what-core 0.12.2 → 0.12.3

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/src/data.js CHANGED
@@ -87,9 +87,34 @@ function subscribeToKey(key, revalidateFn) {
87
87
  };
88
88
  }
89
89
 
90
- const inFlightRequests = new Map(); // key -> { promise, timestamp, refCount }
90
+ const inFlightRequests = new Map(); // key -> { promise, timestamp, refCount, epoch }
91
91
  const lastFetchTimestamps = new Map(); // key -> timestamp of last completed fetch
92
92
 
93
+ // How many times each key has been invalidated. This orders invalidations
94
+ // against in-flight requests, which a wall clock cannot: Date.now() has 1ms
95
+ // resolution, so a refetch and an unrelated invalidation issued microseconds
96
+ // apart share a timestamp, and the invalidation would be answered by a request
97
+ // that predates the mutation. A counter has no ties.
98
+ const keyEpochs = new Map();
99
+
100
+ function currentEpoch(key) {
101
+ return keyEpochs.get(key) || 0;
102
+ }
103
+
104
+ function bumpEpoch(key) {
105
+ keyEpochs.set(key, currentEpoch(key) + 1);
106
+ }
107
+
108
+ // Every timer this module arms is housekeeping or polling. None of it is work
109
+ // worth keeping a Node process alive for, and on the server the usual owner of a
110
+ // timer (a component that unmounts) does not exist, so nothing ever clears them:
111
+ // an SSR render that touched one query pinned the event loop for minutes and kept
112
+ // firing the query function long after the HTML had been sent.
113
+ function unrefTimer(timer) {
114
+ if (timer && typeof timer.unref === 'function') timer.unref();
115
+ return timer;
116
+ }
117
+
93
118
  // Create an effect scoped to the current component's lifecycle.
94
119
  // When the component unmounts, the effect is automatically disposed.
