Skip to main content

fix(devtools): preserve shared connections during store cleanup#3580 (GitHub)

PR a1fea2e•Base commit: d867e6f•Snapshot context: same as repo snapshot (d867e6f)

Heuristic risk: Low (1 pt)

Rule-based heuristic

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.

tier-a-candidate+0 pts

None of the 3 related file(s) absent from this PR reached Tier A.

tier-b-candidates+1 pt

All 3 related file(s) absent from this PR are Tier B, one level below the Tier A threshold.

many-source-files+0 pts

1 non-test source file(s) changed, below the threshold of 4.

test-coverage-gap+0 pts

No test gap: every changed source file either has no likely test or has a likely test inside this PR.

import-dependents+0 pts

No changed file has 3 or more direct importers in this PR's import context.

cycle-involvement+0 pts

No changed file sits in an import cycle at the pinned snapshot.

Changed files (2)

Source files

Msrc/middleware/devtools.ts
+8-3
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

Mtests/devtools.test.tsx
+134-1
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 },
+          }),
+        }),
+      )
+

Files that historically change together with this PR's changed files in prior commits.

src/middleware/persist.tsTier B (moderate confidence with lift)
Direct import link: no

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.

Related throughsrc/middleware/devtools.ts
Co-change support17 shared commits
Directional confidence31.5% forward / 41.5% reverse

Lift 9.32: this file changes together with the changed file far more often than its overall change rate would suggest.

src/middleware/redux.tsTier B (moderate confidence with lift)
Direct import link: yes

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.

Related throughsrc/middleware/devtools.ts
Co-change support17 shared commits
Directional confidence31.5% forward / 100.0% reverse

Lift 22.48: this file changes together with the changed file far more often than its overall change rate would suggest.

src/middleware/immer.tsTier B (moderate confidence with lift)
Direct import link: no

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.

Related throughsrc/middleware/devtools.ts
Co-change support14 shared commits
Directional confidence25.9% forward / 82.4% reverse

Lift 18.51: this file changes together with the changed file far more often than its overall change rate would suggest.

Standing note: Historical association, not proof that this PR needed to change the file.

Import context (up to two levels)

Anchored on src/middleware/devtools.tsDepth 1 • Imported by
  • src/middleware.ts
  • src/middleware/redux.ts
Anchored on src/middleware/devtools.tsDepth 1 • Imports
  • src/vanilla.ts
Anchored on src/middleware/devtools.tsDepth 2 • Imported by
  • 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)

tests/devtools.test.tsx(matched rule: test-directory-basename)
Already in this PR

Prepared review

Prepared • Stored text from offline analysis, not a live AI response

Diff-grounded observations

src/middleware/devtools.ts
“if (connectionInfo?.stores[store] !== api) return”

Tracked-connection cleanup now bails out unless the stored entry still points at the api being cleaned up.

src/middleware/devtools.ts
“removeStoreFromTrackedConnections(options.name, store, api)”

The removal call passes the api explicitly to removeStoreFromTrackedConnections(options.name, store, api).

src/middleware/devtools.ts
“unsubscribeFromStore = (”

The subscription returned by the connection is assigned to the unsubscribeFromStore variable.

tests/devtools.test.tsx
“expect(connection.unsubscribe).not.toHaveBeenCalled()”

A new test asserts that cleaning up the original store does not unsubscribe the shared connection.

Broader context (not about changed lines)

src/middleware/persist.ts is a related file not in this PR: in history before this PR, 17 of the 54 commits that changed src/middleware/devtools.ts also changed it, and no direct import link was found between them at the PR base.
tests/devtools.test.tsx is the likely test for the changed source file and is part of this PR.
src/middleware.ts and src/middleware/redux.ts import src/middleware/devtools.ts directly at this snapshot, so the changed file has two direct importers in its depth-1 import context.
This PR was analyzed at the pinned repository snapshot, so the cycle signal was evaluated and no changed file sits in an import cycle.
The stored diff excerpt for tests/devtools.test.tsx is truncated (80 of 156 lines), so observations about that file cover only the stored excerpt. Co-change support is historical association, not causation: no candidate should be read as a file this PR was required to change. Prepared text, not a live model response.