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/dist/chunk-JVEPLFIB.min.js +11 -0
- package/dist/{chunk-NCPX66TV.min.js → chunk-VTPLA4AS.min.js} +1 -1
- package/dist/index.min.js +5 -5
- package/dist/render.min.js +1 -1
- package/dist/testing.min.js +1 -1
- package/package.json +1 -1
- package/src/agent-context.js +1 -1
- package/src/data.js +89 -23
- package/src/dom.js +52 -0
- package/src/index.js +6 -0
- package/src/render.js +394 -41
- package/dist/chunk-RXISSKLI.min.js +0 -11
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
//
|
|
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 {
|