95
120
  function scopedEffect(fn) {
@@ -212,13 +237,33 @@ export function useSWR(key, fetcher, options = {}) {
212
237
 
213
238
  let abortController = null;
214
239
 
215
- async function revalidate() {
240
+ // `force` is invalidateQueries(): "this data is wrong now". It bypasses the
241
+ // FRESHNESS window, which is the one caller that must never be answered from
242
+ // it (with the default 2s dedupingInterval an invalidation issued right after
243
+ // a fetch was silently swallowed and the stale data stayed on screen).
244
+ //
245
+ // It does NOT bypass request COALESCING, which is a different mechanism
246
+ // wearing a similar name. Every component reading a key subscribes
247
+ // separately, so one invalidateQueries() call fans out to N subscribers; the
248
+ // in-flight map is what collapses those back into one request. Skipping it
249
+ // opened N concurrent fetches of the same key whose responses then raced to
250
+ // write the cache.
251
+ //
252
+ // What force does change is WHICH in-flight request is acceptable. A response
253
+ // to a request that started before the data was invalidated is already stale,
254
+ // so it cannot answer the invalidation. One that started after it is a
255
+ // sibling subscriber's, and is exactly what we want to join.
256
+ async function revalidate({ force = false } = {}) {
216
257
  const now = Date.now();
258
+ const epoch = currentEpoch(key);
217
259
 
218
260
  // Deduplication: if there's already a request in flight, reuse it
219
261
  if (inFlightRequests.has(key)) {
220
262
  const existing = inFlightRequests.get(key);
221
- if (now - existing.timestamp < dedupingInterval) {
263
+ const usable = force
264
+ ? existing.epoch === epoch
265
+ : now - existing.timestamp < dedupingInterval;
266
+ if (usable) {
222
267
  existing.refCount++;
223
268
  return existing.promise;
224
269
  }
@@ -226,7 +271,7 @@ export function useSWR(key, fetcher, options = {}) {
226
271
 
227
272
  // Also deduplicate against recently completed fetches
228
273
  const lastFetch = lastFetchTimestamps.get(key);
229
- if (lastFetch && now - lastFetch < dedupingInterval && cacheS.peek() != null) {
274
+ if (!force && lastFetch && now - lastFetch < dedupingInterval && cacheS.peek() != null) {
230
275
  return cacheS.peek();
231
276
  }
232
277
 
@@ -243,7 +288,7 @@ export function useSWR(key, fetcher, options = {}) {
243
288
  isValidating.set(true);
244
289
 
245
290
  const promise = fetcher(key, { signal: abortSignal });
246
- inFlightRequests.set(key, { promise, timestamp: now, refCount: 1 });
291
+ inFlightRequests.set(key, { promise, timestamp: now, refCount: 1, epoch });
247
292
 
248
293
  try {
249
294
  const result = await promise;
@@ -271,11 +316,16 @@ export function useSWR(key, fetcher, options = {}) {
271
316
  }
272
317
  }
273
318
 
274
- // Subscribe to invalidation events for this key
275
- const unsubscribe = subscribeToKey(key, () => revalidate().catch(() => {}));
276
-
277
- // Initial fetch
319
+ // Initial fetch, plus the invalidation subscription for this key.
320
+ //
321
+ // The subscription lives INSIDE the effect. It used to be created once
322
+ // outside, while the effect's cleanup tore it down, and this effect re-runs
323
+ // whenever the fetcher reads a signal that changed. So the first reactive
324
+ // refetch unsubscribed the key and never resubscribed: from then on
325
+ // invalidateQueries() had no subscriber to call and silently did nothing.
326
+ // Re-subscribing per run keeps the two halves in the same lifecycle.
278
327
  scopedEffect(() => {
328
+ const unsubscribe = subscribeToKey(key, () => revalidate({ force: true }).catch(() => {}));
279
329
  revalidate().catch(() => {});
280
330
  // Cleanup: abort and unsubscribe on unmount
281
331
  return () => {
@@ -309,9 +359,9 @@ export function useSWR(key, fetcher, options = {}) {
309
359
  // Polling
310
360
  if (refreshInterval > 0) {
311
361
  scopedEffect(() => {
312
- const interval = setInterval(() => {
362
+ const interval = unrefTimer(setInterval(() => {
313
363
  revalidate().catch(() => {});
314
- }, refreshInterval);
364
+ }, refreshInterval));
315
365
  return () => clearInterval(interval);
316
366
  });
317
367
  }
@@ -367,13 +417,17 @@ export function useQuery(options) {
367
417
 
368
418
  let lastFetchTime = 0;
369
419
  let abortController = null;
420
+ let cleanupTimer = null;
370
421
 
371
- async function fetchQuery() {
422
+ // See the note on useSWR's revalidate: an invalidation must not be answered
423
+ // from the freshness window, or `invalidateQueries` becomes a no-op for every
424
+ // query with a staleTime.
425
+ async function fetchQuery({ force = false } = {}) {
372
426
  if (!enabled) return;
373
427
 
374
428
  // Check if data is still fresh
375
429
  const now = Date.now();
376
- if (cacheS.peek() != null && now - lastFetchTime < staleTime) {
430
+ if (!force && cacheS.peek() != null && now - lastFetchTime < staleTime) {
377
431
  return cacheS.peek();
378
432
  }
379
433
 
@@ -405,8 +459,13 @@ export function useQuery(options) {
405
459
  if (onSuccess) onSuccess(result);
406
460
  if (onSettled) onSettled(result, null);
407
461
 
408
- // Schedule cache cleanup (only if no active subscribers)
409
- setTimeout(() => {
462
+ // Schedule cache cleanup (only if no active subscribers).
463
+ //
464
+ // Replaces the previous timer instead of arming another: every
465
+ // successful fetch used to add one and clear none, so a polling query
466
+ // accumulated pending Timeout objects at roughly (fetch rate x cacheTime).
467
+ if (cleanupTimer) clearTimeout(cleanupTimer);
468
+ cleanupTimer = unrefTimer(setTimeout(() => {
410
469
  if (Date.now() - lastFetchTime >= cacheTime) {
411
470
  const subs = revalidationSubscribers.get(key);
412
471
  if (!subs || subs.size === 0) {
@@ -417,7 +476,7 @@ export function useQuery(options) {
417
476
  lastFetchTimestamps.delete(key);
418
477
  }
419
478
  }
420
- }, cacheTime);
479
+ }, cacheTime));
421
480
 
422
481
  return result;
423
482
  } catch (e) {
@@ -426,7 +485,7 @@ export function useQuery(options) {
426
485
  if (attempts < retry) {
427
486
  // Abort-aware retry delay: cancel the wait if the component unmounts
428
487
  await new Promise((resolve, reject) => {
429
- const id = setTimeout(resolve, retryDelay(attempts));
488
+ const id = unrefTimer(setTimeout(resolve, retryDelay(attempts)));
430
489
  abortSignal.addEventListener('abort', () => {
431
490
  clearTimeout(id);
432
491
  reject(new DOMException('Aborted', 'AbortError'));
@@ -452,11 +511,13 @@ export function useQuery(options) {
452
511
  return attemptFetch();
453
512
  }
454
513
 
455
- // Subscribe to invalidation events for this key
456
- const unsubscribe = subscribeToKey(key, () => fetchQuery().catch(() => {}));
457
-
458
- // Initial fetch
514
+ // Initial fetch, plus the invalidation subscription for this key.
515
+ // Subscribing inside the effect is deliberate: see the matching comment in
516
+ // useSWR above. A query whose queryFn reads a signal re-runs this effect, and
517
+ // a subscription created once outside it was cancelled by the first such
518
+ // re-run, leaving the query permanently deaf to invalidateQueries().
459
519
  scopedEffect(() => {
520
+ const unsubscribe = subscribeToKey(key, () => fetchQuery({ force: true }).catch(() => {}));
460
521
  if (enabled) {
461
522
  fetchQuery().catch(() => {});
462
523
  }
@@ -482,9 +543,9 @@ export function useQuery(options) {
482
543
  // Polling
483
544
  if (refetchInterval) {
484
545
  scopedEffect(() => {
485
- const interval = setInterval(() => {
546
+ const interval = unrefTimer(setInterval(() => {
486
547
  fetchQuery().catch(() => {});
487
- }, refetchInterval);
548
+ }, refetchInterval));
488
549
  return () => clearInterval(interval);
489
550
  });
490
551
  }
@@ -647,6 +708,10 @@ export function invalidateQueries(keyOrPredicate, options = {}) {
647
708
  }
648
709
 
649
710
  for (const key of keysToInvalidate) {
711
+ // Before notifying anyone: every subscriber woken below reads this epoch, so
712
+ // they agree on one refetch, and any request already in flight is now a
713
+ // generation behind and cannot answer for them.
714
+ bumpEpoch(key);
650
715
  // Hard invalidation clears data immediately (shows loading state)
651
716
  // Soft invalidation (default) keeps stale data visible during re-fetch (SWR pattern)
652
717
  if (hard && cacheSignals.has(key)) cacheSignals.get(key).set(null);
@@ -688,6 +753,7 @@ export function clearCache() {
688
753
  cacheTimestamps.clear();
689
754
  lastFetchTimestamps.clear();
690
755
  inFlightRequests.clear();
756
+ keyEpochs.clear();
691
757
  }
692
758
 
693
759
  /**
package/src/dom.js CHANGED
@@ -405,6 +405,58 @@ export function getComponentStack() {
405
405
  return componentStack;
406
406
  }
407
407
 
408
+ /**
409
+ * Run a component during SSR under a real component context.
410
+ *
411
+ * renderToString used to call `vnode.tag(props)` directly, with nothing on the
412
+ * component stack. Every hook that needs a context (useState, useSignal,
413
+ * useComputed, useEffect, useMemo, useCallback, useRef, useReducer, onMount,
414
+ * onCleanup, and Context.Provider) resolves it through getCurrentComponent(),
415
+ * so all of them threw on the server. A single useState anywhere in the tree
416
+ * meant the component could not be server-rendered at all: the page failed at
417
+ * render time, not with a hydration warning.
418
+ *
419
+ * The context is the same shape createComponent and the hydration path build,
420
+ * for the same reason: `useContext` walks `_parentCtx`, so a Provider's context
421
+ * has to stay on the stack while its children render. Hence the callback.
422
+ *
423
+ * Nothing here ever mounts, so nothing deferred may run. Every hook that defers
424
+ * work (useEffect in all three of its dep shapes) re-checks `ctx.disposed`
425
+ * inside its microtask, and onMount/onCleanup only collect callbacks that a
426
+ * mount would later invoke. _endComponentSSR marks the context disposed, which
427
+ * is what makes an SSR render leave no live effects behind.
428
+ *
429
+ * Begin/end rather than a wrapper callback because one of the three SSR call
430
+ * sites is a generator: renderToStream has to hold the frame open across yields
431
+ * until the subtree has finished streaming.
432
+ *
433
+ * Always pair these in a try/finally.
434
+ */
435
+ export function _beginComponentSSR(Component) {
436
+ const ctx = {
437
+ hooks: [],
438
+ hookIndex: 0,
439
+ effects: [],
440
+ cleanups: [],
441
+ mounted: false,
442
+ disposed: false,
443
+ Component,
444
+ _parentCtx: componentStack[componentStack.length - 1] || null,
445
+ _errorBoundary: null,
446
+ };
447
+ componentStack.push(ctx);
448
+ return ctx;
449
+ }
450
+
451
+ export function _endComponentSSR(ctx) {
452
+ const top = componentStack[componentStack.length - 1];
453
+ // Defensive: an async component that interleaved with another render could
454
+ // otherwise pop someone else's frame and silently reparent every context
455
+ // lookup after it.
456
+ if (top === ctx) componentStack.pop();
457
+ ctx.disposed = true;
458
+ }
459
+
408
460
  // --- _installLazyChildren(Component, target, lazyChildren) ---
409
461
  // Deferred children from compiled JSX arrive as a zero-arg factory instead of
410
462
  // built DOM. This defines target.children over that factory and returns a
package/src/index.js CHANGED
@@ -6,6 +6,9 @@ export { signal, computed, effect, memo as signalMemo, batch, untrack, flushSync
6
6
 
7
7
  // Fine-grained rendering primitives
8
8
  export { template, _template, _$template, svgTemplate, insert, mapArray, spread, setProp, delegateEvents, on, classList, hydrate, isHydrating, _$createComponent } from './render.js';
9
+ // Internal, underscore-prefixed: SSR has no DOM to run a mapArray inserter
10
+ // against, so it renders the rows from the inserter's inputs instead.
11
+ export { _mapArrayToArray } from './render.js';
9
12
 
10
13
  // JSX factory — Fragment and html tagged template are public APIs.
11
14
  // h is exported for internal package use only (jsx-runtime, server, router, react-compat).
@@ -17,6 +20,9 @@ export { mount } from './dom.js';
17
20
  // Internal, underscore-prefixed: shared so the client, compiled-JSX and SSR
18
21
  // attribute paths cannot disagree about ARIA serialization again.
19
22
  export { _isAriaAttr } from './dom.js';
23
+ // Internal, underscore-prefixed: the component stack lives here, so SSR has to
24
+ // borrow it rather than build a second one that hooks cannot see.
25
+ export { _beginComponentSSR, _endComponentSSR } from './dom.js';
20
26
 
21
27
  // Hooks (React-compatible API)
22
28
  export {