@rangojs/router 0.1.0 → 0.2.0

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 (128) hide show
  1. package/README.md +2 -1
  2. package/dist/bin/rango.js +11 -1
  3. package/dist/types/browser/dev-discovery.d.ts +11 -0
  4. package/dist/types/browser/navigation-store.d.ts +3 -13
  5. package/dist/types/browser/prefetch/loader.d.ts +10 -0
  6. package/dist/types/browser/prefetch/runtime.d.ts +3 -0
  7. package/dist/types/browser/types.d.ts +4 -16
  8. package/dist/types/build/generate-manifest.d.ts +6 -0
  9. package/dist/types/cache/cache-scope.d.ts +7 -5
  10. package/dist/types/cache/cf/cf-cache-store.d.ts +10 -9
  11. package/dist/types/cache/document-cache.d.ts +3 -1
  12. package/dist/types/cache/memory-segment-store.d.ts +6 -6
  13. package/dist/types/cache/shell-snapshot.d.ts +21 -8
  14. package/dist/types/cache/types.d.ts +25 -7
  15. package/dist/types/cache/vercel/vercel-cache-store.d.ts +9 -8
  16. package/dist/types/dev-discovery-protocol.d.ts +5 -0
  17. package/dist/types/prerender/store.d.ts +1 -0
  18. package/dist/types/route-definition/helpers-types.d.ts +6 -6
  19. package/dist/types/route-map-builder.d.ts +4 -29
  20. package/dist/types/router/match-handlers.d.ts +2 -3
  21. package/dist/types/router/prerender-match.d.ts +4 -1
  22. package/dist/types/router/router-interfaces.d.ts +3 -1
  23. package/dist/types/router/segment-resolution/view-transition-default.d.ts +3 -4
  24. package/dist/types/router/transition-when.d.ts +13 -0
  25. package/dist/types/rsc/handler-context.d.ts +1 -0
  26. package/dist/types/rsc/render-pipeline.d.ts +3 -2
  27. package/dist/types/rsc/rsc-rendering.d.ts +5 -10
  28. package/dist/types/rsc/shell-capture.d.ts +4 -8
  29. package/dist/types/rsc/shell-serve.d.ts +2 -0
  30. package/dist/types/rsc/transition-gate.d.ts +10 -14
  31. package/dist/types/rsc/types.d.ts +2 -0
  32. package/dist/types/server/context.d.ts +16 -0
  33. package/dist/types/server/request-context.d.ts +22 -8
  34. package/dist/types/server.d.ts +1 -1
  35. package/dist/types/testing/e2e/index.d.ts +1 -1
  36. package/dist/types/testing/index.d.ts +2 -2
  37. package/dist/types/testing/run-transition-when.d.ts +6 -5
  38. package/dist/types/testing/shell-status.d.ts +23 -3
  39. package/dist/types/types/route-entry.d.ts +3 -0
  40. package/dist/types/types/segments.d.ts +27 -22
  41. package/dist/types/urls/path-helper-types.d.ts +4 -3
  42. package/dist/types/vite/discovery/state.d.ts +3 -2
  43. package/dist/types/vite/utils/manifest-utils.d.ts +1 -1
  44. package/dist/vite/index.js +194 -107
  45. package/package.json +24 -20
  46. package/skills/cache-guide/SKILL.md +6 -3
  47. package/skills/caching/SKILL.md +1 -1
  48. package/skills/catalog.json +7 -1
  49. package/skills/deployment-caching/SKILL.md +176 -0
  50. package/skills/document-cache/SKILL.md +30 -3
  51. package/skills/ppr/SKILL.md +90 -14
  52. package/skills/prerender/SKILL.md +15 -8
  53. package/skills/rango/SKILL.md +20 -17
  54. package/skills/testing/SKILL.md +1 -1
  55. package/skills/testing/cache-prerender.md +5 -1
  56. package/skills/vercel/SKILL.md +22 -1
  57. package/skills/view-transitions/SKILL.md +12 -8
  58. package/src/browser/dev-discovery.ts +66 -0
  59. package/src/browser/navigation-client.ts +6 -0
  60. package/src/browser/navigation-store.ts +4 -271
  61. package/src/browser/partial-update.ts +7 -14
  62. package/src/browser/prefetch/cache.ts +1 -1
  63. package/src/browser/prefetch/loader.ts +110 -0
  64. package/src/browser/prefetch/runtime.ts +7 -0
  65. package/src/browser/react/Link.tsx +7 -10
  66. package/src/browser/react/NavigationProvider.tsx +1 -1
  67. package/src/browser/react/use-router.ts +1 -1
  68. package/src/browser/rsc-router.tsx +4 -2
  69. package/src/browser/types.ts +4 -27
  70. package/src/build/generate-manifest.ts +11 -0
  71. package/src/cache/cache-scope.ts +23 -7
  72. package/src/cache/cf/cf-cache-store.ts +63 -39
  73. package/src/cache/document-cache.ts +4 -2
  74. package/src/cache/memory-segment-store.ts +17 -6
  75. package/src/cache/shell-snapshot.ts +45 -15
  76. package/src/cache/types.ts +24 -6
  77. package/src/cache/vercel/vercel-cache-store.ts +52 -22
  78. package/src/dev-discovery-protocol.ts +11 -0
  79. package/src/prerender/build-shell-capture.ts +7 -1
  80. package/src/prerender/store.ts +5 -0
  81. package/src/route-definition/dsl-helpers.ts +7 -2
  82. package/src/route-definition/helpers-types.ts +6 -6
  83. package/src/route-map-builder.ts +56 -49
  84. package/src/router/handler-context.ts +13 -5
  85. package/src/router/lazy-includes.ts +1 -0
  86. package/src/router/match-api.ts +8 -4
  87. package/src/router/match-handlers.ts +44 -6
  88. package/src/router/match-middleware/cache-lookup.ts +10 -3
  89. package/src/router/prerender-match.ts +19 -2
  90. package/src/router/router-interfaces.ts +4 -0
  91. package/src/router/segment-resolution/static-store.ts +36 -10
  92. package/src/router/segment-resolution/view-transition-default.ts +9 -5
  93. package/src/router/transition-when.ts +76 -0
  94. package/src/router.ts +34 -3
  95. package/src/rsc/handler-context.ts +1 -0
  96. package/src/rsc/handler.ts +3 -1
  97. package/src/rsc/loader-fetch.ts +2 -2
  98. package/src/rsc/manifest-init.ts +4 -11
  99. package/src/rsc/progressive-enhancement.ts +9 -14
  100. package/src/rsc/render-pipeline.ts +10 -2
  101. package/src/rsc/rsc-rendering.ts +319 -147
  102. package/src/rsc/shell-capture.ts +34 -11
  103. package/src/rsc/shell-serve.ts +3 -0
  104. package/src/rsc/transition-gate.ts +37 -40
  105. package/src/rsc/types.ts +2 -0
  106. package/src/server/context.ts +28 -0
  107. package/src/server/request-context.ts +38 -10
  108. package/src/server.ts +0 -2
  109. package/src/testing/dispatch.ts +1 -0
  110. package/src/testing/e2e/index.ts +5 -0
  111. package/src/testing/index.ts +9 -1
  112. package/src/testing/run-transition-when.ts +42 -9
  113. package/src/testing/shell-status.ts +92 -3
  114. package/src/types/route-entry.ts +3 -0
  115. package/src/types/segments.ts +27 -22
  116. package/src/urls/include-helper.ts +1 -0
  117. package/src/urls/path-helper-types.ts +4 -3
  118. package/src/urls/path-helper.ts +8 -4
  119. package/src/vite/discovery/bundle-postprocess.ts +10 -7
  120. package/src/vite/discovery/discover-routers.ts +7 -66
  121. package/src/vite/discovery/prerender-collection.ts +13 -2
  122. package/src/vite/discovery/state.ts +4 -4
  123. package/src/vite/discovery/virtual-module-codegen.ts +75 -42
  124. package/src/vite/plugins/version-injector.ts +0 -1
  125. package/src/vite/plugins/virtual-entries.ts +11 -1
  126. package/src/vite/router-discovery.ts +132 -22
  127. package/src/vite/utils/manifest-utils.ts +1 -4
  128. package/src/vite/utils/shared-utils.ts +7 -0
