@rangojs/router 0.5.0 → 0.5.2

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.
Files changed (65) hide show
  1. package/README.md +5 -1
  2. package/dist/types/browser/event-controller.d.ts +6 -0
  3. package/dist/types/browser/prefetch/cache.d.ts +31 -2
  4. package/dist/types/cache/cf/cf-cache-constants.d.ts +8 -1
  5. package/dist/types/cache/cf/cf-cache-store.d.ts +15 -1
  6. package/dist/types/cache/cf/cf-cache-types.d.ts +1 -1
  7. package/dist/types/cache/cf/cf-kv-utils.d.ts +21 -0
  8. package/dist/types/handles/is-thenable.d.ts +2 -4
  9. package/dist/types/route-definition/helpers-types.d.ts +5 -4
  10. package/dist/types/rsc/helpers.d.ts +3 -0
  11. package/dist/types/rsc/render-pipeline.d.ts +9 -0
  12. package/dist/types/rsc/routine-plan.d.ts +124 -0
  13. package/dist/types/rsc/shell-capture-constants.d.ts +17 -0
  14. package/dist/types/server/request-context.d.ts +4 -1
  15. package/dist/types/static-handler.d.ts +15 -1
  16. package/dist/types/urls/path-helper-types.d.ts +6 -7
  17. package/dist/types/vite/encryption-key.d.ts +2 -0
  18. package/dist/types/vite/plugins/expose-internal-ids.d.ts +10 -0
  19. package/dist/types/vite/plugins/server-ref-hashing.d.ts +24 -0
  20. package/dist/types/vite/plugins/server-reference-pattern.d.ts +1 -0
  21. package/dist/types/vite/utils/shared-utils.d.ts +12 -0
  22. package/dist/vite/index.js +154 -57
  23. package/package.json +3 -3
  24. package/skills/caching/SKILL.md +1 -1
  25. package/skills/mime-routes/SKILL.md +3 -1
  26. package/skills/parallel/SKILL.md +1 -1
  27. package/skills/response-routes/SKILL.md +4 -2
  28. package/skills/typesafety/generated-files-and-cli.md +16 -9
  29. package/skills/typesafety/route-types.md +5 -1
  30. package/skills/use-cache/SKILL.md +47 -0
  31. package/src/browser/event-controller.ts +40 -15
  32. package/src/browser/partial-update.ts +26 -11
  33. package/src/browser/prefetch/cache.ts +53 -9
  34. package/src/browser/prefetch/fetch.ts +83 -2
  35. package/src/browser/react/NavigationProvider.tsx +7 -0
  36. package/src/cache/cache-runtime.ts +89 -15
  37. package/src/cache/cf/cf-cache-constants.ts +8 -1
  38. package/src/cache/cf/cf-cache-store.ts +41 -64
  39. package/src/cache/cf/cf-cache-types.ts +1 -1
  40. package/src/cache/cf/cf-kv-utils.ts +38 -0
  41. package/src/cache/segment-codec.ts +21 -5
  42. package/src/handles/is-thenable.ts +2 -4
  43. package/src/route-definition/helpers-types.ts +5 -3
  44. package/src/router/match-middleware/cache-lookup.ts +24 -15
  45. package/src/rsc/handler.ts +26 -5
  46. package/src/rsc/helpers.ts +13 -0
  47. package/src/rsc/progressive-enhancement.ts +247 -70
  48. package/src/rsc/render-pipeline.ts +68 -24
  49. package/src/rsc/routine-plan.ts +359 -0
  50. package/src/rsc/rsc-rendering.ts +555 -302
  51. package/src/rsc/server-action.ts +180 -71
  52. package/src/rsc/shell-capture-constants.ts +18 -0
  53. package/src/rsc/shell-capture.ts +92 -21
  54. package/src/server/request-context.ts +6 -0
  55. package/src/ssr/ssr-root.tsx +1 -0
  56. package/src/static-handler.ts +18 -2
  57. package/src/urls/path-helper-types.ts +9 -10
  58. package/src/vite/encryption-key.ts +29 -0
  59. package/src/vite/plugins/expose-action-id.ts +2 -2
  60. package/src/vite/plugins/expose-internal-ids.ts +46 -0
  61. package/src/vite/plugins/server-ref-hashing.ts +74 -0
  62. package/src/vite/plugins/server-reference-pattern.ts +10 -0
  63. package/src/vite/rango.ts +9 -0
  64. package/src/vite/router-discovery.ts +21 -2
  65. package/src/vite/utils/shared-utils.ts +12 -7
