@rangojs/router 0.0.0-experimental.133 → 0.0.0-experimental.135

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 (71) hide show
  1. package/dist/bin/rango.js +7 -2
  2. package/dist/vite/index.js +41 -27
  3. package/package.json +23 -24
  4. package/skills/composability/SKILL.md +0 -1
  5. package/skills/handler-use/SKILL.md +7 -7
  6. package/skills/intercept/SKILL.md +38 -13
  7. package/skills/loader/SKILL.md +10 -0
  8. package/skills/migrate-nextjs/SKILL.md +3 -3
  9. package/skills/migrate-react-router/SKILL.md +144 -1
  10. package/skills/prerender/SKILL.md +20 -17
  11. package/skills/router-setup/SKILL.md +1 -2
  12. package/skills/testing/SKILL.md +1 -0
  13. package/skills/testing/render-handler.md +15 -14
  14. package/skills/use-cache/SKILL.md +11 -0
  15. package/skills/view-transitions/SKILL.md +43 -0
  16. package/src/browser/navigation-bridge.ts +65 -16
  17. package/src/browser/navigation-client.ts +27 -1
  18. package/src/browser/navigation-store.ts +82 -8
  19. package/src/browser/network-error-handler.ts +34 -7
  20. package/src/browser/partial-update.ts +43 -3
  21. package/src/browser/prefetch/cache.ts +8 -0
  22. package/src/browser/prefetch/fetch.ts +32 -4
  23. package/src/browser/react/NavigationProvider.tsx +195 -4
  24. package/src/browser/react/deferred-handle-resolution.ts +75 -0
  25. package/src/browser/response-adapter.ts +38 -9
  26. package/src/browser/types.ts +32 -1
  27. package/src/cache/cache-runtime.ts +26 -5
  28. package/src/cache/document-cache.ts +17 -1
  29. package/src/cache/profile-registry.ts +15 -0
  30. package/src/cache/read-through-swr.ts +15 -1
  31. package/src/handles/MetaTags.tsx +6 -0
  32. package/src/index.rsc.ts +6 -1
  33. package/src/index.ts +6 -4
  34. package/src/internal-debug.ts +11 -8
  35. package/src/render-error-thrower.tsx +20 -0
  36. package/src/route-content-wrapper.tsx +12 -5
  37. package/src/route-definition/dsl-helpers.ts +21 -32
  38. package/src/route-definition/helper-factories.ts +0 -2
  39. package/src/route-definition/helpers-types.ts +38 -39
  40. package/src/route-definition/index.ts +1 -2
  41. package/src/route-definition/resolve-handler-use.ts +0 -1
  42. package/src/route-definition/use-item-types.ts +3 -6
  43. package/src/route-types.ts +0 -5
  44. package/src/router/match-api.ts +5 -1
  45. package/src/router/match-middleware/background-revalidation.ts +40 -23
  46. package/src/router/match-middleware/cache-store.ts +39 -24
  47. package/src/router/segment-resolution/fresh.ts +4 -0
  48. package/src/router/segment-resolution/loader-cache.ts +14 -2
  49. package/src/router/segment-resolution/revalidation.ts +3 -0
  50. package/src/router/segment-resolution/view-transition-default.ts +35 -15
  51. package/src/rsc/progressive-enhancement.ts +56 -2
  52. package/src/rsc/rsc-rendering.ts +7 -2
  53. package/src/rsc/server-action.ts +25 -2
  54. package/src/rsc/transition-gate.ts +89 -0
  55. package/src/segment-system.tsx +59 -8
  56. package/src/server/context.ts +13 -0
  57. package/src/server/loader-registry.ts +13 -1
  58. package/src/server/request-context.ts +52 -3
  59. package/src/testing/index.ts +6 -0
  60. package/src/testing/render-handler.ts +14 -0
  61. package/src/testing/run-transition-when.ts +164 -0
  62. package/src/types/handler-context.ts +1 -1
  63. package/src/types/index.ts +2 -0
  64. package/src/types/segments.ts +100 -0
  65. package/src/urls/path-helper-types.ts +10 -7
  66. package/src/urls/urls-function.ts +0 -1
  67. package/src/vite/inject-client-debug.ts +36 -0
  68. package/src/vite/plugins/version-injector.ts +22 -7
  69. package/src/vite/plugins/virtual-entries.ts +28 -9
  70. package/src/vite/router-discovery.ts +8 -13
  71. package/src/network-error-thrower.tsx +0 -18
@@ -20,7 +20,7 @@ import {
20
20
  } from "./intercept-utils.js";