@@ -476,6 +476,8 @@ export interface ShellCaptureDebugEvent {
476
476
  snapshotBytes?: number;
477
477
  /** True when the snapshot exceeded maxSnapshotBytes and was dropped. */
478
478
  snapshotSkipped?: boolean;
479
+ /** Outcome reported by a store that supports shell-write acknowledgements. */
480
+ storeWrite?: "stored" | "invalidated";
479
481
  /** Consecutive failure count in the key's backoff entry, when one exists. */
480
482
  backoffFailures?: number;
481
483
  /** Ms remaining in the key's backoff window, when one exists. */
@@ -516,6 +518,9 @@ export function describeShellCaptureEvent(
516
518
  `snapshot=${event.snapshotBytes}b${event.snapshotSkipped ? " (over cap, skipped)" : ""}`,
517
519
  );
518
520
  }
521
+ if (event.storeWrite !== undefined) {
522
+ parts.push(`store-write=${event.storeWrite}`);
523
+ }
519
524
  if (event.backoffFailures !== undefined) {
520
525
  parts.push(`backoff-failures=${event.backoffFailures}`);
521
526
  }
@@ -941,13 +946,15 @@ type CaptureAttemptOutcome = "stored" | "redirect" | "no-shell" | "refused";
941
946
  * bag, not a return value: captureAndStoreShell's outcome type stays a string
