@times-components/ts-components 1.172.0 → 1.172.1

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.
@@ -399,4 +399,66 @@ describe('useFetchFeed', () => {
399
399
  // 1 initial + 1 poll = 2
400
400
  expect(global.fetch).toHaveBeenCalledTimes(2);
401
401
  });
402
+
403
+ it('stops polling when the only polling subscriber unsubscribes', async () => {
404
+ const mockData = { data: 'test' };
405
+ global.fetch = jest.fn(() => mockJsonResponse(mockData));
406
+
407
+ const url = uniqueUrl('unsub-poll');
408
+
409
+ // Wrapper that lets us toggle the polling subscriber on/off.
410
+ const Wrapper: React.FC<{ showPoller: boolean }> = ({ showPoller }) => (
411
+ <>
412
+ {/* Non-polling subscriber — always mounted */}
413
+ <HookRenderer
414
+ hook={() => useFetchFeed(url)}
415
+ onState={() => {
416
+ /* noop */
417
+ }}
418
+ />
419
+ {/* Polling subscriber — conditionally mounted */}
420
+ {showPoller && (
421
+ <HookRenderer
422
+ hook={() => useFetchFeed(url, { pollingInterval: 5000 })}
423
+ onState={() => {
424
+ /* noop */
425
+ }}
426
+ />
427
+ )}
428
+ </>
429
+ );
430
+
431
+ let result: any;
432
+ await act(async () => {
433
+ result = render(<Wrapper showPoller={true} />);
434
+ await Promise.resolve();
435
+ await Promise.resolve();
436
+ await Promise.resolve();
437
+ });
438
+
439
+ expect(global.fetch).toHaveBeenCalledTimes(1);
440
+
441
+ // Confirm polling is active.
442
+ await act(async () => {
443
+ jest.advanceTimersByTime(5000);
444
+ await Promise.resolve();
445
+ await Promise.resolve();
446
+ await Promise.resolve();
447
+ });
448
+ expect(global.fetch).toHaveBeenCalledTimes(2);
449
+
450
+ // Unmount the polling subscriber.
451
+ await act(async () => {
452
+ result.rerender(<Wrapper showPoller={false} />);
453
+ });
454
+
455
+ // Advance timers — no more fetches should happen.
456
+ await act(async () => {
457
+ jest.advanceTimersByTime(15000);
458
+ await Promise.resolve();
459
+ await Promise.resolve();
460
+ });
461
+
462
+ expect(global.fetch).toHaveBeenCalledTimes(2);
463
+ });
402
464
  });
@@ -35,8 +35,8 @@ type CacheEntry = {
35
35
  pollTimer: ReturnType<typeof setInterval> | null;
36
36
  /** The polling interval (ms) requested by subscribers. Uses the shortest. */
37
37
  pollInterval: number | undefined;
38
- /** Set of setState dispatchers so every subscriber re-renders on update. */
39
- listeners: Set<(s: FetchState<any>) => void>;
38
+ /** Map of setState dispatchers polling interval (undefined = no polling). */
39
+ listeners: Map<(s: FetchState<any>) => void, number | undefined>;
40
40
  };
41
41
 
42
42
  const cache = new Map<string, CacheEntry>();
@@ -53,7 +53,7 @@ function getOrCreateEntry(url: string): CacheEntry {
53
53
  subscribers: 0,
54
54
  pollTimer: null,
55
55
  pollInterval: undefined,
56
- listeners: new Set()
56
+ listeners: new Map()
57
57
  };
58
58
  cache.set(url, entry);
59
59
  }
@@ -67,7 +67,7 @@ function notify(entry: CacheEntry): void {
67
67
  loading: entry.fetching,
68
68
  error: entry.error
69
69
  };
70
- entry.listeners.forEach(fn => fn(snapshot));
70
+ entry.listeners.forEach((_, fn) => fn(snapshot));
71
71
  }
72
72
 
73
73
  /** Execute a single fetch for `url`, de-duplicating in-flight requests. */
@@ -184,7 +184,7 @@ export const useFetchFeed = <T>(
184
184
  // Stable listener wrapper that always calls the latest setState.
185
185
  const listener = (s: FetchState<any>) =>
186
186
  setStateRef.current(s as FetchState<T>);
187
- entry.listeners.add(listener);
187
+ entry.listeners.set(listener, pollingInterval);
188
188
 
189
189
  // If another subscriber already has data, sync immediately.
190
190
  if (entry.data !== undefined) {
@@ -195,14 +195,16 @@ export const useFetchFeed = <T>(
195
195
  });
196
196
  }
197
197
 
198
- // Configure polling use the shortest interval requested.
199
- if (pollingInterval && pollingInterval > 0) {
200
- const needsRestart =
201
- entry.pollInterval === undefined ||
202
- pollingInterval < entry.pollInterval;
203
- if (needsRestart) {
198
+ // Recalculate polling from all listeners.
199
+ {
200
+ const intervals = Array.from(entry.listeners.values()).filter(
201
+ (v): v is number => v != null && v > 0
202
+ );
203
+ const shortest =
204
+ intervals.length > 0 ? Math.min(...intervals) : undefined;
205
+ if (shortest !== entry.pollInterval) {
204
206
  stopPolling(entry);
205
- entry.pollInterval = pollingInterval;
207
+ entry.pollInterval = shortest;
206
208
  }
207
209
  }
208
210
 
@@ -217,10 +219,25 @@ export const useFetchFeed = <T>(
217
219
  if (entry.subscribers <= 0) {
218
220
  // Last subscriber unmounted — clean up everything.
219
221
  stopPolling(entry);
222
+ entry.pollInterval = undefined;
220
223
  if (entry.controller) {
221
224
  entry.controller.abort();
222
225
  }
223
226
  cache.delete(url);
227
+ } else {
228
+ // Recalculate polling from remaining subscribers.
229
+ const remaining = Array.from(entry.listeners.values()).filter(
230
+ (v): v is number => v != null && v > 0
231
+ );
232
+ const shortest =
233
+ remaining.length > 0 ? Math.min(...remaining) : undefined;
234
+ if (shortest !== entry.pollInterval) {
235
+ stopPolling(entry);
236
+ entry.pollInterval = shortest;
237
+ if (shortest) {
238
+ ensurePolling(url);
239
+ }
240
+ }
224
241
  }
225
242
  };
226
243
  },