21
21
  import type { BoundTransaction } from "./navigation-transaction.js";
22
22
  import { ServerRedirect } from "../errors.js";
23
- import { debugLog } from "./logging.js";
23
+ import { debugLog, isBrowserDebugEnabled } from "./logging.js";
24
24
  import {
25
25
  validateRedirectOrigin,
26
26
  validateExternalRedirect,
@@ -188,7 +188,11 @@ export function createPartialUpdater(
188
188
  routerId: store.getRouterId?.(),
189
189
  });
190
190
  const streamingToken = tx.startStreaming();
191
- const { payload, streamComplete: rawStreamComplete } = fetchResult;
191
+ const {
192
+ payload,
193
+ streamComplete: rawStreamComplete,
194
+ fullyPrefetched,
195
+ } = fetchResult;
192
196
  debugLog("payload.metadata", payload.metadata);
193
197
 
194
198
  // Side effect only: end the streaming token once the stream settles.
@@ -395,7 +399,14 @@ export function createPartialUpdater(
395
399
 
396
400
  const renderOptions = {
397
401
  isAction: mode.type === "action",
398
- forceAwait: mode.type === "stale-revalidation",
402
+ // forceAwait unwraps the ROUTER loader promises during render so they
403
+ // land without a loading()/fallback frame. A fully-prefetched nav has
404
+ // its router data already resolved (the prefetch stream drained), so
405
+ // awaiting it here is free and lets us commit NORMALLY (not in a
406
+ // transition) below — a normal commit still shows fallbacks for any
407
+ // CLIENT component that suspends on mount, which a transition would
408
+ // wrongly suppress by holding the old UI until that suspense settles.
409
+ forceAwait: mode.type === "stale-revalidation" || fullyPrefetched,
399
410
  interceptSegments:
400
411
  reconciled.interceptSegments.length > 0
401
412
  ? reconciled.interceptSegments
@@ -480,6 +491,21 @@ export function createPartialUpdater(
480
491
  debugLog("[partial-update] updating document");
481
492
 
482
493
  const hasTransition = shouldStartViewTransition(reconciled.segments);
494
+ // [VT-DIAG] Gated behind INTERNAL_RANGO_DEBUG. Reports which reconciled
495
+ // segment still carries a transition after the server-side when-gate, and
496
+ // whether the commit will be held in a startTransition. If `withTransition`
497
+ // lists an ancestor (layout/root) id rather than the gated leaf, an ungated
498
+ // ancestor transition is holding the subtree (missing loading() fallback).
499
+ if (isBrowserDebugEnabled()) {
500
+ debugLog("[VT-DIAG] commit", {
501
+ mode: mode.type,
502
+ hasTransition,
503
+ withTransition: reconciled.segments
504
+ .filter((s) => s.transition)
505
+ .map((s) => s.id),
506
+ all: reconciled.segments.map((s) => s.id),
507
+ });
508
+ }
483
509
  const scrollPayload = toScrollPayload(navScroll);
484
510
 
485
511
  if (mode.type === "action" || mode.type === "stale-revalidation") {
@@ -505,6 +531,20 @@ export function createPartialUpdater(
505
531
  });
506
532
  });
507
533
  } else {
534
+ // Normal commit (cold/partial nav AND fully-prefetched nav). For a
535
+ // fully-prefetched nav, renderOptions.forceAwait (above) unwrapped the
536
+ // already-resolved ROUTER loader data AND route content during render, so
537
+ // the new tree carries it inline with no loading()/fallback frame — yet we
538
+ // still commit NORMALLY here rather than in a transition. A transition
539
+ // holds the OLD UI until ALL suspense in the new tree settles, including a
540
+ // CLIENT component that starts its own data request only when mounted
541
+ // (post-commit) under a persistent boundary; that would retain the
542
+ // previous page indefinitely with no feedback. A normal commit lets such
543
+ // client-initiated suspense reveal a fallback (correct) while the router
544
+ // data — genuinely ready — never flashes. Cold/partial navs
545
+ // (fullyPrefetched=false) do not forceAwait, so they stream their
546
+ // fallbacks. Explicit transition() routes keep the broader content-hold
547
+ // via the hasTransition branch above (the documented opt-in).
508
548
  onUpdate({
509
549
  root: newTree,
510
550
  metadata: payload.metadata!,
@@ -65,6 +65,14 @@ export interface DecodedPrefetch {
65
65
  * when it adopted an inflight entry through the wildcard key.
66
66
  */
67
67
  scope: "source" | "wildcard";
68
+ /**
69
+ * Synchronously-readable flag, flipped to true when `streamComplete` resolves
70
+ * (the entire RSC stream has drained). Navigation reads this at click time to
71
+ * tell a FULLY warmed prefetch (payload commits without suspending → safe to
72
+ * commit in a startTransition, no fallback flash) from a partially-warmed one
73
+ * (still streaming → stream its fallbacks like a cold load). Starts false.
74
+ */
75
+ complete: boolean;
68
76
  }
69
77
 
70
78
  let cacheTTL = 300_000;
@@ -244,14 +244,21 @@ function executePrefetchFetch(
244
244
  const storageKey = scope === "source" ? sourceKey : wildcardKey;
245
245
 
246
246
  // Track stream completion off a tee so navigation's scroll/revalidation
247
- // gating matches the fresh-fetch path; decode the other branch.
247
+ // gating matches the fresh-fetch path; decode the other branch. The
248
+ // completion callback reports whether the stream ended on a clean EOF
249
+ // (true) or was aborted/errored (false) — only a clean end can mark the
250
+ // entry complete (see below).
248
251
  let resolveStreamComplete!: () => void;
252
+ let endedCleanly = false;
249
253
  const streamComplete = new Promise<void>((resolve) => {
250
254
  resolveStreamComplete = resolve;
251
255
  });
252
256
  const tracked = teeWithCompletion(
253
257
  response,
254
- () => resolveStreamComplete(),
258
+ (clean) => {
259
+ endedCleanly = clean;
260
+ resolveStreamComplete();
261
+ },
255
262
  effectiveSignal,
256
263
  // Speculative prefetch: a never-consumed/aborted stream error is benign.
257
264
  true,
@@ -264,7 +271,12 @@ function executePrefetchFetch(
264
271
  // error is still surfaced to navigation if it consumes the entry.
265
272
  payload.catch(() => {});
266
273
 
267
- const entry: DecodedPrefetch = { payload, streamComplete, scope };
274
+ const entry: DecodedPrefetch = {
275
+ payload,
276
+ streamComplete,
277
+ scope,
278
+ complete: false,
279
+ };
268
280
  storePrefetch(storageKey, entry, gen);
269
281
  // The stall timeout now owns the body stream: arm eviction (publishedKey)
270
282
  // and clear the timer once the stream completes. The tee's finally resolves
@@ -272,7 +284,23 @@ function executePrefetchFetch(
272
284
  // no lingering timer while a stalled one is evicted when the timer fires.
273
285
  publishedKey = storageKey;
274
286
  publishedEntry = entry;
275
- streamComplete.then(() => clearTimeout(timeoutId));
287
+ // Evict a broken prefetch IMMEDIATELY on the earliest failure signal — do not
288
+ // wait for both branches to settle. A decode that rejects while the tracking
289
+ // stream is still draining (or hung) would otherwise leave the rejected payload
290
+ // consumable (navigation reads entry.payload regardless of `complete`) until EOF
291
+ // or the stall timeout. removePrefetch is identity-guarded, so a fresh entry
292
+ // republished under the same key is never dropped, and a double call is a no-op.
293
+ payload.catch(() => removePrefetch(storageKey, entry));
294
+ streamComplete.then(() => {
295
+ if (!endedCleanly) removePrefetch(storageKey, entry);
296
+ });
297
+ // Mark complete ONLY on a fully-healthy prefetch (decode resolved AND clean EOF).
298
+ Promise.allSettled([payload, streamComplete]).then(([decode]) => {
299
+ if (decode.status === "fulfilled" && endedCleanly) {
300
+ entry.complete = true;
301
+ }
302
+ clearTimeout(timeoutId);
303
+ });
276
304
  return entry;
277
305
  })
278
306
  .catch(() => null)
@@ -31,6 +31,57 @@ import { handleNavigationEnd } from "../scroll-restoration.js";
31
31
  import { createAppShellRef, type AppShellRef } from "../app-shell.js";
32
32
  import { startConnectionWarmup } from "../connection-warmup.js";
33
33
  import { debugLog } from "../logging.js";
34
+ import { cloneHandleData } from "../navigation-store.js";
35
+ import { collectHandleData } from "../../handle.js";
36
+ import { Meta } from "../../handles/meta.js";
37
+ import type { MetaDescriptor } from "../../router/types.js";
38
+ import {
39
+ HEAD_RESOLVE_HANDLE_NAMES,
40
+ hasDeferredHandleValue,
41
+ resolveDeferredHandleValues,
42
+ } from "./deferred-handle-resolution.js";
43
+
44
+ /** Meta handle-name key. Meta is the only head-placed handle whose consumer
45
+ * use()s a deferred value above the route <Suspense>, so it must be resolved in
46
+ * the store before apply; every other handle keeps the promise contract. */
47
+ const META = "__rsc_router_meta__";
48
+
49
+ /**
50
+ * Carry the previous page's COLLECTED Meta forward so the title is kept (no
51
+ * blank) while a deferred Meta resolves on a soft navigation.
52
+ *
53
+ * Why a carry-forward and not just preserving the previous Meta data: handle
54
+ * collection (useHandle/MetaTags) is driven by the event controller's
55
+ * `segmentOrder`, which becomes the NEW route's order so the synchronous
56
+ * breadcrumbs render immediately. The previous route's title lives under a
57
+ * segment that is NOT in the new order, so it would stop being collected — the
58
+ * title would fall back to the layout default. Re-keying the previous COLLECTED
59
+ * descriptors under a segment that IS in the new order keeps them visible.
60
+ *
61
+ * Title descriptors are wrapped as `{ title: { absolute } }` so re-collection
62
+ * under a (possibly template-bearing) new layout does not re-apply a title
63
+ * template to an already-final title. Promise and default (charSet/viewport)
64
+ * descriptors are dropped: Promise ones would suspend MetaTags, and the defaults
65
+ * are re-added by collectMeta.
66
+ */
67
+ function carriedPreviousMeta(prev: MetaDescriptor[]): MetaDescriptor[] {
68
+ const out: MetaDescriptor[] = [];
69
+ for (const d of prev) {
70
+ if (d && typeof (d as { then?: unknown }).then === "function") continue;
71
+ const base = d as Exclude<MetaDescriptor, Promise<unknown>>;
72
+ if ("charSet" in base) continue;
73
+ if ("name" in base && (base as { name?: unknown }).name === "viewport") {
74
+ continue;
75
+ }
76
+ if ("title" in base) {
77
+ const t = (base as { title: unknown }).title;
78
+ out.push({ title: { absolute: typeof t === "string" ? t : String(t) } });
79
+ continue;
80
+ }
81
+ out.push(base);
82
+ }
83
+ return out;
84
+ }
34
85
 
35
86
  /**
36
87
  * Process handles from an async generator, updating the event controller
@@ -66,6 +117,19 @@ async function processHandles(
66
117
  historyKey,
67
118
  } = opts;
68
119
 
120
+ // This nav's instance token, captured before any await — processHandles runs
121
+ // right after its own commit, so this is that commit's token. generateHistoryKey
122
+ // is URL-only, so an A->B->A revisit reuses the key; the token lets a late
123
+ // resolution tell its own visit apart from a newer same-URL visit, so a stale
124
+ // nav can never clobber a fresher one's live state or cache (P1).
125
+ const myInstance = store.getNavInstance();
126
+
127
+ // True while this nav still owns the live page: same history key AND the most
128
+ // recent commit is still ours (no newer nav has committed since).
129
+ const stillLive = (): boolean =>
130
+ historyKey === store.getHistoryKey() &&
131
+ myInstance === store.getNavInstance();
132
+
69
133
  let yieldCount = 0;
70
134
  for await (const handleData of handlesGenerator) {
71
135
  // Check if user navigated away before each update.
@@ -79,7 +143,132 @@ async function processHandles(
79
143
  }
80
144
 
81
145
  yieldCount++;
82
- eventController.setHandleData(handleData, matched, isPartial, resolvedIds);
146
+
147
+ // Resolve ONLY Meta in the store before applying. Meta is the sole
148
+ // head-placed handle whose consumer use()s a deferred value above the route
149
+ // <Suspense>; an uncontained suspension there would revert the just-committed
150
+ // route and hide its loading fallback. Every other handle (Breadcrumbs,
151
+ // custom handles) keeps the DeferredHandleEntry contract: its deferred values
152
+ // reach the consumer AS A PROMISE and are narrowed via isThenable(). So sync
153
+ // handles AND non-Meta deferred promises apply/stream through immediately —
154
+ // only Meta is held back and swapped in once resolved.
155
+ const metaDeferred = hasDeferredHandleValue(
156
+ handleData,
157
+ HEAD_RESOLVE_HANDLE_NAMES,
158
+ );
159
+
160
+ // Apply now. The non-deferred-Meta case applies the whole snapshot in one
161
+ // call (Meta included). When Meta IS deferred, replace the deferred Meta with
162
+ // the previous page's COLLECTED Meta (stale-while-revalidate — never a blank
163
+ // title) keyed under one of the NEW route's Meta segments, so it stays
164
+ // collected under the new segment order while the synchronous and non-Meta
165
+ // deferred handles update with normal cleanup. The resolved Meta is swapped
166
+ // in by the partial merge below.
167
+ if (metaDeferred) {
168
+ const immediate: HandleData = { ...handleData };
169
+ const metaSegments = handleData[META] ?? {};
170
+ // Anchor: the last new Meta segment in matched order (collected after the
171
+ // shared layout, so its carried title wins). Falls back to any new Meta
172
+ // segment if matched ordering does not surface one.
173
+ const metaSegmentIds = Object.keys(metaSegments);
174
+ const ordered = (matched ?? []).filter((id) =>
175
+ metaSegmentIds.includes(id),
176
+ );
177
+ const anchor = ordered.at(-1) ?? metaSegmentIds.at(-1);
178
+
179
+ const prevState = eventController.getHandleState();
180
+ const prevCollected = collectHandleData(
181
+ Meta,
182
+ prevState.data,
183
+ prevState.segmentOrder,
184
+ ) as MetaDescriptor[];
185
+ const carried = carriedPreviousMeta(prevCollected);
186
+
187
+ if (anchor && carried.length > 0) {
188
+ immediate[META] = { [anchor]: carried };
189
+ } else {
190
+ // No previous Meta to carry and/or no anchor: leave Meta unset until it
191
+ // resolves (the documented no-previous-Meta behavior).
192
+ delete immediate[META];
193
+ }
194
+ eventController.setHandleData(immediate, matched, isPartial, resolvedIds);
195
+ } else {
196
+ eventController.setHandleData(
197
+ handleData,
198
+ matched,
199
+ isPartial,
200
+ resolvedIds,
201
+ );
202
+ }
203
+
204
+ // Snapshot of the nav's full applied handle state (sync handles, non-Meta
205
+ // deferred promises, and — when Meta is deferred — the carried previous Meta).
206
+ // Captured AFTER applying so it reflects what is actually on screen now.
207
+ const baseSnapshot = cloneHandleData(eventController.getHandleState().data);
208
+
209
+ if (!metaDeferred) {
210
+ // Non-deferred: the applied snapshot is final. Keep the cache in sync and
211
+ // fresh. The token guard stops a stale same-URL nav writing a newer entry.
212
+ if (store.getCacheEntryInstance(historyKey) === myInstance) {
213
+ store.updateCacheHandleData(historyKey, baseSnapshot, false);
214
+ }
215
+ continue;
216
+ }
217
+
218
+ // Meta is deferred-pending. The applied snapshot carries the PREVIOUS page's
219
+ // Meta (or none), not this route's final title, so the cache entry must NOT
220
+ // be served as fresh on a popstate return. Mark it STALE and handlesPending
221
+ // (token-guarded). This is the P1 fix: the deferred Meta is a SERVER-side
222
+ // promise streamed via Flight, so a navigate-away ABORTS the stream and the
223
+ // client's deferred-Meta promise never resolves — the .then below never
224
+ // fires. stale makes a popstate return revalidate; handlesPending makes that
225
+ // revalidation a FULL re-render (no client segment IDs) so the server
226
+ // re-streams the handles. A diff-only revalidation would omit the unchanged
227
+ // segments' handles and the deferred Meta would never land — see the
228
+ // segmentIds branch in navigation-bridge.ts.
229
+ if (store.getCacheEntryInstance(historyKey) === myInstance) {
230
+ store.updateCacheHandleData(historyKey, baseSnapshot, true, true);
231
+ }
232
+
233
+ // Resolve Meta late, then swap it in. The swap is a PARTIAL merge with
234
+ // resolvedIds=undefined so the stale-clear loop (which scans all handle
235
+ // names under resolvedIds) cannot wipe the non-Meta buckets we already
236
+ // applied. When the deferred Meta DOES resolve while this nav still owns the
237
+ // entry (no navigate-away abort), write the resolved handle data and clear
238
+ // stale + handlesPending — the entry is now complete, so a popstate return
239
+ // serves it without revalidating.
240
+ //
241
+ // Order-safety: each stream yield is a full cumulative snapshot and a
242
+ // segment's handle array is atomic, so concurrent Meta resolutions of
243
+ // different yields write identical per-segment arrays or touch disjoint
244
+ // segments — neither can clobber the other.
245
+ void resolveDeferredHandleValues(
246
+ handleData,
247
+ HEAD_RESOLVE_HANDLE_NAMES,
248
+ ).then((resolved) => {
249
+ const cacheValue = { ...baseSnapshot, [META]: resolved[META] };
250
+ if (stillLive()) {
251
+ // Still on the live page: swap Meta in and refresh the cache as fresh.
252
+ eventController.setHandleData(
253
+ { [META]: resolved[META] },
254
+ matched,
255
+ true,
256
+ undefined,
257
+ );
258
+ store.updateCacheHandleData(
259
+ historyKey,
260
+ eventController.getHandleState().data,
261
+ false,
262
+ false,
263
+ );
264
+ } else if (store.getCacheEntryInstance(historyKey) === myInstance) {
265
+ // Navigated away, but THIS nav still owns the target cache entry: write
266
+ // the resolved data and clear stale + handlesPending so a popstate return
267
+ // is fresh.
268
+ store.updateCacheHandleData(historyKey, cacheValue, false, false);
269
+ }
270
+ // else: a newer nav to the same URL superseded us — do nothing.
271
+ });
83
272
  }
84
273
 
85
274
  // Check again before final updates
@@ -97,8 +286,9 @@ async function processHandles(
97
286
  // After handles processing completes, update the cache's handleData.
98
287
  // This fixes a race condition where commit() caches stale handleData before
99
288
  // the async handles processing completes.
100
- // Only update if we're still on the same page (historyKey matches).
101
- if (historyKey === store.getHistoryKey()) {
289
+ // Only update if we're still on the same page AND this is still the live nav
290
+ // (the token guard stops a stale same-URL nav writing a newer nav's state).
291
+ if (stillLive()) {
102
292
  const finalHandleData = eventController.getHandleState().data;
103
293
  store.updateCacheHandleData(historyKey, finalHandleData);
104
294
  }
@@ -362,7 +552,8 @@ export function NavigationProvider({
362
552
  payload.root instanceof Promise ? use(payload.root) : payload.root;
363
553
 
364
554
  // Wrap content in RootErrorBoundary to catch:
365
- // 1. Errors from NetworkErrorThrower (rendered during network failures)
555
+ // 1. Errors from RenderErrorThrower (network failures and unprocessable
556
+ // navigation responses, routed here by the navigation bridge)
366
557
  // 2. Client component errors that occur before/outside the segment tree's error boundary
367
558
  // 3. Errors during promise resolution or navigation state updates
368
559
  // This acts as a safety net - the segment tree has its own RootErrorBoundary that
@@ -0,0 +1,75 @@
1
+ import type { HandleData } from "../types.js";
2
+ import { isThenable } from "../../handles/is-thenable.js";
3
+
4
+ /**
5
+ * The set of handle names whose deferred (Promise) values MUST be resolved in
6
+ * the store BEFORE the snapshot is applied during client navigation.
7
+ *
8
+ * The boundary: a handle belongs here only if its consumer `use()`s a promise in
9
+ * <head>, above the route's <Suspense>. Suspending there would revert the
10
+ * just-committed route and hide its loading fallback. Today that is Meta alone
11
+ * (MetaTags lives in <head> and use()s deferred descriptors). Every OTHER handle
12
+ * keeps the public DeferredHandleEntry contract: its deferred value reaches the
13
+ * consumer AS A PROMISE during soft navigation, narrowed via isThenable().
14
+ *
15
+ * If a future head-placed handle starts use()-ing promises, add its name here.
16
+ */
17
+ export const HEAD_RESOLVE_HANDLE_NAMES: readonly string[] = [
18
+ "__rsc_router_meta__",
19
+ ];
20
+
21
+ /**
22
+ * True when a handle value in this snapshot is a deferred (Promise) value.
23
+ *
24
+ * When `onlyHandleNames` is given, only those handle buckets are considered;
25
+ * deferred values under any other handle are ignored (they pass through to the
26
+ * consumer as promises, by contract).
27
+ */
28
+ export function hasDeferredHandleValue(
29
+ data: HandleData,
30
+ onlyHandleNames?: readonly string[],
31
+ ): boolean {
32
+ const scope = onlyHandleNames ? new Set(onlyHandleNames) : null;
33
+ for (const [handleName, segments] of Object.entries(data)) {
34
+ if (scope && !scope.has(handleName)) continue;
35
+ for (const values of Object.values(segments)) {
36
+ if (values.some(isThenable)) return true;
37
+ }
38
+ }
39
+ return false;
40
+ }
41
+
42
+ /**
43
+ * Snapshot with deferred (Promise) values awaited; a rejected deferred is
44
+ * dropped (it contributes nothing), mirroring the render-side REJECTED_META.
45
+ * Promise.allSettled treats non-promise values as already-fulfilled, so plain
46
+ * values pass through unchanged.
47
+ *
48
+ * When `onlyHandleNames` is given, ONLY those handle buckets are resolved; every
49
+ * other bucket is copied through by reference (its deferred values keep their
50
+ * promise identity so the consumer can narrow them).
51
+ */
52
+ export async function resolveDeferredHandleValues(
53
+ data: HandleData,
54
+ onlyHandleNames?: readonly string[],
55
+ ): Promise<HandleData> {
56
+ const scope = onlyHandleNames ? new Set(onlyHandleNames) : null;
57
+ const out: HandleData = {};
58
+ await Promise.all(
59
+ Object.entries(data).flatMap(([handleName, segments]) => {
60
+ // Out-of-scope buckets pass through untouched (promise identity kept).
61
+ if (scope && !scope.has(handleName)) {
62
+ out[handleName] = segments;
63
+ return [];
64
+ }
65
+ out[handleName] = {};
66
+ return Object.entries(segments).map(async ([segmentId, values]) => {
67
+ const settled = await Promise.allSettled(values);
68
+ out[handleName][segmentId] = settled
69
+ .filter((r) => r.status === "fulfilled")
70
+ .map((r) => (r as PromiseFulfilledResult<unknown>).value);
71
+ });
72
+ }),
73
+ );
74
+ return out;
75
+ }
@@ -2,6 +2,16 @@ import { validateRedirectOrigin } from "./validate-redirect-origin.js";
2
2
 
3
3
  type HeaderResult = { url: string } | "blocked" | null;
4
4
 
5
+ /**
6
+ * Null-body statuses: the Fetch spec forbids pairing these with a body, so
7
+ * `new Response(body, { status })` throws ("Response with null body status
8
+ * cannot have body"). fetch() can still surface one WITH a body straight from
9
+ * the network layer (never the JS constructor): a 304 stale-while-revalidate
10
+ * prefetch revalidated to Not Modified (body from cache), or a 204 soft
11
+ * redirect. teeWithCompletion must not re-run those through `new Response`.
12
+ */
13
+ const NULL_BODY_STATUS = new Set([101, 204, 205, 304]);
14
+
5
15
  /**
6
16
  * Extract and validate an RSC response header URL (X-RSC-Reload, X-RSC-Redirect).
7
17
  * Returns { url } if valid, "blocked" if present but invalid origin, null if absent.
@@ -74,7 +84,13 @@ export function handleReloadHeader(
74
84
  * Returns a new Response with one branch; the other is consumed to detect
75
85
  * end-of-stream, calling onComplete when done.
76
86
  *
77
- * If the response has no body, onComplete fires synchronously.
87
+ * `onComplete` receives `endedCleanly`: true only on a normal EOF drain, false
88
+ * on a read error or an abort (the signal cancelled the reader). Callers that
89
+ * gate a "fully complete" fast path (e.g. prefetch's `entry.complete`) must
90
+ * treat a non-clean end as incomplete — a broken stream is not complete data.
91
+ *
92
+ * If the response has no body, onComplete fires synchronously with `true`
93
+ * (an empty body is a clean, complete stream).
78
94
  * If signal is provided, an abort cancels the tracking reader.
79
95
  *
80
96
  * `silent` suppresses the stream-error log. Prefetch passes it: a speculative,
@@ -84,7 +100,7 @@ export function handleReloadHeader(
84
100
  */
85
101
  export function teeWithCompletion(
86
102
  response: Response,
87
- onComplete: () => void,
103
+ onComplete: (endedCleanly: boolean) => void,
88
104
  signal?: AbortSignal,
89
105
  silent = false,
90
106
  ): Response {
@@ -92,15 +108,18 @@ export function teeWithCompletion(
92
108
  // rejection's .catch, so onComplete must be settled exactly once across all
93
109
  // paths (no-body early return, finally, catch).
94
110
  let settled = false;
95
- const settle = () => {
111
+ const settle = (endedCleanly: boolean) => {
96
112
  if (!settled) {
97
113
  settled = true;
98
- onComplete();
114
+ onComplete(endedCleanly);
99
115
  }
100
116
  };
101
117
 
102
- if (!response.body) {
103
- settle();
118
+ // Empty body, or a null-body status fetch() paired with a body: either way the
119
+ // body can't be re-attached via `new Response` below. Settle and pass the
120
+ // original response through; its body (when present) stays readable downstream.
121
+ if (!response.body || NULL_BODY_STATUS.has(response.status)) {
122
+ settle(true);
104
123
  return response;
105
124
  }
106
125
 
@@ -110,21 +129,31 @@ export function teeWithCompletion(
110
129
  const reader = trackingStream.getReader();
111
130
  const onAbort = signal ? reader.cancel.bind(reader) : undefined;
112
131
  if (onAbort) signal!.addEventListener("abort", onAbort, { once: true });
132
+ // Only a loop that reaches `done` is a clean EOF. A read error rejects out
133
+ // of the try and still runs the finally, so the finally must NOT assume
134
+ // clean — it gates on this flag (false on error) AND on signal.aborted
135
+ // (an abort cancels the reader so read() resolves { done: true } and the
136
+ // loop breaks here normally, NOT in the .catch — re-check the signal to
137
+ // catch that case).
138
+ let cleanEof = false;
113
139
  try {
114
140
  while (true) {
115
141
  const { done } = await reader.read();
116
- if (done) break;
142
+ if (done) {
143
+ cleanEof = true;
144
+ break;
145
+ }
117
146
  }
118
147
  } finally {
119
148
  if (onAbort) signal!.removeEventListener("abort", onAbort);
120
149
  reader.releaseLock();
121
- settle();
150
+ settle(cleanEof && !signal?.aborted);
122
151
  }
123
152
  })().catch((error) => {
124
153
  if (!silent && !signal?.aborted) {
125
154
  console.error("[Browser] Error reading tracking stream:", error);
126
155
  }
127
- settle();
156
+ settle(false);
128
157
  });
129
158
 
130
159
  return new Response(rscStream, {
@@ -447,6 +447,10 @@ export interface NavigationStore {
447
447
  // History-based segment cache (for back/forward navigation and partial merging)
448
448
  getHistoryKey(): string;
449
449
  setHistoryKey(key: string): void;
450
+ /** Monotonic token of the most recently committed navigation. */
451
+ getNavInstance(): number;
452
+ /** Nav-instance token recorded on a cache entry (undefined if absent). */
453
+ getCacheEntryInstance(historyKey: string): number | undefined;
450
454
  cacheSegmentsForHistory(
451
455
  historyKey: string,
452
456
  segments: ResolvedSegment[],
@@ -458,10 +462,28 @@ export interface NavigationStore {
458
462
  stale: boolean;
459
463
  handleData?: HandleData;
460
464
  routerId?: string;
465
+ /**
466
+ * True when the entry's handle data is incomplete (a deferred Meta was
467
+ * still pending at navigate-away). A popstate return must revalidate with
468
+ * a FULL re-render so the server re-streams handles.
469
+ */
470
+ handlesPending?: boolean;
461
471
  }
462
472
  | undefined;
463
473
  hasHistoryCache(historyKey: string): boolean;
464
- updateCacheHandleData(historyKey: string, handleData: HandleData): void;
474
+ /**
475
+ * Update only the handleData (and optionally the stale / handlesPending flags)
476
+ * of an existing cache entry. When a flag is omitted the entry's current value
477
+ * is preserved. `stale=true` marks a single entry stale so a popstate return
478
+ * revalidates it; `handlesPending=true` additionally forces that revalidation
479
+ * to be a full re-render (so a deferred Meta re-streams).
480
+ */
481
+ updateCacheHandleData(
482
+ historyKey: string,
483
+ handleData: HandleData,
484
+ stale?: boolean,
485
+ handlesPending?: boolean,
486
+ ): void;
465
487
  markCacheAsStale(): void;
466
488
  markHistoryCacheStale(): void;
467
489
  markCacheAsStaleAndBroadcast(): void;
@@ -521,6 +543,15 @@ export interface FetchPartialResult {
521
543
  payload: RscPayload;
522
544
  /** Promise that resolves when the response stream is fully consumed */
523
545
  streamComplete: Promise<void>;
546
+ /**
547
+ * True only when this payload came from a prefetch-cache hit whose stream had
548
+ * ALREADY fully drained at fetch time (the route was fully prefetched). The
549
+ * commit then runs in a startTransition so loading()/Suspense content — already
550
+ * resolved — swaps in directly without flashing a fallback. A partially-warmed
551
+ * (still-streaming) prefetch hit and a cold fetch leave this false so their
552
+ * fallbacks stream as usual.
553
+ */
554
+ fullyPrefetched?: boolean;
524
555
  }
525
556
 
526
557
  /**