@@ -307,6 +307,53 @@ intercept("@modal", ".product", async (ctx) => {
307
307
  }),
308
308
  ```
309
309
 
310
+ ## Embedding server actions in cached components
311
+
312
+ A cached function may return a component that creates an inline `"use server"`
313
+ action. The canonical case is a cached list whose items each carry an action --
314
+ e.g. a cached article list where every row has a like button:
315
+
316
+ ```typescript
317
+ export async function ArticleList() {
318
+ "use cache: articles";
319
+ const articles = await db.query("SELECT id, title FROM articles");
320
+ return (
321
+ <ul>
322
+ {articles.map((a) => {
323
+ const articleId = a.id; // captured -> frozen with the cache entry
324
+ async function like() {
325
+ "use server";
326
+ // runs live on click, in the CURRENT request scope
327
+ const user = cookies().get("session")?.value;
328
+ if (user) await db.like(articleId, user);
329
+ }
330
+ return <LikeButton key={a.id} title={a.title} action={like} />;
331
+ })}
332
+ </ul>
333
+ );
334
+ }
335
+ ```
336
+
337
+ Three behaviors, locked by `use-cache-inline-action.test.ts` (dev + prod):
338
+
339
+ | What | Behavior | Why |
340
+ | ----------------------------------------------- | ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
341
+ | Values the action **closes over** (`articleId`) | **Frozen** at cache-write | The closure compiles to encrypted bound args, snapshotted when the entry is written and replayed verbatim on a hit. Correct for stable identities; wrong for volatile values. |
342
+ | The action **body** | **Runs live** every call | Once invoked it is an ordinary server function: fresh computation and live request context. `cookies()` / `headers()` work here (the body executes in the live request). |
343
+ | Invocability on a **cache hit** | **Works** | The action survives serialize -> cache -> deserialize and stays callable. |
344
+
345
+ Rule of thumb: **capture stable identities, read volatile/request-scoped values
346
+ live in the body.** Do not close over a per-request token, the current user, or
347
+ the current time and expect freshness -- those are frozen at cache-write. The
348
+ `cookies()`/`headers()` read guard applies to the cached function body, NOT to an
349
+ inline action's body.
350
+
351
+ Deploy note: captured values are encrypted with a per-build key by default. If
352
+ cache entries can outlive a deploy (a persistent store such as the Cloudflare
353
+ cache store), set `RANGO_ENCRYPTION_KEY` (base64-encoded 32 bytes) in the build
354
+ environment so actions embedded in entries written by a previous deploy still
355
+ decrypt; without it they fail until the entry expires or revalidates.
356
+
310
357
  ## Vite Transform
311
358
 
312
359
  The `rango:use-cache` Vite plugin detects the directive and wraps exports with
@@ -234,6 +234,12 @@ export interface EventController {
234
234
  listener: ActionStateListener,
235
235
  ): () => void;
236
236
  subscribeToHandles(listener: HandleListener): () => void;
237
+ /**
238
+ * Deliver queued location, params, navigation, and handle notifications now.
239
+ * NavigationProvider calls this inside the payload update so React assigns
240
+ * every route-state hook update to the same normal or transition lane.
241
+ */
242
+ flushRouteState(): void;
237
243
 
238
244
  // Handle operations
239
245
  setHandleData(
@@ -367,19 +373,32 @@ function matchesActionId(
367
373
  return entryActionId.endsWith(`#${subscriptionId}`);
368
374
  }
369
375
 
