@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
@@ -12,11 +12,6 @@ let globalRouteMap: Record<string, string> = {};
12
12
 
13
13
  let cachedManifest: Record<string, string> | null = null;
14
14
 
15
- let cachedPrecomputedEntries: Array<{
16
- staticPrefix: string;
17
- routes: Record<string, string>;
18
- }> | null = null;
19
-
20
15
  /**
21
16
  * Register routes into the global route map.
22
17
  * Routes are merged with any existing registered routes.
@@ -87,43 +82,6 @@ export function clearCachedManifest(): void {
87
82
  cachedManifest = null;
88
83
  }
89
84
 
90
- /**
91
- * Set pre-computed route entries from build-time data.
92
- *
93
- * Each entry corresponds to a leaf node in the prefix tree (no nested includes).
94
- * evaluateLazyEntry() checks these before running the handler, avoiding the
95
- * 5-50ms cost of handler evaluation for route matching on the first request.
96
- *
97
- * @param entries - Array of { staticPrefix, routes } from build-time prefix tree leaves
98
- */
99
- export function setPrecomputedEntries(
100
- entries: Array<{
101
- staticPrefix: string;
102
- routes: Record<string, string>;
103
- }> | null,
104
- ): void {
105
- cachedPrecomputedEntries = entries;
106
- }
107
-
108
- /**
109
- * Get pre-computed route entries (if available)
110
- */
111
- export function getPrecomputedEntries(): typeof cachedPrecomputedEntries {
112
- return cachedPrecomputedEntries;
113
- }
114
-
115
- // Route trie for O(path_length) matching at runtime.
116
- // Built at build time from the route manifest and serialized into the virtual module.
117
- let cachedRouteTrie: import("./build/route-trie.js").TrieNode | null = null;
118
-
119
- export function setRouteTrie(trie: typeof cachedRouteTrie): void {
120
- cachedRouteTrie = trie;
121
- }
122
-
123
- export function getRouteTrie(): typeof cachedRouteTrie {
124
- return cachedRouteTrie;
125
- }
126
-
127
85
  // Per-router isolated data: each router gets its own manifest, trie, and
128
86
  // precomputed entries so multi-router setups (e.g. site + admin via
129
87
  // createHostRouter()) don't see each other's routes.
@@ -146,14 +104,18 @@ const perRouterPrecomputedEntriesMap: Map<
146
104
  export function clearAllRouterData(): void {
147
105
  globalRouteMap = {};
148
106
  cachedManifest = null;
149
- cachedPrecomputedEntries = null;
150
- cachedRouteTrie = null;
151
107
  rootScopeRoutes.clear();
108
+ perRouterRootScopeRoutes.clear();
152
109
  globalSearchSchemas.clear();
110
+ perRouterSearchSchemas.clear();
153
111
  perRouterManifestMap.clear();
154
112
  perRouterTrieMap.clear();
155
113
  perRouterPrecomputedEntriesMap.clear();
156
114
  authoritativeTrieRouters.clear();
115
+ // Clear the loader registry too: a loader that survives a clear closes over
116
+ // a module import whose data predates the clear, and re-running it would
117
+ // re-install that stale trie as authoritative.
118
+ routerManifestLoaders.clear();
157
119
  }
158
120
 