942
947
  * union its existing callers (producer B, tests) consume unchanged.
943
948
  */
944
- interface CaptureAttemptStats {
945
- barrierWaitMs?: number;
946
- writeSettleMs?: number;
947
- preludeBytes?: number;
948
- snapshotBytes?: number;
949
- snapshotSkipped?: boolean;
950
- }
949
+ type CaptureAttemptStats = Pick<
950
+ ShellCaptureDebugEvent,
951
+ | "barrierWaitMs"
952
+ | "writeSettleMs"
953
+ | "preludeBytes"
954
+ | "snapshotBytes"
955
+ | "snapshotSkipped"
956
+ | "storeWrite"
957
+ >;
951
958
 
952
959
  /**
953
960
  * Run the shell capture with a single in-place retry, then store the result.
@@ -1116,6 +1123,7 @@ async function attemptCapture(
1116
1123
  reqCtx,
1117
1124
  descriptor,
1118
1125
  );
1126
+ const captureStartedAt = Date.now();
1119
1127
 
1120
1128
  return runWithRequestContext(derivedCtx, async () => {
1121
1129
  const match = await ctx.router.match(request, { env });
@@ -1157,6 +1165,7 @@ async function attemptCapture(
1157
1165
  derivedCtx,
1158
1166
  descriptor,
1159
1167
  stats,
1168
+ captureStartedAt,
1160
1169
  );
1161
1170
  });
1162
1171
  }
@@ -1329,6 +1338,7 @@ export function deriveShellCaptureContext(
1329
1338
  ttl: descriptor.ttl,
1330
1339
  swr: descriptor.swr,
1331
1340
  store: new SnapshotOnlySegmentStore(recordingStore),
1341
+ keyPrefix: "doc",
1332
1342
  };
1333
1343
  }
1334
1344
 
@@ -1355,6 +1365,7 @@ async function captureAndStoreShell(
1355
1365
  reqCtx: RequestContext<any>,
1356
1366
  capture: ShellCaptureDescriptor,
1357
1367
  stats?: CaptureAttemptStats,
1368
+ captureStartedAt: number = Date.now(),
1358
1369
  ): Promise<Exclude<CaptureAttemptOutcome, "redirect">> {
1359
1370
  const captureShellHTML = ssrModule.captureShellHTML!;
1360
1371
 
@@ -1661,15 +1672,26 @@ async function captureAndStoreShell(
1661
1672
  handlerLiveHoles: handlerLayerIsLive(
1662
1673
  reqCtx._shellCaptureHandleLiveness,
1663
1674
  ),
1664
- createdAt: Date.now(),
1675
+ transitionWhen: reqCtx._transitionWhen?.length ? true : undefined,
1676
+ createdAt: captureStartedAt,
1665
1677
  };
1666
- await store.putShell(
1678
+ const storeWrite = await store.putShell(
1667
1679
  capture.key,
1668
1680
  entry,
1669
1681
  capture.ttl,
1670
1682
  capture.swr,
1671
1683
  shellTags,
1672
1684
  );
1685
+ if (stats && storeWrite) stats.storeWrite = storeWrite;
1686
+ if (storeWrite === "invalidated") {
1687
+ warnCaptureRefusedOnce(
1688
+ capture.key,
1689
+ "one of the shell's tags was invalidated after this capture generation started, so the store rejected the write. " +
1690
+ "If capture code deterministically calls updateTag() on its own shell tag, move that mutation out of the render; " +
1691
+ "otherwise every generation is invalidated before it can be served.",
1692
+ );
1693
+ return "refused";
1694
+ }
1673
1695
  } catch (error) {
1674
1696
  // Best-effort: a failed put must never throw out of the background task.
1675
1697
  reportCacheError(
@@ -1680,8 +1702,9 @@ async function captureAndStoreShell(
1680
1702
  );
1681
1703
  }
1682
1704
  }
1683
- // A shell was captured (the store I/O may have failed, but that is reported,
1684
- // not retried) so this attempt is `stored` and the caller does not retry.
1705
+ // A shell was captured (ordinary store I/O failure is reported, not retried).
1706
+ // An acknowledged invalidation rejection returned `refused` above so the
1707
+ // scheduler backs the key off instead of recapturing on every request.
1685
1708
  return "stored";
1686
1709
  } finally {
1687
1710
  // Stop the hop loop for the pathological never-quiets path (quiesce never
@@ -24,6 +24,9 @@ import type { ShellCacheEntry, SegmentCacheStore } from "../cache/types.js";
24
24
  /** Debug/status header the browser (and e2e assertions) can read: HIT | MISS. */