370
- // Batch rapid notifications into one microtask to prevent render storms
371
- function makeDebouncedNotifier(listeners: Set<() => void>): () => void {
376
+ interface DebouncedNotifier {
377
+ schedule(): void;
378
+ flush(): void;
379
+ }
380
+
381
+ // Batch rapid notifications into one task to prevent render storms. The
382
+ // explicit flush lets a React update owner preserve its scheduling lane.
383
+ function makeDebouncedNotifier(listeners: Set<() => void>): DebouncedNotifier {
372
384
  let timeout: ReturnType<typeof setTimeout> | null = null;
373
- return () => {
374
- if (timeout !== null) clearTimeout(timeout);
375
- timeout = setTimeout(() => {
376
- timeout = null;
377
- notifyListeners(
378
- [...listeners],
379
- (listener) => listener(),
380
- (listener) => listeners.has(listener),
381
- );
382
- }, 0);
385
+ const flush = () => {
386
+ if (timeout === null) return;
387
+ clearTimeout(timeout);
388
+ timeout = null;
389
+ notifyListeners(
390
+ [...listeners],
391
+ (listener) => listener(),
392
+ (listener) => listeners.has(listener),
393
+ );
394
+ };
395
+
396
+ return {
397
+ schedule() {
398
+ if (timeout !== null) clearTimeout(timeout);
399
+ timeout = setTimeout(flush, 0);
400
+ },
401
+ flush,
383
402
  };
384
403
  }
385
404
 
@@ -459,7 +478,7 @@ export function createEventController(
459
478
 
460
479
  function notify(): void {
461
480
  cachedDerivedState = null;
462
- notifyStateListeners();
481
+ notifyStateListeners.schedule();
463
482
  }
464
483
 
465
484
  const actionNotifyTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
@@ -517,6 +536,11 @@ export function createEventController(
517
536
 
518
537
  const notifyHandles = makeDebouncedNotifier(handleListeners);
519
538
 
539
+ function flushRouteState(): void {
540
+ notifyStateListeners.flush();
541
+ notifyHandles.flush();
542
+ }
543
+
520
544
  function getState(): DerivedNavigationState {
521
545
  if (cachedDerivedState) return cachedDerivedState;
522
546
 
@@ -1001,7 +1025,7 @@ export function createEventController(
1001
1025
  handleSegmentOrder = newSegmentOrder;
1002
1026
  routeSegmentIds = newRouteSegmentIds;
1003
1027
 
1004
- notifyHandles();
1028
+ notifyHandles.schedule();
1005
1029
  }
1006
1030
 
1007
1031
  function getHandleState(): HandleState {
@@ -1021,7 +1045,7 @@ export function createEventController(
1021
1045
  return;
1022
1046
  }
1023
1047
  routeSegmentIds = next;
1024
- notifyHandles();
1048
+ notifyHandles.schedule();
1025
1049
  }
1026
1050
 
1027
1051
  // ========================================================================
@@ -1102,6 +1126,7 @@ export function createEventController(
1102
1126
  subscribe,
1103
1127
  subscribeToAction,
1104
1128
  subscribeToHandles,
1129
+ flushRouteState,
1105
1130
 
1106
1131
  // Direct access
1107
1132
  getCurrentNavigation: () => currentNavigation,
@@ -421,10 +421,8 @@ export function createPartialUpdater(
421
421
  // forceAwait unwraps the ROUTER loader promises during render so they
422
422
  // land without a loading()/fallback frame. A fully-prefetched nav has
423
423
  // its router data already resolved (the prefetch stream drained), so
424
- // awaiting it here is free and lets us commit NORMALLY (not in a
425
- // transition) below a normal commit still shows fallbacks for any
426
- // CLIENT component that suspends on mount, which a transition would
427
- // wrongly suppress by holding the old UI until that suspense settles.
424
+ // awaiting it here is free; the commit below then runs in a transition
425
+ // (fullyPrefetched branch) so nothing router-owned can flash.
428
426
  forceAwait: mode.type === "stale-revalidation" || fullyPrefetched,
429
427
  interceptSegments:
430
428
  reconciled.interceptSegments.length > 0
@@ -549,14 +547,31 @@ export function createPartialUpdater(
549
547
  scroll: scrollPayload,
550
548
  });
551
549
  });
550
+ } else if (fullyPrefetched) {
551
+ // Fully-prefetched nav: the payload is fully resolved (forceAwait
552
+ // above), so commit inside a transition to hold the current UI across
553
+ // the synchronous resolution — no fallback flash. No addTransitionType:
554
+ // this is the React content-hold, not a view transition. Deliberate
555
+ // trade-off (#622 introduced, #624 reverted, then reinstated): a client
556
+ // component that suspends during its FIRST render (use() of a promise
557
+ // created at mount — see ClientMountSuspense in the e2e test-app) under
558
+ // an ALREADY-REVEALED boundary holds the old content until it resolves
559
+ // instead of revealing that boundary's fallback; its render happens
560
+ // pre-commit inside the transition, so userland effects cannot run
561
+ // first. Boundaries newly mounted by this nav still reveal their
562
+ // fallbacks (React shows new boundaries inside transitions).
563
+ startTransition(() => {
564
+ onUpdate({
565
+ root: newTree,
566
+ metadata: payload.metadata!,
567
+ scroll: scrollPayload,
568
+ });
569
+ });
552
570
  } else {
553
- // Normal commit for ALL navs here, never a transition (see the
554
- // forceAwait comment above for why prefetched router data lands
555
- // without fallback frames). A transition would hold the OLD UI until
556
- // every suspense in the new tree settles — including a client
557
- // component that only starts fetching post-mount, retaining the
558
- // previous page indefinitely. Explicit transition() routes keep the
559
- // content-hold via the hasTransition branch above (the opt-in).
571
+ // Cold/partially-prefetched nav: normal commit so fallbacks stream
572
+ // like a first load and the click has visible feedback. Explicit
573
+ // transition() routes keep the content-hold via the hasTransition
574
+ // branch above (the opt-in).
560
575
  onUpdate({
561
576
  root: newTree,
562
577
  metadata: payload.metadata!,
@@ -72,8 +72,31 @@ export interface DecodedPrefetch {
72
72
  * tell a FULLY warmed prefetch (payload commits without suspending → safe to
73
73
  * commit in a startTransition, no fallback flash) from a partially-warmed one
74
74
  * (still streaming → stream its fallbacks like a cold load). Starts false.
75
+ *
76
+ * On a respawned entry (see `respawn`) this starts true: the buffered bytes
77
+ * are a complete stream by construction.
75
78
  */
76
79
  complete: boolean;
80
+ /**
81
+ * Re-create this entry from its buffered raw Flight bytes. Attached (by
82
+ * `executePrefetchFetch`) only after the stream ended cleanly AND the eager
83
+ * decode succeeded — a truncated or failed prefetch must stay one-shot.
84
+ *
85
+ * Why bytes and not the decoded payload: the revived payload is a live graph
86
+ * with one-shot stream edges (the handle stream is an async generator drained
87
+ * during commit; userland payloads may embed serialized ReadableStreams).
88
+ * Re-decoding from the wire bytes manufactures fresh instances of every
89
+ * stream, which no amount of cloning the revived graph can do. Each call tees
90
+ * the reserve branch, so the replacement entry carries its own `respawn` —
91
+ * one prefetch serves unlimited adoptions within TTL/state validity.
92
+ */
93
+ respawn?: () => DecodedPrefetch;
94
+ /**
95
+ * Release the buffered reserve bytes. Called when the entry is evicted
96
+ * without being consumed (expiry, FIFO, sweep, cache clear) so the tee
97
+ * buffer is dropped promptly instead of waiting for GC.
98
+ */
99
+ dispose?: () => void;
77
100
  }
78
101
 
79
102
  let cacheTTL = 300_000;
@@ -208,6 +231,7 @@ export function hasPrefetch(key: string): boolean {
208
231
  const entry = cache.get(key);
209
232
  if (!entry) return false;
210
233
  if (Date.now() - entry.timestamp > cacheTTL) {
234
+ entry.entry.dispose?.();
211
235
  cache.delete(key);
212
236
  return false;
213
237
  }
@@ -216,22 +240,34 @@ export function hasPrefetch(key: string): boolean {
216
240
 
217
241
  /**
218
242
  * Consume a cached, eagerly-decoded prefetch. Returns null if not found or
219
- * expired. One-time consumption: the entry is deleted after retrieval.
220
- * Returns null when caching is disabled (TTL <= 0).
243
+ * expired. Returns null when caching is disabled (TTL <= 0).
244
+ *
245
+ * A decoded payload is single-render (its handle stream is drained during the
246
+ * commit), so the returned entry is never served twice. When the entry carries
247
+ * `respawn` (clean-EOF prefetch with buffered bytes), the slot is re-armed in
248
+ * place with a fresh decode of those bytes — the ORIGINAL timestamp is kept so
249
+ * TTL bounds the age of the data, not of the latest adoption. Without
250
+ * `respawn` the entry is deleted (legacy one-shot behavior).
221
251
  *
222
252
  * Does NOT check in-flight prefetches — use consumeInflightPrefetch()
223
253
  * for that (returns a Promise instead of a resolved entry).
224
254
  */
225
255
  export function consumePrefetch(key: string): DecodedPrefetch | null {
226
256
  if (cacheTTL <= 0) return null;
227
- const entry = cache.get(key);
228
- if (!entry) return null;
229
- if (Date.now() - entry.timestamp > cacheTTL) {
257
+ const cached = cache.get(key);
258
+ if (!cached) return null;
259
+ if (Date.now() - cached.timestamp > cacheTTL) {
260
+ cached.entry.dispose?.();
230
261
  cache.delete(key);
231
262
  return null;
232
263
  }
233
- cache.delete(key);
234
- return entry.entry;
264
+ const entry = cached.entry;
265
+ if (entry.respawn) {
266
+ cached.entry = entry.respawn();
267
+ } else {
268
+ cache.delete(key);
269
+ }
270
+ return entry;
235
271
  }
236
272
 
237
273
  /**
@@ -301,6 +337,7 @@ export function storePrefetch(
301
337
  let min = Infinity;
302
338
  for (const [k, cached] of cache) {
303
339
  if (now - cached.timestamp > cacheTTL) {
340
+ cached.entry.dispose?.();
304
341
  cache.delete(k);
305
342
  } else if (cached.timestamp < min) {
306
343
  min = cached.timestamp;
@@ -312,7 +349,10 @@ export function storePrefetch(
312
349
  // FIFO eviction if at capacity
313
350
  if (cache.size >= maxPrefetchCacheSize) {
314
351
  const oldest = cache.keys().next().value;
315
- if (oldest) cache.delete(oldest);
352
+ if (oldest) {
353
+ cache.get(oldest)?.entry.dispose?.();
354
+ cache.delete(oldest);
355
+ }
316
356
  }
317
357
 
318
358
  cache.set(key, { entry, timestamp: now });
@@ -335,7 +375,10 @@ export function storePrefetch(
335
375
  * entry.
336
376
  */
337
377
  export function removePrefetch(key: string, entry: DecodedPrefetch): void {
338
- if (cache.get(key)?.entry === entry) cache.delete(key);
378
+ if (cache.get(key)?.entry === entry) {
379
+ entry.dispose?.();
380
+ cache.delete(key);
381
+ }
339
382
  }
340
383
 
341
384
  /**
@@ -405,6 +448,7 @@ export function clearPrefetchCache(rotateRangoState = true): void {
405
448
  inflightPromises.clear();
406
449
  inflightAliases.clear();
407
450
  adoptedKeys.clear();
451
+ for (const cached of cache.values()) cached.entry.dispose?.();
408
452
  cache.clear();
409
453
  earliestTimestamp = Infinity;
410
454
  abortAllPrefetches();
@@ -70,6 +70,49 @@ export function setPrefetchDecoder(fn: PrefetchDecoder): void {
70
70
  decoder = fn;
71
71
  }
72
72
 
73
+ /**
74
+ * Build the respawn closure for a cleanly-completed prefetch entry. Each call
75
+ * tees the reserve byte branch: one side feeds a fresh decode — a fresh handle
76
+ * stream and fresh serialized-stream instances, which no cloning of the
77
+ * revived payload graph could produce — and the other becomes the next
78
+ * reserve, so the replacement entry can respawn again. `streamComplete`
79
+ * resolves immediately and `complete` starts true: the buffered bytes are a
80
+ * finished stream by construction. The chunk imports the decode triggers are
81
+ * already in the module cache from the original eager decode, so a respawn
82
+ * costs ~ms with no network.
83
+ */
84
+ function makeRespawn(
85
+ storageKey: string,
86
+ reserve: ReadableStream<Uint8Array>,
87
+ scope: "source" | "wildcard",
88
+ ): () => DecodedPrefetch {
89
+ return () => {
90
+ const [body, nextReserve] = reserve.tee();
91
+ // decoder is non-null here: respawn is only armed after a successful
92
+ // eager decode, which requires the decoder to have been set.
93
+ const payload = decoder!(Promise.resolve(new Response(body)));
94
+ payload.catch(() => {});
95
+ const entry: DecodedPrefetch = {
96
+ payload,
97
+ streamComplete: Promise.resolve(),
98
+ scope,
99
+ complete: true,
100
+ dispose: () => {
101
+ // Rejects (caught) instead of throwing if the branch is already
102
+ // locked by a later respawn — cache eviction never races adoption on
103
+ // the same entry object, so this is belt-and-braces.
104
+ nextReserve.cancel().catch(() => {});
105
+ },
106
+ };
107
+ entry.respawn = makeRespawn(storageKey, nextReserve, scope);
108
+ // Parity with the original path: a rejected decode must not stay
109
+ // consumable. Clean bytes should never fail to decode, but if they do,
110
+ // evict (identity-guarded) so navigation refetches instead.
111
+ payload.catch(() => removePrefetch(storageKey, entry));
112
+ return entry;
113
+ };
114
+ }
115
+
73
116
  /**
74
117
  * Check if a URL resolves to the current page (same pathname + search).
75
118
  * Used to prevent same-page prefetching, which produces a trivial diff
@@ -271,9 +314,23 @@ function executePrefetchFetch(
271
314
  true,
272
315
  );
273
316
 
317
+ // Split the decode branch: one side feeds the eager decoder (unchanged —
318
+ // client chunks still import at prefetch time), the other stays unread as
319
+ // a reserve of the raw Flight bytes. A decoded payload is single-render
320
+ // (its handle stream drains during commit), so adoption would otherwise
321
+ // spend the entry; the reserve lets a cleanly-completed entry respawn a
322
+ // fresh decode per adoption instead (see makeRespawn).
323
+ let decodeSource: Response = tracked;
324
+ let reserve: ReadableStream<Uint8Array> | undefined;
325
+ if (tracked.body) {
326
+ const [decodeBody, reserveBody] = tracked.body.tee();
327
+ decodeSource = new Response(decodeBody, { headers: tracked.headers });
328
+ reserve = reserveBody;
329
+ }
330
+
274
331
  // Eager decode: parsing the Flight stream imports the route's client
275
332
  // chunks now, not on click.
276
- const payload = decoder(Promise.resolve(tracked));
333
+ const payload = decoder(Promise.resolve(decodeSource));
277
334
  // Mark handled so an unconsumed prefetch decode error stays quiet; the
278
335
  // error is still surfaced to navigation if it consumes the entry.
279
336
  payload.catch(() => {});
@@ -283,6 +340,11 @@ function executePrefetchFetch(
283
340
  streamComplete,
284
341
  scope,
285
342
  complete: false,
343
+ ...(reserve && {
344
+ dispose: () => {
345
+ reserve.cancel().catch(() => {});
346
+ },
347
+ }),
286
348
  };
287
349
  storePrefetch(storageKey, entry, gen);
288
350
  // The stall timeout now owns the body stream: arm eviction (publishedKey)
@@ -301,10 +363,29 @@ function executePrefetchFetch(
301
363
  streamComplete.then(() => {
302
364
  if (!endedCleanly) removePrefetch(storageKey, entry);
303
365
  });
304
- // Mark complete ONLY on a fully-healthy prefetch (decode resolved AND clean EOF).
366
+ // Mark complete ONLY on a fully-healthy prefetch (decode resolved AND
367
+ // clean EOF) — and only then arm respawn: a truncated or failed stream's
368
+ // reserve would replay broken bytes, so it stays one-shot (and its
369
+ // buffered reserve is released via the removePrefetch dispose above).
305
370
  Promise.allSettled([payload, streamComplete]).then(([decode]) => {
306
371
  if (decode.status === "fulfilled" && endedCleanly) {
307
372
  entry.complete = true;
373
+ if (reserve) {
374
+ entry.respawn = makeRespawn(storageKey, reserve, scope);
375
+ // Refill after an early adoption. A click that lands before this
376
+ // point adopts the entry with respawn unarmed and leaves the slot
377
+ // empty — via either path: pre-headers inflight adoption makes
378
+ // storePrefetch skip publication (adoptedKeys), and a mid-stream
379
+ // cache adoption deletes the incomplete entry. Now that the bytes
380
+ // proved themselves complete, publish a respawned sibling so the
381
+ // revisit is warm. Guards: hasPrefetch skips when the slot is
382
+ // live again (our own un-adopted entry, a fresh entry, or a new
383
+ // in-flight prefetch), and storePrefetch's generation check drops
384
+ // the sibling if the cache was invalidated since the fetch began.
385
+ if (!hasPrefetch(storageKey)) {
386
+ storePrefetch(storageKey, entry.respawn(), gen);
387
+ }
388
+ }
308
389
  }
309
390
  clearTimeout(timeoutId);
310
391
  });
@@ -456,6 +456,13 @@ export function NavigationProvider({
456
456
  cached === undefined ? update.metadata.resolvedIds : undefined,
457
457
  );
458
458
  }
459
+
460
+ // tx.commit() and the metadata updates above mutate the controller
461
+ // synchronously, but its ordinary notifications are task-debounced. Flush
462
+ // them here so hook setState calls inherit this payload update's lane. In
463
+ // a transition that suspends, the source tree therefore keeps its source
464
+ // pathname/params until the destination payload commits with them.
465
+ eventController.flushRouteState();
459
466
  });
460
467
 
461
468
  return unsubscribe;
@@ -205,8 +205,63 @@ interface CacheEnvelope {
205
205
  * singleton), and cleared for a key as soon as the leader settles: a rejected
206
206
  * leader (function threw, or the result was not serializable) propagates to
207
207
  * current waiters, which then retry fresh.
208
+ *
209
+ * A leader that NEVER settles must not hang followers (scar tissue, autobarn
210
+ * pilot outage): a background shell capture's render became leader, awaited a
211
+ * tarpitting upstream fetch, and workerd killed the capture's waitUntil context
212
+ * — orphaning the leader promise as permanently pending, its map entry never
213
+ * cleared. Every later document render calling the same cached function (an
214
+ * isolate-global key for plain-args calls) awaited it forever before first
215
+ * byte: isolate-wide TTFB-0 until redeploy. Followers therefore trust an entry
216
+ * only for {@link IN_FLIGHT_LEADER_MAX_WAIT_MS} from registration; past it
217
+ * they evict the entry and run fresh — bounded duplicate upstream work instead
218
+ * of an unbounded hang. Eviction is age-based off `registeredAt`, so it also
219
+ * heals entries stranded by a killed context (no timer in that context needs
220
+ * to survive).
221
+ */
222
+ interface InFlightExecution {
223
+ promise: Promise<CacheEnvelope>;
224
+ registeredAt: number;
225
+ }
226
+
227
+ const inFlightExecutions = new Map<string, InFlightExecution>();
228
+
229
+ /**
230
+ * How long a follower trusts an in-flight leader before evicting it and
231
+ * running fresh. High enough that a slow-but-healthy upstream never triggers
232
+ * duplicate work (a legitimate cached call taking >15s is already pathological);
233
+ * low enough to bound the blast radius of a wedged leader to seconds, not the
234
+ * isolate lifetime. Aligned with SHELL_CAPTURE_MAX_WAIT_MS.
235
+ */
236
+ const IN_FLIGHT_LEADER_MAX_WAIT_MS = 15_000;
237
+
238
+ /** Distinguishes a leader timeout from a leader rejection in raceLeader. */
239
+ const LEADER_TIMED_OUT = Symbol("leader-timed-out");
240
+
241
+ /**
242
+ * Await a leader's envelope for at most `remainingMs`. Resolves with the
243
+ * envelope, `undefined` on leader rejection (the leader already cleared its
244
+ * entry — caller falls through to a fresh run), or {@link LEADER_TIMED_OUT}
245
+ * when the window expires first (caller must evict the entry itself).
208
246
  */
209
- const inFlightExecutions = new Map<string, Promise<CacheEnvelope>>();
247
+ function raceLeader(
248
+ promise: Promise<CacheEnvelope>,
249
+ remainingMs: number,
250
+ ): Promise<CacheEnvelope | undefined | typeof LEADER_TIMED_OUT> {
251
+ return new Promise((resolve) => {
252
+ const timer = setTimeout(() => resolve(LEADER_TIMED_OUT), remainingMs);
253
+ promise.then(
254
+ (envelope) => {
255
+ clearTimeout(timer);
256
+ resolve(envelope);
257
+ },
258
+ () => {
259
+ clearTimeout(timer);
260
+ resolve(undefined);
261
+ },
262
+ );
263
+ });
264
+ }
210
265
 
211
266
  // ============================================================================
212
267
  // Core: registerCachedFunction
@@ -541,20 +596,34 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
541
596
  // requests. The store write stays exactly once (the leader's).
542
597
  const existing = inFlightExecutions.get(cacheKey);
543
598
  if (existing) {
544
- let envelope: CacheEnvelope | undefined;
545
- try {
546
- envelope = await existing;
547
- } catch {
548
- // Leader rejected (function threw or its result was not serializable);
549
- // its map entry is already cleared, so fall through to a fresh run.
550
- envelope = undefined;
551
- }
552
- if (envelope) {
599
+ const remainingMs =
600
+ IN_FLIGHT_LEADER_MAX_WAIT_MS - (Date.now() - existing.registeredAt);
601
+ const raced =
602
+ remainingMs <= 0
603
+ ? LEADER_TIMED_OUT
604
+ : await raceLeader(existing.promise, remainingMs);
605
+ if (raced === LEADER_TIMED_OUT) {
606
+ // Wedged (or context-orphaned) leader: evict so this call and every
607
+ // later one run fresh. Guard the delete so a newer leader's entry is
608
+ // never removed; the stale leader's own clearSelf is identity-guarded
609
+ // the same way, so it can't evict our replacement if it settles late.
610
+ if (inFlightExecutions.get(cacheKey) === existing) {
611
+ inFlightExecutions.delete(cacheKey);
612
+ }
613
+ reportCacheError(
614
+ new Error(
615
+ `in-flight leader did not settle within ${IN_FLIGHT_LEADER_MAX_WAIT_MS}ms; evicted — executing fresh`,
616
+ ),
617
+ "cache-read",
618
+ `[use cache] "${id}" inflight-timeout`,
619
+ );
620
+ // Fall through to a fresh execution below (this call becomes leader).
621
+ } else if (raced) {
553
622
  try {
554
623
  return await serveCached({
555
- value: envelope.serialized,
556
- handles: envelope.handles,
557
- tags: envelope.tags,
624
+ value: raced.serialized,
625
+ handles: raced.handles,
626
+ tags: raced.tags,
558
627
  shouldRevalidate: false,
559
628
  });
560
629
  } catch (error) {
@@ -566,6 +635,8 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
566
635
  // Fall through to a fresh execution below.
567
636
  }
568
637
  }
638
+ // raced === undefined: leader rejected; its map entry is already
639
+ // cleared, so fall through to a fresh run.
569
640
  }
570
641
 
571
642
  // This call becomes the leader. Register a deferred envelope so concurrent
@@ -582,9 +653,12 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
582
653
  // Followers attach their own catch; guard the map's own reference so a
583
654
  // rejected envelope with no waiter is not an unhandled rejection.
584
655
  envelopePromise.catch(() => {});
585
- inFlightExecutions.set(cacheKey, envelopePromise);
656
+ inFlightExecutions.set(cacheKey, {
657
+ promise: envelopePromise,
658
+ registeredAt: Date.now(),
659
+ });
586
660
  const clearSelf = (): void => {
587
- if (inFlightExecutions.get(cacheKey) === envelopePromise) {
661
+ if (inFlightExecutions.get(cacheKey)?.promise === envelopePromise) {
588
662
  inFlightExecutions.delete(cacheKey);
589
663
  }
590
664
  };
@@ -85,8 +85,15 @@ export const MAX_REVALIDATION_INTERVAL = 30;
85
85
  *
86
86
  * This is the default; override per store via
87
87
  * `CFCacheStoreOptions.edgeLookupTimeoutMs` (<= 0 disables the budget).
88
+ *
89
+ * 25ms, raised from 10: production Workers logs (autobarn pilot) showed the
90
+ * 10ms budget firing frequently on cold colos where the first Cache API touch
91
+ * is slow but healthy — each false positive downgrades a warm L1 HIT to an
92
+ * L2/render round trip that costs far more than the 15ms of extra patience.
93
+ * A genuinely degraded colo still gets cut off; tune per store for
94
+ * shell-critical routes.
88
95
  */
89
- export const EDGE_LOOKUP_TIMEOUT_MS = 10;
96
+ export const EDGE_LOOKUP_TIMEOUT_MS = 25;
90
97
 
91
98
  /**
92
99
  * Maximum time (ms) to wait for the BODY of a matched L1 entry to be read