159
121
  export function setRouterManifest(
@@ -229,9 +191,9 @@ export async function ensureRouterManifest(routerId: string): Promise<void> {
229
191
  // name->path map is pre-set by the eager virtual module's
230
192
  // setRouterManifest() and never comes from the loader). Gating on the
231
193
  // manifest instead would let the loader be skipped while the per-router
232
- // trie is missing, and findMatch would fall back to the global merged
233
- // trie — which contains routes from ALL routers and breaks multi-router
234
- // setups.
194
+ // trie is missing, and findMatch would fall back to insertion-order regex
195
+ // matching — which mishandles wildcard priority (catch-alls match before
196
+ // specific routes).
235
197
  if (perRouterTrieMap.has(routerId)) return;
236
198
  const loader = routerManifestLoaders.get(routerId);
237
199
  if (loader) {
@@ -265,7 +227,16 @@ export function waitForManifestReady(): Promise<void> | null {
265
227
  // Tracks whether each route is at root scope (no named include boundary above).
266
228
  // Used by dot-local reverse resolution to decide whether bare-name fallback
267
229
  // is allowed after scoped lookups are exhausted.
230
+ //
231
+ // Both this and the search-schema registry below are keyed by route NAME and
232
+ // registered by path() at evaluation time. Two routers can legally declare the
233
+ // same route name (per-router manifests keep them apart), so each registry has
234
+ // a per-router tier consulted first; the global tier is the fallback for
235
+ // contexts with no router identity (single-router apps, unit tests, evaluation
236
+ // outside generateManifestFull). Without the per-router tier, the
237
+ // last-evaluated router silently won for BOTH routers.
268
238
  const rootScopeRoutes: Map<string, boolean> = new Map();
239
+ const perRouterRootScopeRoutes: Map<string, Map<string, boolean>> = new Map();
269
240
 
270
241
  /**
271
242
  * Register whether a route is at root scope.
@@ -274,29 +245,65 @@ const rootScopeRoutes: Map<string, boolean> = new Map();
274
245
  export function registerRouteRootScope(
275
246
  routeName: string,
276
247
  rootScoped: boolean,
248
+ routerId?: string,
277
249
  ): void {
278
250
  rootScopeRoutes.set(routeName, rootScoped);
251
+ if (routerId) {
252
+ let perRouter = perRouterRootScopeRoutes.get(routerId);
253
+ if (!perRouter) {
254
+ perRouter = new Map();
255
+ perRouterRootScopeRoutes.set(routerId, perRouter);
256
+ }
257
+ perRouter.set(routeName, rootScoped);
258
+ }
279
259
  }
280
260
 
281
261
  /**
282
262
  * Check if a route is at root scope.
283
263
  * Returns undefined if the route has not been registered (e.g. in unit tests).
284
264
  */
285
- export function isRouteRootScoped(routeName: string): boolean | undefined {
265
+ export function isRouteRootScoped(
266
+ routeName: string,
267
+ routerId?: string,
268
+ ): boolean | undefined {
269
+ if (routerId) {
270
+ const scoped = perRouterRootScopeRoutes.get(routerId)?.get(routeName);
271
+ if (scoped !== undefined) return scoped;
272
+ }
286
273
  return rootScopeRoutes.get(routeName);
287
274
  }
288
275
 
289
276
  import type { SearchSchema } from "./search-params.js";
290
277
 
291
278
  const globalSearchSchemas: Map<string, SearchSchema> = new Map();
279
+ const perRouterSearchSchemas: Map<
280
+ string,
281
+ Map<string, SearchSchema>
282
+ > = new Map();
292
283
 
293
284
  export function registerSearchSchema(
294
285
  routeName: string,
295
286
  schema: SearchSchema,
287
+ routerId?: string,
296
288
  ): void {
297
289
  globalSearchSchemas.set(routeName, schema);
290
+ if (routerId) {
291
+ let perRouter = perRouterSearchSchemas.get(routerId);
292
+ if (!perRouter) {
293
+ perRouter = new Map();
294
+ perRouterSearchSchemas.set(routerId, perRouter);
295
+ }
296
+ perRouter.set(routeName, schema);
297
+ }
298
298
  }
299
299
 
300
- export function getSearchSchema(routeName: string): SearchSchema | undefined {
300
+ export function getSearchSchema(
301
+ routeName: string,
302
+ routerId?: string,
303
+ ): SearchSchema | undefined {
304
+ if (routerId) {
305
+ const schema = perRouterSearchSchemas.get(routerId)?.get(routeName);
306
+ if (schema !== undefined) return schema;
307
+ }
301
308
  return globalSearchSchemas.get(routeName);
302
309
  }
@@ -96,7 +96,7 @@ function resolveRouteName(
96
96
  // Resolution order: explicit param > scope registry > heuristic.
97
97
  const isRootScoped =
98
98
  rootScoped ??
99
- isRouteRootScoped(currentRoutePrefix) ??
99
+ isRouteRootScoped(currentRoutePrefix, _getRequestContext()?._routerId) ??
100
100
  !currentRoutePrefix.includes(".");
101
101
  if (isRootScoped) {
102
102
  if (routeMap[lookupName] !== undefined) {
@@ -208,7 +208,9 @@ export function createHandlerContext<TEnv>(
208
208
  const variables: any = requestContext?._variables ?? {};
209
209
 
210
210
  // If route has a search schema, parse URLSearchParams into typed object
211
- const searchSchema = routeName ? getSearchSchema(routeName) : undefined;
211
+ const searchSchema = routeName
212
+ ? getSearchSchema(routeName, requestContext?._routerId)
213
+ : undefined;
212
214
  const resolvedSearchParams = searchSchema
213
215
  ? parseSearchParams(searchParams, searchSchema)
214
216
  : searchParams;
@@ -306,7 +308,9 @@ export function createHandlerContext<TEnv>(
306
308
  routeMap,
307
309
  routeName,
308
310
  params,
309
- routeName ? isRouteRootScoped(routeName) : undefined,
311
+ routeName
312
+ ? isRouteRootScoped(routeName, requestContext?._routerId)
313
+ : undefined,
310
314
  ),
311
315
  passthrough: createPrerenderPassthroughFn(false, isPassthroughRoute),
312
316
  _responseType: responseType,
@@ -399,7 +403,9 @@ export function createPrerenderContext<TEnv>(
399
403
  routeMap,
400
404
  routeName,
401
405
  params,
402
- routeName ? isRouteRootScoped(routeName) : undefined,
406
+ routeName
407
+ ? isRouteRootScoped(routeName, _getRequestContext()?._routerId)
408
+ : undefined,
403
409
  ),
404
410
  passthrough: createPrerenderPassthroughFn(
405
411
  true,
@@ -496,7 +502,9 @@ export function createStaticContext<TEnv>(
496
502
  routeMap,
497
503
  routeName,
498
504
  undefined,
499
- routeName ? isRouteRootScoped(routeName) : undefined,
505
+ routeName
506
+ ? isRouteRootScoped(routeName, _getRequestContext()?._routerId)
507
+ : undefined,
500
508
  ),
501
509
  _routeName: routeName,
502
510
  } as InternalHandlerContext<any, TEnv>;
@@ -171,6 +171,7 @@ function runExpansion<TEnv = any>(
171
171
  counters: lazyCounters,
172
172
  cacheProfiles: lazyContext?.cacheProfiles,
173
173
  rootScoped: lazyContext?.rootScoped,
174
+ routerId: lazyContext?.routerId,
174
175
  includeScope: lazyContext?.includeScope,
175
176
  },
176
177
  () => {
@@ -184,9 +184,8 @@ export async function createMatchContextForFull<TEnv>(
184
184
  },
185
185
  isSameRouteNavigation: false,
186
186
  interceptResult: null,
187
- // Shell fast path: a capture or an eligible HIT tail may substitute an
188
- // implicit doc-level scope (marker-gated; a route-derived scope wins).
189
- // Full matches only — partial navigations never serve a shell.
187
+ // Shell fast path: a capture or an eligible document/navigation HIT may
188
+ // substitute an implicit doc-level scope (a route-derived scope wins).
190
189
  cacheScope: resolveShellImplicitCacheScope(snapshot.cacheScope),
191
190
  isIntercept: false,
192
191
  actionContext: undefined,
@@ -426,7 +425,12 @@ export async function createMatchContextForPartial<TEnv>(
426
425
  interceptSelectorContext,
427
426
  isSameRouteNavigation: nav.isSameRouteNavigation,
428
427
  interceptResult,
429
- cacheScope: snapshot.cacheScope,
428
+ // A normal-route navigation may replay a PPR shell's canonical segment
429
+ // snapshot. Intercepts remain source-dependent and always use their normal
430
+ // cache path.
431
+ cacheScope: isIntercept
432
+ ? snapshot.cacheScope
433
+ : resolveShellImplicitCacheScope(snapshot.cacheScope),
430
434
  isIntercept,
431
435
  actionContext,
432
436
  isAction,
@@ -1,12 +1,19 @@
1
1
  import type { ReactNode } from "react";
2
2
  import { INTERNAL_RANGO_DEBUG } from "../internal-debug.js";
3
3
  import { sanitizeError } from "../errors";
4
- import type { ErrorInfo, ErrorPhase, MatchResult } from "../types";
5
4
  import type {
6
- EntryData,
7
- InterceptEntry,
8
- InterceptSelectorContext,
9
- } from "../server/context";
5
+ ErrorBoundaryHandler,
6
+ ErrorInfo,
7
+ ErrorPhase,
8
+ MatchResult,
9
+ NotFoundBoundaryHandler,
10
+ } from "../types";
11
+ import {
12
+ isPprEntry,
13
+ type EntryData,
14
+ type InterceptEntry,
15
+ type InterceptSelectorContext,
16
+ } from "../server/context.js";
10
17
  import type { MatchApiDeps } from "./types.js";
11
18
  import type { RouterContext } from "./router-context.js";
12
19
  import { runWithRouterContext } from "./router-context.js";
@@ -30,7 +37,6 @@ import {
30
37
  startRevalidationTrace,
31
38
  flushRevalidationTrace,
32
39
  } from "./logging.js";
33
- import type { ErrorBoundaryHandler, NotFoundBoundaryHandler } from "../types";
34
40
  import type { MiddlewareFn } from "./middleware.js";
35
41
  import {
36
42
  type TelemetrySink,
@@ -41,6 +47,7 @@ import {
41
47
  buildCacheSignalSegments,
42
48
  } from "./telemetry.js";
43
49
  import { _getRequestContext } from "../server/request-context.js";
50
+ import { evaluatePprTransitionWhen } from "./transition-when.js";
44
51
 
45
52
  /**
46
53
  * Per-call telemetry lifecycle emitter for match()/matchPartial(). Each method
@@ -227,6 +234,35 @@ export function createMatchHandlers<TEnv = any>(
227
234
  if (reqCtx) reqCtx._cacheSignal = segments;
228
235
  };
229
236
 
237
+ const evaluatePprTransitionWhenForMatch = (
238
+ ctx: MatchContext<TEnv>,
239
+ isPartial: boolean,
240
+ ): void => {
241
+ const reqCtx = _getRequestContext();
242
+ if (!reqCtx) return;
243
+ reqCtx._pprTransitionDecisions = undefined;
244
+ if (!isPprEntry(ctx.manifestEntry)) return;
245
+
246
+ evaluatePprTransitionWhen(
247
+ ctx.entries,
248
+ reqCtx,
249
+ {
250
+ params: ctx.matched.params,
251
+ routeName: ctx.interceptSelectorContext.toRouteName,
252
+ },
253
+ (error, segmentId) =>
254
+ callOnError(error, "rendering", {
255
+ request: ctx.request,
256
+ url: ctx.url,
257
+ env: ctx.env,
258
+ params: ctx.matched.params,
259
+ segmentId,
260
+ isPartial,
261
+ handledByBoundary: false,
262
+ }),
263
+ );
264
+ };
265
+
230
266
  async function createMatchContextForFull(
231
267
  request: Request,
232
268
  env: TEnv,
@@ -313,6 +349,7 @@ export function createMatchHandlers<TEnv = any>(
313
349
  }
314
350
 
315
351
  const ctx = result as MatchContext<TEnv>;
352
+ evaluatePprTransitionWhenForMatch(ctx, false);
316
353
 
317
354
  try {
318
355
  const state = createPipelineState();
@@ -424,6 +461,7 @@ export function createMatchHandlers<TEnv = any>(
424
461
  emitter.end(0, false);
425
462
  return null;
426
463
  }
464
+ evaluatePprTransitionWhenForMatch(ctx, true);
427
465
 
428
466
  if (isRouterDebugEnabled()) {
429
467
  startRevalidationTrace({
@@ -460,6 +460,8 @@ export function withCacheLookup<TEnv>(
460
460
  state.shouldRevalidate = cacheResult.shouldRevalidate;
461
461
  state.cachedSegments = cacheResult.segments;
462
462
  state.cachedMatchedIds = cacheResult.segments.map((s) => s.id);
463
+ const pprTransitionDecisions =
464
+ _getRequestContext()?._pprTransitionDecisions;
463
465
 
464
466
  const canCheckSegmentRevalidation =
465
467
  !ctx.isFullMatch &&
@@ -533,8 +535,13 @@ export function withCacheLookup<TEnv>(
533
535
  reason: "cached-no-rules",
534
536
  });
535
537
  }
536
- segment.component = null;
537
- segment.loading = undefined;
538
+ // A PPR transition decision must reach the client even when this cached
539
+ // segment otherwise needs no update. Keep its replayed component so the
540
+ // partial result retains the segment for gateTransitions().
541
+ if (!pprTransitionDecisions?.has(segment.id)) {
542
+ segment.component = null;
543
+ segment.loading = undefined;
544
+ }
538
545
  yield segment;
539
546
  continue;
540
547
  }
@@ -566,7 +573,7 @@ export function withCacheLookup<TEnv>(
566
573
  shouldRevalidate,
567
574
  });
568
575
 
569
- if (!shouldRevalidate) {
576
+ if (!shouldRevalidate && !pprTransitionDecisions?.has(segment.id)) {
570
577
  segment.component = null;
571
578
  segment.loading = undefined;
572
579
  }
@@ -34,6 +34,9 @@ import type {
34
34
  import type { RouteMatchResult } from "./pattern-matching.js";
35
35
 
36
36
  export interface PrerenderMatchDeps<TEnv = any> {
37
+ /** Owning router id; scopes bake-time root-scope/search-schema lookups the
38
+ * same way reqCtx._routerId does at runtime (issue #762). */
39
+ routerId?: string;
37
40
  findMatch: (
38
41
  pathname: string,
39
42
  ) => RouteMatchResult<TEnv> | null | Promise<RouteMatchResult<TEnv> | null>;
@@ -244,8 +247,13 @@ export async function matchForPrerender<TEnv = any>(
244
247
  deps.mergedRouteMap,
245
248
  matched.routeKey,
246
249
  matchedParams,
247
- matched.routeKey ? isRouteRootScoped(matched.routeKey) : undefined,
250
+ matched.routeKey
251
+ ? isRouteRootScoped(matched.routeKey, deps.routerId)
252
+ : undefined,
248
253
  ),
254
+ // Scope the bake context's own lookups (createPrerenderContext reads it
255
+ // through the request ALS) per router as well.
256
+ _routerId: deps.routerId,
249
257
  };
250
258
 
251
259
  return runWithRequestContext(minimalRequestContext, async () => {
@@ -464,6 +472,8 @@ export async function renderStaticSegment<TEnv = any>(
464
472
  routeName?: string,
465
473
  buildEnv?: TEnv,
466
474
  devMode?: boolean,
475
+ routerId?: string,
476
+ rootScoped?: boolean,
467
477
  ): Promise<{ encoded: string; handles: string } | null> {
468
478
  const syntheticUrl = new URL("http://prerender/");
469
479
  const syntheticRequest = new Request(syntheticUrl);
@@ -514,8 +524,15 @@ export async function renderStaticSegment<TEnv = any>(
514
524
  mergedRouteMap,
515
525
  routeName,
516
526
  {},
517
- routeName ? isRouteRootScoped(routeName) : undefined,
527
+ // Def-stamped value first: the collector passes the mount-time
528
+ // root-scope captured on the Static definition (stampStaticDefScope),
529
+ // which needs no registry lookup and survives name-prefix arguments.
530
+ rootScoped ??
531
+ (routeName ? isRouteRootScoped(routeName, routerId) : undefined),
518
532
  ),
533
+ // Scope the bake context's own lookups (createStaticContext reads it
534
+ // through the request ALS) per router as well.
535
+ _routerId: routerId,
519
536
  };
520
537
 
521
538
  return runWithRequestContext(minimalRequestContext, async () => {
@@ -419,6 +419,9 @@ export interface RangoInternal<
419
419
  /** @internal basename for runtime manifest generation */
420
420
  readonly __basename?: string;
421
421
 
422
+ /** @internal Cloudflare dev worker generation captured at construction. */
423
+ readonly __devDiscoveryEpoch?: number;
424
+
422
425
  /**
423
426
  * @internal Router-level error/notFound fallbacks (`createRouter` options),
424
427
  * exposed for the build-time clientChunks discovery so a `"use client"`
@@ -469,6 +472,7 @@ export interface RangoInternal<
469
472
  routeName?: string,
470
473
  buildEnv?: any,
471
474
  devMode?: boolean,
475
+ rootScoped?: boolean,
472
476
  ): Promise<{ encoded: string; handles: string } | null>;
473
477
 
474
478
  /**
@@ -11,14 +11,19 @@ import type { StaticStore } from "../../prerender/store.js";
11
11
  import type { SegmentHandleData } from "../../cache/types.js";
12
12
 
13
13
  // Lazy-initialized static store for production Static handler interception.
14
- // Remains undefined until first check; null means checked but no manifest.
15
- // When no __STATIC_MANIFEST exists (dev mode), set to null eagerly to avoid
16
- // the dynamic import() in ensureStaticDeps() which disrupts AsyncLocalStorage
17
- // in workerd/Cloudflare runtime.
18
- let _staticStore: StaticStore | null | undefined =
19
- typeof globalThis !== "undefined" && globalThis.__STATIC_MANIFEST
20
- ? undefined
21
- : null;
14
+ // undefined = not yet checked; null = checked, no manifest (dev mode, or an
15
+ // app without Static handlers) a permanent SYNCHRONOUS fast path: no await
16
+ // may ever run on that path (an await per lookup disrupts AsyncLocalStorage
17
+ // in workerd/Cloudflare runtime).
18
+ //
19
+ // The check happens on the FIRST LOOKUP, not at module eval: the manifest
20
+ // loader global (__loadStaticManifestModule, bundle-postprocess.ts) is
21
+ // assigned by the RSC entry's BODY, which runs after hoisted module imports
22
+ // evaluate this file — an eval-time read here could never see it. The
23
+ // pre-#760 eager `import "./__static-manifest.js"` existed precisely to beat
24
+ // that hoisting; deferring the manifest module keeps its O(#Static handlers)
25
+ // thunk table off the worker's cold-start path.
26
+ let _staticStore: StaticStore | null | undefined = undefined;
22
27
  let _deserializeComponent: ((encoded: string) => Promise<unknown>) | undefined;
23
28
  let _decodeHandleValue:
24
29
  | typeof import("../../cache/handle-snapshot.js").decodeHandleValue
@@ -26,6 +31,18 @@ let _decodeHandleValue:
26
31
 
27
32
  async function ensureStaticDeps(): Promise<void> {
28
33
  if (_staticStore === undefined) {
34
+ // Evaluate the deferred manifest module (it assigns __STATIC_MANIFEST);
35
+ // support a pre-set __STATIC_MANIFEST too (tests, non-postprocess embeds).
36
+ if (
37
+ !globalThis.__STATIC_MANIFEST &&
38
+ globalThis.__loadStaticManifestModule
39
+ ) {
40
+ await globalThis.__loadStaticManifestModule();
41
+ }
42
+ if (!globalThis.__STATIC_MANIFEST) {
43
+ _staticStore = null;
44
+ return;
45
+ }
29
46
  const { createStaticStore } = await import("../../prerender/store.js");
30
47
  _staticStore = createStaticStore();
31
48
  }
@@ -52,8 +69,17 @@ export async function tryStaticLookup(
52
69
  handlerId: string,
53
70
  segmentId: string,
54
71
  ): Promise<ReactNode | undefined> {
55
- // Fast path: already checked and no manifest available (dev mode or no Static handlers).
56
- // Avoids await which can disrupt AsyncLocalStorage in workerd/Cloudflare runtime.
72
+ // Fast path: no manifest available (dev mode or no Static handlers).
73
+ // Latched SYNCHRONOUSLY including on the very first call — so this path
74
+ // never awaits (an await here disrupts AsyncLocalStorage in
75
+ // workerd/Cloudflare runtime).
76
+ if (
77
+ _staticStore === undefined &&
78
+ !globalThis.__STATIC_MANIFEST &&
79
+ !globalThis.__loadStaticManifestModule
80
+ ) {
81
+ _staticStore = null;
82
+ }
57
83
  if (_staticStore === null) return undefined;
58
84
  await ensureStaticDeps();
59
85
  if (!_staticStore || !_deserializeComponent) return undefined;
@@ -23,10 +23,9 @@ import { getRequestContext } from "../../server/request-context.js";
23
23
  * gate, which also avoids needless object allocation and payload growth.
24
24
  *
25
25
  * `when`: a server-only predicate. It is STRIPPED from the returned config (a
26
- * function cannot cross Flight or the segment cache) and recorded on the request
27
- * context keyed by `segmentId`, so rsc-rendering can evaluate it post-handler
28
- * outside any cache scope and drop this segment's transition when it returns
29
- * false. Used by both the fresh and revalidation resolution paths.
26
+ * function cannot cross Flight or the segment cache). PPR routes evaluate it
27
+ * from the manifest before the pipeline; other routes record it here for the
28
+ * existing post-handler gate. Used by both fresh and revalidation resolution.
30
29
  */
31
30
  export function applyViewTransitionDefault(
32
31
  transition: EntryData["transition"],
@@ -39,7 +38,12 @@ export function applyViewTransitionDefault(
39
38
  if (segmentId !== undefined) {
40
39
  try {
41
40
  const ctx = getRequestContext();
42
- (ctx._transitionWhen ??= []).push({ id: segmentId, when: result.when });
41
+ if (!ctx._pprTransitionDecisions?.has(segmentId)) {
42
+ (ctx._transitionWhen ??= []).push({
43
+ id: segmentId,
44
+ when: result.when,
45
+ });
46
+ }
43
47
  } catch {
44
48
  // No active request context (e.g. a unit test calling this util
45
49
  // directly). Skip collection; the strip below still applies so the
@@ -0,0 +1,76 @@
1
+ import { getParallelSlotEntries, type EntryData } from "../server/context.js";
2
+ import type { RequestContext } from "../server/request-context.js";
3
+ import type { TransitionWhenContext } from "../types/segments.js";
4
+
5
+ export type TransitionWhenErrorReporter = (
6
+ error: unknown,
7
+ segmentId: string,
8
+ ) => void;
9
+
10
+ export function createTransitionWhenContext<TEnv>(
11
+ ctx: RequestContext<TEnv>,
12
+ target?: {
13
+ params: Record<string, string>;
14
+ routeName?: string;
15
+ },
16
+ ): TransitionWhenContext<Record<string, string>, TEnv> {
17
+ return {
18
+ currentUrl: ctx._gateCurrentUrl,
19
+ currentParams: ctx._gateCurrentParams,
20
+ fromRouteName: ctx._prevRouteKey as TransitionWhenContext["fromRouteName"],
21
+ nextUrl: ctx.url,
22
+ nextParams: target?.params ?? ctx.params,
23
+ toRouteName: (target?.routeName ??
24
+ ctx.routeName) as TransitionWhenContext["toRouteName"],
25
+ actionId: ctx._gateActionId,
26
+ actionUrl: ctx._gateActionUrl,
27
+ actionResult: ctx._gateActionResult,
28
+ formData: ctx._gateFormData,
29
+ method: ctx.request.method,
30
+ get: ctx.get,
31
+ env: ctx.env,
32
+ };
33
+ }
34
+
35
+ /** Evaluate server-only transition gates before handlers on a PPR route. */
36
+ export function evaluatePprTransitionWhen<TEnv>(
37
+ entries: EntryData[],
38
+ ctx: RequestContext<TEnv>,
39
+ target: {
40
+ params: Record<string, string>;
41
+ routeName?: string;
42
+ },
43
+ reportError: TransitionWhenErrorReporter,
44
+ ): void {
45
+ const decisions = new Map<string, boolean>();
46
+ const whenContext = createTransitionWhenContext(ctx, target);
47
+ const visited = new Set<EntryData>();
48
+
49
+ const evaluate = (entry: EntryData, segmentId: string): void => {
50
+ const when = entry.transition?.when;
51
+ if (!when) return;
52
+ try {
53
+ decisions.set(segmentId, when(whenContext) !== false);
54
+ } catch (error) {
55
+ decisions.set(segmentId, false);
56
+ reportError(error, segmentId);
57
+ }
58
+ };
59
+
60
+ const visitEntryAndOrphanLayouts = (entry: EntryData): void => {
61
+ if (visited.has(entry)) return;
62
+ visited.add(entry);
63
+ evaluate(entry, entry.shortCode);
64
+
65
+ for (const orphan of entry.layout) visitEntryAndOrphanLayouts(orphan);
66
+ // Rendered parallel slots are leaves identified by their owning segment.
67
+ for (const { slot, entry: parallelEntry } of getParallelSlotEntries(
68
+ entry.parallel,
69
+ )) {
70
+ evaluate(parallelEntry, `${entry.shortCode}.${slot}`);
71
+ }
72
+ };
73
+
74
+ for (const entry of entries) visitEntryAndOrphanLayouts(entry);
75
+ ctx._pprTransitionDecisions = decisions.size > 0 ? decisions : undefined;
76
+ }