25
25
  export const SHELL_STATUS_HEADER = "x-rango-shell";
26
26
 
27
+ /** Partial-navigation status header set only after captured PPR segments are consumed. */
28
+ export const PPR_REPLAY_STATUS_HEADER = "x-rango-ppr-replay";
29
+
27
30
  /**
28
31
  * Default shell ttl (seconds) for `ppr: true` and for a PartialPrerenderProps
29
32
  * that omits `ttl`.
@@ -1,41 +1,58 @@
1
1
  import type { MatchResult } from "../types.js";
2
- import type { TransitionWhenContext } from "../types/segments.js";
3
2
  import type { getRequestContext } from "../server/request-context.js";
4
3
  import { invokeOnError } from "../router/error-handling.js";
5
4
  import type { OnErrorCallback } from "../types/error-types.js";
5
+ import { createTransitionWhenContext } from "../router/transition-when.js";
6
6
 
7
7
  /**
8
8
  * Apply transition({ when }) gates to a payload's segments.
9
9
  *
10
- * The predicates were collected during resolution (keyed by segment id) and
11
- * stripped from the serialized config; here after handlers ran and outside any
12
- * cache scope we evaluate each and drop the segment's transition when the
13
- * predicate does not hold, so the navigation streams its loading fallback
14
- * instead of holding the previous content. A predicate that throws is reported
15
- * to the router's onError (phase "rendering") and then treated as "do not hold"
16
- * (conservative), so a buggy predicate degrades to no transition rather than
17
- * failing the response.
10
+ * PPR predicates were evaluated from the manifest before cache lookup and route
11
+ * handlers; their request-specific decisions are projected onto a copy so they
12
+ * never mutate reusable cache/prerender/shell records. Ordinary-route predicates
13
+ * were collected during fresh resolution and are evaluated here after handlers.
14
+ * Both paths drop the segment transition when the predicate does not hold.
18
15
  *
19
16
  * Mutating the segments here is safe: the segment cache stores a serialized copy
20
17
  * (segment-codec), written during match() BEFORE this gate runs, so dropping a
21
- * transition never corrupts a cache entry. The flip side is that a cache hit
18
+ * transition never corrupts a cache entry. On an ordinary route, a cache hit
22
19
  * skips resolution, collects no predicate, and replays the cached transition
23
- * as-is (it was serialized before the gate) combining transition({ when })
24
- * with cache() on the same segment freezes the gate to its cached state, so
25
- * avoid caching a route whose transition decision is request-dependent.
20
+ * as-is (it was serialized before the gate). PPR routes are the exception: their
21
+ * manifest predicates were evaluated before lookup and apply to replayed data.
26
22
  *
27
- * Returns the same array (mutated) for inline use at the payload's `segments`
28
- * field.
23
+ * Returns the same array for the ordinary post-handler path. A PPR drop returns a
24
+ * copy containing only the request-specific transition changes.
29
25
  */
