Heuristic risk: Low (1 pt)
This is a rule-based heuristic, not a probability and not a defect prediction. The risk label and the related-file list are separate signals.
None of the 3 related file(s) absent from this PR reached Tier A.
All 3 related file(s) absent from this PR are Tier B, one level below the Tier A threshold.
1 non-test source file(s) changed, below the threshold of 4.
No test gap: every changed source file either has no likely test or has a likely test inside this PR.
No changed file has 3 or more direct importers in this PR's import context.
No changed file sits in an import cycle at the pinned snapshot.
Changed files (2)
Source files
View diff excerpt (48 lines)
diff --git a/src/middleware/devtools.ts b/src/middleware/devtools.ts
index cc4561c..0038d77 100644
--- a/src/middleware/devtools.ts
+++ b/src/middleware/devtools.ts
@@ -156,10 +156,11 @@ const extractConnectionInformation = (
const removeStoreFromTrackedConnections = (
name: string | undefined,
store: string | undefined,
+ api: StoreInformation,
) => {
if (store === undefined) return
const connectionInfo = trackedConnections.get(name)
- if (!connectionInfo) return
+ if (connectionInfo?.stores[store] !== api) return
delete connectionInfo.stores[store]
if (Object.keys(connectionInfo.stores).length === 0) {
trackedConnections.delete(name)
@@ -243,15 +244,19 @@ const devtoolsImpl: DevtoolsImpl =
)
return r
}) as NamedSet<S>
+ let unsubscribeFromStore: (() => void) | undefined
;(api as StoreApi<S> & StoreDevtools<S>).devtools = {
cleanup: () => {
+ unsubscribeFromStore?.()
+ unsubscribeFromStore = undefined
+ removeStoreFromTrackedConnections(options.name, store, api)
if (
+ (store === undefined || !trackedConnections.has(options.name)) &&
connection &&
typeof (connection as any).unsubscribe === 'function'
) {
;(connection as any).unsubscribe()
}
- removeStoreFromTrackedConnections(options.name, store)
},
}
@@ -298,7 +303,7 @@ const devtoolsImpl: DevtoolsImpl =
}
}
- ;(
+ unsubscribeFromStore = (
connection as unknown as {
// FIXME https://github.com/reduxjs/redux-devtools/issues/1097
subscribe: (
Test files
View diff excerpt (showing 80 of 156 lines)
Truncated excerpt: showing 80 of 156 lines
diff --git a/tests/devtools.test.tsx b/tests/devtools.test.tsx
index 3778ab9..f4d8554 100644
--- a/tests/devtools.test.tsx
+++ b/tests/devtools.test.tsx
@@ -96,9 +96,12 @@ const extensionConnector = {
const api: Connection['api'] = {
subscribe: vi.fn((f: (m: unknown) => void) => {
subscribers.push(f)
- return () => {}
+ return () => {
+ subscribers.splice(subscribers.indexOf(f), 1)
+ }
}),
unsubscribe: vi.fn(() => {
+ subscribers.splice(0)
connectionMap.delete(
areNameUndefinedMapsNeeded ? options.testConnectionId : key,
)
@@ -2687,6 +2690,136 @@ describe('when create devtools was called multiple times with `name` and `store`
})
describe('cleanup', () => {
+ it('should preserve a replacement store when the original store is cleaned up', () => {
+ const options = {
+ name: 'replacement-before-cleanup',
+ store: 'shared',
+ enabled: true,
+ }
+ const original = createStore(devtools(() => ({ count: 0 }), options))
+ const replacement = createStore(devtools(() => ({ count: 0 }), options))
+ const [connection] = getNamedConnectionApis(options.name)
+
+ original.devtools.cleanup()
+
+ expect(connection.unsubscribe).not.toHaveBeenCalled()
+ const [subscriber] = getNamedConnectionSubscribers(options.name)
+ subscriber({
+ type: 'DISPATCH',
+ payload: { type: 'JUMP_TO_STATE' },
+ state: JSON.stringify({ shared: { count: 20 } }),
+ })
+ expect(replacement.getState()).toEqual({ count: 20 })
+ expect(original.getState()).toEqual({ count: 0 })
+
+ original.devtools.cleanup()
+ expect(connection.unsubscribe).not.toHaveBeenCalled()
+ replacement.devtools.cleanup()
+ expect(connection.unsubscribe).toHaveBeenCalledTimes(1)
+ })
+
+ it.each([1, 2])(
+ 'should preserve other stores on a shared connection after %i cleanup calls',
+ (cleanupCalls) => {
+ const name = `shared-cleanup-${cleanupCalls}`
+ const store1 = createStore(
+ devtools(() => ({ count: 0 }), { name, store: 'first', enabled: true }),
+ )
+ const store2 = createStore(
+ devtools(() => ({ count: 0 }), {
+ name,
+ store: 'second',
+ enabled: true,
+ }),
+ )
+ const subscribers = namedConnections.get(name)!.subscribers
+
+ for (let i = 0; i < cleanupCalls; i++) {
+ store1.devtools.cleanup()
+ }
+ subscribers.forEach((subscriber) =>
+ subscriber({
+ type: 'DISPATCH',
+ payload: { type: 'JUMP_TO_STATE' },
+ state: JSON.stringify({
+ first: { count: 10 },
+ second: { count: 20 },
+ }),
+ }),
+ )
+Related files not in this PR (3)
Files that historically change together with this PR's changed files in prior commits.
In history before this PR, 17 of the 54 commits that changed src/middleware/devtools.ts also changed src/middleware/persist.ts. The other direction: 17 of the 41 commits that changed src/middleware/persist.ts also changed src/middleware/devtools.ts. No direct import link was found between them at the PR base snapshot. 2 changed files in this PR point at it.
Lift 9.32: this file changes together with the changed file far more often than its overall change rate would suggest.
In history before this PR, 17 of the 54 commits that changed src/middleware/devtools.ts also changed src/middleware/redux.ts. The other direction: 17 of the 17 commits that changed src/middleware/redux.ts also changed src/middleware/devtools.ts. A direct import link exists between them at the PR base snapshot. 2 changed files in this PR point at it.
Lift 22.48: this file changes together with the changed file far more often than its overall change rate would suggest.
In history before this PR, 14 of the 54 commits that changed src/middleware/devtools.ts also changed src/middleware/immer.ts. The other direction: 14 of the 17 commits that changed src/middleware/immer.ts also changed src/middleware/devtools.ts. No direct import link was found between them at the PR base snapshot. 2 changed files in this PR point at it.
Lift 18.51: this file changes together with the changed file far more often than its overall change rate would suggest.
Import context (up to two levels)
- src/middleware.ts
- src/middleware/redux.ts
- src/vanilla.ts
- src/index.ts(via src/vanilla.ts)
- src/middleware/combine.ts(via src/vanilla.ts)
- src/middleware/immer.ts(via src/vanilla.ts)
- src/middleware/persist.ts(via src/vanilla.ts)
- src/middleware/ssrSafe.ts(via src/vanilla.ts)
- src/middleware/subscribeWithSelector.ts(via src/vanilla.ts)
- src/react.ts(via src/vanilla.ts)
- src/traditional.ts(via src/vanilla.ts)
Suggested tests (filename heuristic)
Prepared review
Prepared • Stored text from offline analysis, not a live AI responseDiff-grounded observations
“if (connectionInfo?.stores[store] !== api) return”
Tracked-connection cleanup now bails out unless the stored entry still points at the api being cleaned up.
“removeStoreFromTrackedConnections(options.name, store, api)”
The removal call passes the api explicitly to removeStoreFromTrackedConnections(options.name, store, api).
“unsubscribeFromStore = (”
The subscription returned by the connection is assigned to the unsubscribeFromStore variable.
“expect(connection.unsubscribe).not.toHaveBeenCalled()”
A new test asserts that cleaning up the original store does not unsubscribe the shared connection.