@connexis/react 1.0.3 → 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -100,9 +100,9 @@ var useSubscription = (topic, optionsOrHandler, handlerOrUndefined) => {
100
100
  return () => {
101
101
  active = false;
102
102
  if (unsubscribe) {
103
- unsubscribe();
104
- } else if (unsubPromise) {
105
- unsubPromise.then((unsub) => unsub());
103
+ const fn = unsubscribe;
104
+ unsubscribe = null;
105
+ fn();
106
106
  }
107
107
  };
108
108
  }, [client, topic, filterString]);
package/dist/index.mjs CHANGED
@@ -61,9 +61,9 @@ var useSubscription = (topic, optionsOrHandler, handlerOrUndefined) => {
61
61
  return () => {
62
62
  active = false;
63
63
  if (unsubscribe) {
64
- unsubscribe();
65
- } else if (unsubPromise) {
66
- unsubPromise.then((unsub) => unsub());
64
+ const fn = unsubscribe;
65
+ unsubscribe = null;
66
+ fn();
67
67
  }
68
68
  };
69
69
  }, [client, topic, filterString]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@connexis/react",
3
- "version": "1.0.3",
3
+ "version": "1.0.4",
4
4
  "description": "React hooks and provider for @connexis",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.mjs",
@@ -149,4 +149,46 @@ describe('@connexis/react Hooks', () => {
149
149
  await new Promise((resolve) => setTimeout(resolve, 0));
150
150
  expect(mockUnsub).toHaveBeenCalled();
151
151
  });
152
+
153
+ it('should call unsub EXACTLY ONCE when cleanup runs before Promise resolves (React Strict Mode)', async () => {
154
+ // In React Strict Mode, effects are invoked twice: mount → cleanup → remount.
155
+ // The cleanup fires before the subscribe Promise has resolved, so `unsubscribe`
156
+ // is still null when cleanup runs. The previous (buggy) implementation
157
+ // scheduled an extra `.then(unsub => unsub())` in the cleanup path, which —
158
+ // combined with the already-scheduled `.then(unsub => { if (!active) unsub() })`
159
+ // in the mount path — caused `unsub` to be called TWICE, double-decrementing
160
+ // the connection ref-count and silently destroying live connections.
161
+ const client = new MockClient();
162
+ vi.mocked(useContext).mockReturnValue(client);
163
+
164
+ // Keep the Promise pending until we manually resolve it
165
+ let resolveUnsub!: (fn: () => Promise<void>) => void;
166
+ const pendingPromise = new Promise<() => Promise<void>>((res) => {
167
+ resolveUnsub = res;
168
+ });
169
+ client.subscribe.mockReturnValue(pendingPromise);
170
+
171
+ const mockUnsub = vi.fn().mockResolvedValue(undefined);
172
+ let effectCleanup: (() => void) | undefined;
173
+
174
+ vi.mocked(useEffect).mockImplementationOnce((effect) => {
175
+ effectCleanup = effect() as any;
176
+ return undefined;
177
+ });
178
+
179
+ const refObject = { current: null };
180
+ vi.mocked(useRef).mockReturnValue(refObject);
181
+
182
+ useSubscription('ticks', vi.fn());
183
+
184
+ // Simulate Strict Mode: cleanup runs BEFORE the Promise resolves
185
+ if (effectCleanup) effectCleanup();
186
+
187
+ // Now resolve the subscribe Promise
188
+ resolveUnsub(mockUnsub);
189
+ await new Promise((resolve) => setTimeout(resolve, 10));
190
+
191
+ // unsub must be called exactly once — not twice
192
+ expect(mockUnsub).toHaveBeenCalledTimes(1);
193
+ });
152
194
  });
package/src/index.ts CHANGED
@@ -91,6 +91,9 @@ export const useSubscription = <T = any>(
91
91
  }
92
92
  });
93
93
 
94
+ // When the Promise resolves: if we are still active, store the unsubscribe
95
+ // handle; otherwise call it immediately (covers the case where cleanup ran
96
+ // before the Promise resolved, e.g. React Strict Mode double-invocation).
94
97
  unsubPromise.then((unsub) => {
95
98
  if (!active) {
96
99
  unsub();
@@ -102,10 +105,21 @@ export const useSubscription = <T = any>(
102
105
  return () => {
103
106
  active = false;
104
107
  if (unsubscribe) {
105
- unsubscribe();
106
- } else if (unsubPromise) {
107
- unsubPromise.then((unsub) => unsub());
108
+ // Promise already resolved — call the stored handle directly and clear
109
+ // the ref so any subsequent accidental call is a no-op.
110
+ const fn = unsubscribe;
111
+ unsubscribe = null;
112
+ fn();
108
113
  }
114
+ // If the Promise has NOT resolved yet (unsubscribe is null), the .then()
115
+ // handler above will fire with active === false and call unsub() exactly
116
+ // once. We must NOT schedule a second .then() here, because that would
117
+ // result in unsub() being invoked twice:
118
+ // 1. from the original .then() above (active === false → unsub())
119
+ // 2. from the extra .then() below (always → unsub())
120
+ // Double-calling unsubDirect decrements the connection ref-count twice
121
+ // per subscription (22 extra decrements for 11 hooks in Strict Mode),
122
+ // driving it to 0 and silently destroying the live connection.
109
123
  };
110
124
  }, [client, topic, filterString]);
111
125
  };