30
26
  export function gateTransitions(
31
27
  segments: MatchResult["segments"],
32
28
  ctx: ReturnType<typeof getRequestContext>,
33
29
  onError?: OnErrorCallback,
34
30
  ): MatchResult["segments"] {
31
+ const pprDecisions = ctx._pprTransitionDecisions;
32
+ let gatedSegments = segments;
33
+
34
+ const dropTransition = (id: string): void => {
35
+ const index = gatedSegments.findIndex((segment) => segment.id === id);
36
+ if (index === -1) return;
37
+ if (!pprDecisions) {
38
+ gatedSegments[index].transition = undefined;
39
+ return;
40
+ }
41
+ if (gatedSegments === segments) gatedSegments = [...segments];
42
+ gatedSegments[index] = {
43
+ ...gatedSegments[index],
44
+ transition: undefined,
45
+ };
46
+ };
47
+
48
+ if (pprDecisions) {
49
+ for (const [id, keep] of pprDecisions) {
50
+ if (!keep) dropTransition(id);
51
+ }
52
+ }
35
53
  const predicates = ctx._transitionWhen;
36
- if (predicates && predicates.length) {
54
+ if (predicates) {
37
55
  for (const { id, when } of predicates) {
38
- let drop: boolean;
39
56
  try {
40
57
  // Assemble the ShouldRevalidateFn-shaped predicate context from the
41
58
  // request context. Source fields (currentUrl/currentParams/fromRouteName)
@@ -44,28 +61,11 @@ export function gateTransitions(
44
61
  // method/get/env come straight off ctx (setRequestContextParams ran
45
62
  // before the gate). Source/action fields are undefined when absent —
46
63
  // never fabricated (see TransitionWhenContext).
47
- const whenCtx: TransitionWhenContext = {
48
- currentUrl: ctx._gateCurrentUrl,
49
- currentParams: ctx._gateCurrentParams,
50
- fromRouteName:
51
- ctx._prevRouteKey as TransitionWhenContext["fromRouteName"],
52
- nextUrl: ctx.url,
53
- nextParams: ctx.params,
54
- toRouteName: ctx.routeName,
55
- actionId: ctx._gateActionId,
56
- actionUrl: ctx._gateActionUrl,
57
- actionResult: ctx._gateActionResult,
58
- formData: ctx._gateFormData,
59
- method: ctx.request.method,
60
- get: ctx.get,
61
- env: ctx.env,
62
- };
63
- drop = when(whenCtx) === false;
64
+ if (when(createTransitionWhenContext(ctx)) !== false) continue;
64
65
  } catch (error) {
65
66
  // A throwing predicate must not fail the response: report it and treat
66
67
  // the transition as gated off (do not hold). invokeOnError no-ops when
67
68
  // onError is undefined.
68
- drop = true;
69
69
  invokeOnError(
70
70
  onError,
71
71
  error,
@@ -79,11 +79,8 @@ export function gateTransitions(
79
79
  "RSC",
80
80
  );
81
81
  }
82
- if (drop) {
83
- const seg = segments.find((s) => s.id === id);
84
- if (seg) seg.transition = undefined;
85
- }
82
+ dropTransition(id);
86
83
  }
87
84
  }
88
- return segments;
85
+ return gatedSegments;
89
86
  }
package/src/rsc/types.ts CHANGED
@@ -41,6 +41,8 @@ export interface RscPayload {
41
41
  handles?: AsyncGenerator<HandleData, void, unknown>;
42
42
  /** RSC version string for cache invalidation */
43
43
  version?: string;
44
+ /** Cloudflare dev worker generation used for stale-document convergence. */
45
+ devDiscoveryEpoch?: number;
44
46
  /** TTL in milliseconds for the client-side in-memory prefetch cache */
45
47
  prefetchCacheTTL?: number;
46
48
  /** Max entries in the client-side in-memory prefetch cache (FIFO eviction) */
@@ -298,6 +298,10 @@ interface HelperContext {
298
298
  counters: Record<string, number>;
299
299
  forRoute?: string;
300
300
  mountIndex?: number;
301
+ /** Owning router id, when known (threaded by generateManifestFull). Scopes
302
+ * the search-schema/root-scope registries so same-named routes in
303
+ * different routers don't clobber each other (route-map-builder.ts). */
304
+ routerId?: string;
301
305
  metrics?: MetricsStore;
302
306
  /** True when rendering for SSR (document requests) */
303
307
  isSSR?: boolean;
@@ -491,6 +495,7 @@ export const getContext = (): {
491
495
  counters: store.counters,
492
496
  forRoute: store.forRoute,
493
497
  mountIndex: store.mountIndex,
498
+ routerId: store.routerId,
494
499
  metrics: store.metrics,
495
500
  isSSR: store.isSSR,
496
501
  patterns: store.patterns,
@@ -533,6 +538,7 @@ export const getContext = (): {
533
538
  counters,
534
539
  forRoute: store?.forRoute,
535
540
  mountIndex: store?.mountIndex,
541
+ routerId: store?.routerId,
536
542
  metrics: store?.metrics,
537
543
  isSSR: store?.isSSR,
538
544
  patterns,
@@ -666,6 +672,28 @@ export function getRootScoped(): boolean {
666
672
  return store?.rootScoped ?? true;
667
673
  }
668
674
 
675
+ /**
676
+ * Stamp build-time scope identity onto a Static() definition at mount time.
677
+ * The bake collector (prerender-collection.ts) renders defs OUTSIDE any
678
+ * evaluation scope and used to iterate the registry first-non-null, so these
679
+ * are the ONLY reliable carriers of the def's owning router, root-scope, and
680
+ * full route name — a name-keyed registry lookup at bake time reproduces the
681
+ * cross-router collision #757/#762 fixed (and the collector's historical
682
+ * `$$routePrefix` argument is a name PREFIX, which the root-scope registry
683
+ * never contains, silently degrading to the dot-heuristic).
684
+ * A definition mounted more than once is stamped each time; the last mount wins.
685
+ */
686
+ export function stampStaticDefScope(
687
+ handler: unknown,
688
+ routeName?: string,
689
+ ): void {
690
+ const store = RangoContext.getStore();
691
+ const def = handler as Record<string, unknown>;
692
+ if (routeName !== undefined) def.$$routeName = routeName;
693
+ def.$$rootScoped = getRootScoped();
694
+ if (store?.routerId !== undefined) def.$$routerId = store.routerId;
695
+ }
696
+
669
697
  // Export HelperContext type for use in other modules
670
698
  export type { HelperContext };
671
699
 
@@ -208,6 +208,9 @@ export interface RequestContext<
208
208
  */
209
209
  _transitionWhen?: Array<{ id: string; when: TransitionWhenFn }>;
210
210
 
211
+ /** @internal PPR transition decisions evaluated before cache lookup/handlers. */
212
+ _pprTransitionDecisions?: Map<string, boolean>;
213
+
211
214
  /** @internal Cache store for segment caching (optional, used by CacheScope) */
212
215
  _cacheStore?: SegmentCacheStore;
213
216
 
@@ -254,21 +257,30 @@ export interface RequestContext<
254
257
  >;
255
258
 
256
259
  /**
257
- * @internal Shell fast-path marker: makes the NEXT full match treat the whole
258
- * matched route as an implicit doc-level cache() boundary (see
260
+ * @internal Shell fast-path marker: makes the next eligible match treat the
261
+ * whole matched route as an implicit doc-level cache() boundary (see
259
262
  * resolveShellImplicitCacheScope in cache/cache-scope.ts). Set ONLY on
260
263
  * (a) the capture's derived context — with a record-only store so the
261
264
  * capture's cacheRoute write lands in the snapshot, never the real store —
262
- * and (b) a HIT tail's seeded context when the entry is eligible
263
- * (!handlerLiveHoles), where the SeededShellStore serves the recorded doc
264
- * entry and the match skips handler execution entirely. Routes with their
265
- * own cache() config (including cache(false)) are never overridden: the
266
- * marker only applies when the route tree derived NO cache scope.
265
+ * (b) a HIT tail's seeded context, and (c) an eligible normal-route partial
266
+ * replay, where `store` is a request-local segment overlay. Handler-live holes
267
+ * and conditional transitions decline replay.
268
+ * Routes with their own cache() config (including cache(false)) are never
269
+ * overridden: the marker only applies when the route tree derived NO cache
270
+ * scope.
267
271
  */
268
272
  _shellImplicitCache?: {
269
273
  ttl?: number;
270
274
  swr?: number;
271
275
  store?: SegmentCacheStore;
276
+ /**
277
+ * @internal Override the implicit scope's default cache namespace. Shell
278
+ * captures and navigation replay both consume the canonical document
279
+ * segment record even when the triggering request is partial.
280
+ */
281
+ keyPrefix?: "doc";
282
+ /** @internal Called only after the implicit cache hit decodes successfully. */
283
+ onHit?: () => void;
272
284
  };
273
285
 
274
286
  /**
@@ -606,6 +618,10 @@ export interface RequestContext<
606
618
  /** @internal Router basename for this request (used by redirect()) */
607
619
  _basename?: string;
608
620
 
621
+ /** @internal Owning router id; scopes the search-schema/root-scope registry
622
+ * lookups per router (route-map-builder.ts) */
623
+ _routerId?: string;
624
+
609
625
  /**
610
626
  * @internal RouteSnapshot from classifyRequest, reused by match/matchPartial
611
627
  * to avoid a second resolveRoute call. Cleared on HMR invalidation.
@@ -639,14 +655,22 @@ export type PublicRequestContext<
639
655
  | "deleteCookie"
640
656
  | "_handleStore"
641
657
  | "_transitionWhen"
658
+ | "_pprTransitionDecisions"
642
659
  | "_cacheStore"
643
660
  | "_shellCaptureRun"
661
+ | "_shellImplicitCache"
662
+ | "_shellLoaderSeed"
663
+ | "_shellCaptureLoaderRecords"
664
+ | "_shellCaptureHandleLiveness"
665
+ | "_shellCaptureLoaderHandleValues"
644
666
  | "_shellFragmentPayload"
667
+ | "_shellCaptureGuardTripped"
645
668
  | "_shellCaptureGuardTrippedLoaderId"
646
669
  | "_explicitTaggedStores"
647
670
  | "_requestTags"
648
671
  | "_cacheProfiles"
649
672
  | "_onResponseCallbacks"
673
+ | "_pendingBackgroundTasks"
650
674
  | "_themeConfig"
651
675
  | "_locationState"
652
676
  | "_routeName"
@@ -673,7 +697,9 @@ export type PublicRequestContext<
673
697
  | "_renderForeground"
674
698
  | "_renderDiagnosticsEnabled"
675
699
  | "_handlerStart"
700
+ | "_tracing"
676
701
  | "_basename"
702
+ | "_routerId"
677
703
  | "_setStatus"
678
704
  | "_rotateStateCookie"
679
705
  | "_setKeepCacheDirective"
@@ -765,7 +791,7 @@ export function setRequestContextParams(
765
791
  routeMap ?? getGlobalRouteMap(),
766
792
  routeName,
767
793
  params,
768
- routeName ? isRouteRootScoped(routeName) : undefined,
794
+ routeName ? isRouteRootScoped(routeName, ctx._routerId) : undefined,
769
795
  );
770
796
  }
771
797
  }
@@ -1496,7 +1522,7 @@ export function createUseFunction<TEnv>(
1496
1522
  // RequestContext carries no `search` field, so reading `(ctx as any).search`
1497
1523
  // here always yielded {} — dropping typed search for action/dispatch loaders.
1498
1524
  const searchSchema = ctx._routeName
1499
- ? getSearchSchema(ctx._routeName)
1525
+ ? getSearchSchema(ctx._routeName, ctx._routerId)
1500
1526
  : undefined;
1501
1527
  const loaderSearch = searchSchema
1502
1528
  ? parseSearchParams(ctx.searchParams, searchSchema)
@@ -1526,7 +1552,9 @@ export function createUseFunction<TEnv>(
1526
1552
  getGlobalRouteMap(),
1527
1553
  ctx._routeName,
1528
1554
  ctx.params as Record<string, string>,
1529
- ctx._routeName ? isRouteRootScoped(ctx._routeName) : undefined,
1555
+ ctx._routeName
1556
+ ? isRouteRootScoped(ctx._routeName, ctx._routerId)
1557
+ : undefined,
1530
1558
  ),
1531
1559
  rendered: () => {
1532
1560
  throw new Error(
package/src/server.ts CHANGED
@@ -25,8 +25,6 @@ export {
25
25
  clearAllRouterData,
26
26
  getGlobalRouteMap,
27
27
  getRouterManifest,
28
- setPrecomputedEntries,
29
- setRouteTrie,
30
28
  setManifestReadyPromise,
31
29
  setRouterManifest,
32
30
  setRouterTrie,
@@ -413,6 +413,7 @@ export async function dispatch<TEnv = any>(
413
413
  // with it. Mirror it so basename-redirect tests behave as they do in a real
414
414
  // mounted app instead of always seeing no prefix.
415
415
  requestContext._basename = router.basename;
416
+ requestContext._routerId = routerId;
416
417
 
417
418
  // Match production's response-route reverse EXACTLY: the real handler builds
418
419
  // it from the route map alone (response-route-handler.ts), with NO matched
@@ -41,10 +41,15 @@ export {
41
41
  type CacheStatusTarget,
42
42
  } from "../cache-status.js";
43
43
  export {
44
+ assertPprReplayStatus,
44
45
  assertShellStatus,
46
+ parsePprReplayStatus,
45
47
  parseShellStatus,
48
+ PPR_REPLAY_STATUS_HEADER,
46
49
  shellCacheKey,
47
50
  SHELL_STATUS_HEADER,
51
+ type PprReplayBypassReason,
52
+ type PprReplayStatus,
48
53
  type ShellStatus,
49
54
  type ShellStatusTarget,
50
55
  } from "../shell-status.js";
@@ -77,12 +77,20 @@ export type {
77
77
  } from "../router/telemetry.js";
78
78
 
79
79
  export {
80
+ assertPprReplayStatus,
80
81
  assertShellStatus,
82
+ parsePprReplayStatus,
81
83
  parseShellStatus,
84
+ PPR_REPLAY_STATUS_HEADER,
82
85
  shellCacheKey,
83
86
  SHELL_STATUS_HEADER,
84
87
  } from "./shell-status.js";
85
- export type { ShellStatus, ShellStatusTarget } from "./shell-status.js";
88
+ export type {
89
+ PprReplayBypassReason,
90
+ PprReplayStatus,
91
+ ShellStatus,
92
+ ShellStatusTarget,
93
+ } from "./shell-status.js";
86
94
 
87
95
  export { collectHandle } from "./collect-handle.js";
88
96
 
@@ -1,10 +1,9 @@
1
1
  /**
2
2
  * runTransitionWhen — unit-test a transition({ when }) predicate in isolation.
3
3
  *
4
- * Runs the SAME two server functions the router uses — applyViewTransitionDefault
5
- * (strips the `when` function from the serialized config and records the
6
- * predicate on the request context) and gateTransitions (assembles the
7
- * TransitionWhenContext and evaluates the predicate post-handler). So the
4
+ * Runs the SAME server functions the router uses — the PPR pre-handler evaluator
5
+ * when `ppr: true`, applyViewTransitionDefault (which strips the server-only
6
+ * function), and gateTransitions. So the
8
7
  * predicate sees exactly the navigation/action metadata it would at runtime
9
8
  * (currentUrl/currentParams/fromRouteName, nextUrl/nextParams/toRouteName,
10
9
  * actionId/actionUrl/actionResult/formData/method, get/env), and `kept` reflects
@@ -33,6 +32,9 @@ import type {
33
32
  TransitionWhenContext,
34
33
  } from "../types/segments.js";
35
34
  import type { OnErrorCallback } from "../types/error-types.js";
35
+ import type { EntryData } from "../server/context.js";
36
+ import { evaluatePprTransitionWhen } from "../router/transition-when.js";
37
+ import { invokeOnError } from "../router/error-handling.js";
36
38
 
37
39
  const toURL = (v: string | URL, base: URL): URL =>
38
40
  typeof v === "string" ? new URL(v, base.origin) : v;
@@ -53,7 +55,7 @@ export interface RunTransitionWhenOptions<TEnv = any> {
53
55
  toRouteName?: string;
54
56
  /** Environment bindings surfaced as `env` (and `ctx.env`). */
55
57
  env?: TEnv;
56
- /** Variables a handler/middleware would have set this request, readable via the predicate's `get()`. */
58
+ /** Variables readable via the predicate's `get()`. With `ppr`, these model pre-handler middleware/input state. */
57
59
  vars?: VarsInit;
58
60
  /** Navigation SOURCE url (`currentUrl`): a URL or path string. */
59
61
  currentUrl?: string | URL;
@@ -71,6 +73,8 @@ export interface RunTransitionWhenOptions<TEnv = any> {
71
73
  formData?: FormData;
72
74
  /** Receives an error thrown by the predicate (the gate reports to `router.onError`, phase `"rendering"`). */
73
75
  onError?: OnErrorCallback;
76
+ /** Model a route with `ppr`, where the predicate runs before route handlers and cache lookup. */
77
+ ppr?: boolean;
74
78
  }
75
79
 
76
80
  /**
@@ -138,8 +142,37 @@ export function runTransitionWhen<TEnv = any>(
138
142
  : config;
139
143
 
140
144
  return runWithRequestContext(reqCtx, () => {
141
- // The real resolution-time collection + post-handler gate, so the predicate
142
- // sees the production-assembled TransitionWhenContext.
145
+ if (opts.ppr) {
146
+ const entry = {
147
+ type: "route",
148
+ id: "tx-when-entry",
149
+ shortCode: "tx-when-seg",
150
+ parent: null,
151
+ transition: configForGate,
152
+ layout: [],
153
+ parallel: {},
154
+ } as unknown as EntryData;
155
+ evaluatePprTransitionWhen(
156
+ [entry],
157
+ reqCtx,
158
+ { params: reqCtx.params, routeName: opts.toRouteName },
159
+ (error, segmentId) =>
160
+ invokeOnError(
161
+ opts.onError,
162
+ error,
163
+ "rendering",
164
+ {
165
+ request: reqCtx.request,
166
+ url: reqCtx.url,
167
+ params: reqCtx.params,
168
+ segmentId,
169
+ },
170
+ "RSC",
171
+ ),
172
+ );
173
+ }
174
+ // Ordinary predicates are collected here and evaluated by the gate. PPR
175
+ // predicates keep the decision already evaluated before this step.
143
176
  const serialized = applyViewTransitionDefault(
144
177
  configForGate,
145
178
  undefined,
@@ -153,12 +186,12 @@ export function runTransitionWhen<TEnv = any>(
153
186
  component: null,
154
187
  transition: serialized,
155
188
  } as ResolvedSegment;
156
- gateTransitions(
189
+ const [gatedSegment] = gateTransitions(
157
190
  [segment],
158
191
  reqCtx as Parameters<typeof gateTransitions>[1],
159
192
  opts.onError,
160
193
  );
161
- const kept = segment.transition !== undefined;
194
+ const kept = gatedSegment.transition !== undefined;
162
195
  return { kept, dropped: !kept, whenContext, ctx: reqCtx };
163
196
  });
164
197
  }