@rangojs/router 0.1.0 → 0.1.1

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 (89) hide show
  1. package/dist/bin/rango.js +11 -1
  2. package/dist/types/browser/dev-discovery.d.ts +11 -0
  3. package/dist/types/browser/navigation-store.d.ts +3 -13
  4. package/dist/types/browser/prefetch/loader.d.ts +10 -0
  5. package/dist/types/browser/prefetch/runtime.d.ts +3 -0
  6. package/dist/types/browser/types.d.ts +4 -16
  7. package/dist/types/build/generate-manifest.d.ts +6 -0
  8. package/dist/types/cache/cache-scope.d.ts +7 -5
  9. package/dist/types/cache/cf/cf-cache-store.d.ts +10 -9
  10. package/dist/types/cache/memory-segment-store.d.ts +6 -6
  11. package/dist/types/cache/shell-snapshot.d.ts +21 -8
  12. package/dist/types/cache/types.d.ts +24 -7
  13. package/dist/types/cache/vercel/vercel-cache-store.d.ts +9 -8
  14. package/dist/types/dev-discovery-protocol.d.ts +5 -0
  15. package/dist/types/prerender/store.d.ts +1 -0
  16. package/dist/types/route-map-builder.d.ts +4 -29
  17. package/dist/types/router/prerender-match.d.ts +4 -1
  18. package/dist/types/router/router-interfaces.d.ts +3 -1
  19. package/dist/types/rsc/handler-context.d.ts +1 -0
  20. package/dist/types/rsc/render-pipeline.d.ts +3 -2
  21. package/dist/types/rsc/rsc-rendering.d.ts +5 -10
  22. package/dist/types/rsc/shell-capture.d.ts +4 -8
  23. package/dist/types/rsc/types.d.ts +2 -0
  24. package/dist/types/server/context.d.ts +16 -0
  25. package/dist/types/server/request-context.d.ts +18 -8
  26. package/dist/types/server.d.ts +1 -1
  27. package/dist/types/types/route-entry.d.ts +3 -0
  28. package/dist/types/vite/discovery/state.d.ts +3 -2
  29. package/dist/types/vite/utils/manifest-utils.d.ts +1 -1
  30. package/dist/vite/index.js +194 -107
  31. package/package.json +7 -2
  32. package/skills/caching/SKILL.md +1 -1
  33. package/skills/ppr/SKILL.md +48 -4
  34. package/src/browser/dev-discovery.ts +66 -0
  35. package/src/browser/navigation-client.ts +6 -0
  36. package/src/browser/navigation-store.ts +4 -271
  37. package/src/browser/partial-update.ts +7 -14
  38. package/src/browser/prefetch/cache.ts +1 -1
  39. package/src/browser/prefetch/loader.ts +110 -0
  40. package/src/browser/prefetch/runtime.ts +7 -0
  41. package/src/browser/react/Link.tsx +7 -10
  42. package/src/browser/react/NavigationProvider.tsx +1 -1
  43. package/src/browser/react/use-router.ts +1 -1
  44. package/src/browser/rsc-router.tsx +4 -2
  45. package/src/browser/types.ts +4 -27
  46. package/src/build/generate-manifest.ts +11 -0
  47. package/src/cache/cache-scope.ts +17 -6
  48. package/src/cache/cf/cf-cache-store.ts +63 -39
  49. package/src/cache/memory-segment-store.ts +17 -6
  50. package/src/cache/shell-snapshot.ts +45 -15
  51. package/src/cache/types.ts +23 -6
  52. package/src/cache/vercel/vercel-cache-store.ts +52 -22
  53. package/src/dev-discovery-protocol.ts +11 -0
  54. package/src/prerender/build-shell-capture.ts +7 -1
  55. package/src/prerender/store.ts +5 -0
  56. package/src/route-definition/dsl-helpers.ts +7 -2
  57. package/src/route-map-builder.ts +56 -49
  58. package/src/router/handler-context.ts +13 -5
  59. package/src/router/lazy-includes.ts +1 -0
  60. package/src/router/match-api.ts +8 -4
  61. package/src/router/prerender-match.ts +19 -2
  62. package/src/router/router-interfaces.ts +4 -0
  63. package/src/router/segment-resolution/static-store.ts +36 -10
  64. package/src/router.ts +34 -3
  65. package/src/rsc/handler-context.ts +1 -0
  66. package/src/rsc/handler.ts +3 -1
  67. package/src/rsc/loader-fetch.ts +2 -2
  68. package/src/rsc/manifest-init.ts +4 -11
  69. package/src/rsc/render-pipeline.ts +10 -2
  70. package/src/rsc/rsc-rendering.ts +207 -146
  71. package/src/rsc/shell-capture.ts +34 -11
  72. package/src/rsc/types.ts +2 -0
  73. package/src/server/context.ts +28 -0
  74. package/src/server/request-context.ts +32 -10
  75. package/src/server.ts +0 -2
  76. package/src/testing/dispatch.ts +1 -0
  77. package/src/types/route-entry.ts +3 -0
  78. package/src/urls/include-helper.ts +1 -0
  79. package/src/urls/path-helper.ts +8 -4
  80. package/src/vite/discovery/bundle-postprocess.ts +10 -7
  81. package/src/vite/discovery/discover-routers.ts +7 -66
  82. package/src/vite/discovery/prerender-collection.ts +13 -2
  83. package/src/vite/discovery/state.ts +4 -4
  84. package/src/vite/discovery/virtual-module-codegen.ts +75 -42
  85. package/src/vite/plugins/version-injector.ts +0 -1
  86. package/src/vite/plugins/virtual-entries.ts +11 -1
  87. package/src/vite/router-discovery.ts +132 -22
  88. package/src/vite/utils/manifest-utils.ts +1 -4
  89. package/src/vite/utils/shared-utils.ts +7 -0
@@ -134,9 +134,9 @@ type CacheFamily = "s" | "i" | "r" | "h" | "tm";
134
134
  * invalidateTags for the build-shell read-through's isTagsInvalidatedSince
135
135
  * gate. The platform's expireTag() DELETES tagged entries (no queryable
136
136
  * history), so the markers are rango's own record of "tag X was invalidated
137
- * at T". One year: markers must outlive any build's shell entries (which the
138
- * buildVersion gate retires on the next deploy anyway); an expired marker
139
- * would silently resurrect an updateTag()-evicted build shell.
137
+ * at T". One year: runtime tagged-shell retention is capped to this lifetime,
138
+ * while buildVersion retires build shells on the next deploy. An expired marker
139
+ * therefore cannot resurrect a runtime shell that outlived its invalidation.
140
140
  */
141
141
  const TAG_MARKER_TTL_SECONDS = 365 * 24 * 60 * 60;
142
142
 
@@ -190,7 +190,7 @@ interface VercelShellEnvelope {
190
190
  rv: string;
191
191
  /** Build version at capture (ShellCacheEntry.buildVersion). */
192
192
  bv?: string;
193
- /** createdAt (ms since epoch). */
193
+ /** Capture-generation start time, used by tag marker checks. */
194
194
  c: number;
195
195
  /** staleAt (ms since epoch). */
196
196
  s: number;
@@ -209,6 +209,8 @@ interface VercelShellEnvelope {
209
209
  * their holes only a handler re-run can fill.
210
210
  */
211
211
  lh?: boolean;
212
+ /** ShellCacheEntry.transitionWhen; conditional transitions must re-run. */
213
+ tw?: true;
212
214
  }
213
215
 
214
216
  /** Read-path outcome for the debug sink. */
@@ -355,6 +357,7 @@ function isRecord(value: unknown): value is Record<string, unknown> {
355
357
  export class VercelCacheStore<
356
358
  TEnv = unknown,
357
359
  > implements SegmentCacheStore<TEnv> {
360
+ readonly supportsPassiveShellReads: true = true;
358
361
  readonly defaults?: CacheDefaults;
359
362
  readonly keyGenerator?: (
360
363
  ctx: RequestContext<TEnv>,
@@ -731,6 +734,7 @@ export class VercelCacheStore<
731
734
 
732
735
  async getShell(
733
736
  key: string,
737
+ options?: { claimRevalidation?: boolean },
734
738
  ): Promise<{ entry: ShellCacheEntry; shouldRevalidate?: boolean } | null> {
735
739
  const storeKey = this.toStoreKey(key, "h");
736
740
  const started = Date.now();
@@ -774,14 +778,25 @@ export class VercelCacheStore<
774
778
  return null;
775
779
  }
776
780
 
781
+ if (
782
+ env.t &&
783
+ env.t.length > 0 &&
784
+ (await this.isTagsInvalidatedSince(env.t, env.c))
785
+ ) {
786
+ void this.safeDelete(storeKey);
787
+ this.emitDebug({ op: "getShell", key, outcome: "miss", readMs });
788
+ return null;
789
+ }
790
+
777
791
  const isStale = env.s > 0 && now > env.s;
778
- const shouldRevalidate = isStale
779
- ? await this.claimRevalidation(
780
- storeKey,
781
- env.e,
782
- "[VercelCacheStore] getShell",
783
- )
784
- : false;
792
+ let shouldRevalidate = isStale;
793
+ if (isStale && options?.claimRevalidation !== false) {
794
+ shouldRevalidate = await this.claimRevalidation(
795
+ storeKey,
796
+ env.e,
797
+ "[VercelCacheStore] getShell",
798
+ );
799
+ }
785
800
  this.emitDebug({
786
801
  op: "getShell",
787
802
  key,
@@ -800,6 +815,7 @@ export class VercelCacheStore<
800
815
  initialTheme: env.i,
801
816
  snapshot: env.sn,
802
817
  handlerLiveHoles: env.lh,
818
+ transitionWhen: env.tw,
803
819
  createdAt: env.c,
804
820
  },
805
821
  shouldRevalidate,
@@ -812,10 +828,12 @@ export class VercelCacheStore<
812
828
  ttlSeconds?: number,
813
829
  swrSeconds?: number,
814
830
  tags?: string[],
815
- ): Promise<void> {
831
+ ): Promise<"stored" | "invalidated" | void> {
816
832
  try {
817
833
  const ttl = resolveTtl(ttlSeconds, this.defaults, DEFAULT_FUNCTION_TTL);
818
834
  const swrWindow = resolveSwrWindow(swrSeconds, this.defaults);
835
+ const totalTtl = ttl + swrWindow;
836
+ const now = Date.now();
819
837
  const { staleAt, expiresAt } = computeExpiration(ttl, swrWindow);
820
838
  // Store the CLAMPED tags (see putResponse/setItem) so dropped tags don't
821
839
  // resurface on a hit; write() re-clamps idempotently.
@@ -823,6 +841,17 @@ export class VercelCacheStore<
823
841
  tags,
824
842
  "[VercelCacheStore] putShell",
825
843
  );
844
+ const storeKey = this.toStoreKey(key, "h");
845
+ if (
846
+ safeTags.length > 0 &&
847
+ (await this.isTagsInvalidatedSince(safeTags, entry.createdAt))
848
+ ) {
849
+ return "invalidated";
850
+ }
851
+ const retentionTtl =
852
+ safeTags.length > 0
853
+ ? Math.min(totalTtl, TAG_MARKER_TTL_SECONDS)
854
+ : totalTtl;
826
855
  const env: VercelShellEnvelope = {
827
856
  p: entry.prelude,
828
857
  po: entry.postponed,
@@ -830,22 +859,24 @@ export class VercelCacheStore<
830
859
  bv: entry.buildVersion,
831
860
  c: entry.createdAt,
832
861
  s: staleAt,
833
- e: expiresAt,
862
+ e: Math.min(expiresAt, now + retentionTtl * 1000),
834
863
  t: safeTags.length > 0 ? safeTags : undefined,
835
864
  i: entry.initialTheme,
836
865
  sn: entry.snapshot,
837
866
  lh: entry.handlerLiveHoles,
867
+ tw: entry.transitionWhen,
838
868
  };
839
869
  // write() enforces the 2 MB per-item ceiling (withinSizeLimit): an
840
870
  // oversized shell prelude is reported and skipped (fail-open to a full
841
871
  // render), never silently no-op'd on the platform.
842
872
  await this.write(
843
- this.toStoreKey(key, "h"),
873
+ storeKey,
844
874
  env,
845
- ttl + swrWindow,
875
+ retentionTtl,
846
876
  safeTags,
847
877
  "[VercelCacheStore] putShell",
848
878
  );
879
+ return "stored";
849
880
  } catch (error) {
850
881
  reportCacheError(error, "cache-write", "[VercelCacheStore] putShell");
851
882
  }
@@ -854,12 +885,10 @@ export class VercelCacheStore<
854
885
  // --- Tags ---
855
886
 
856
887
  /**
857
- * Build-shell read-through gate (SegmentCacheStore.isTagsInvalidatedSince).
858
- * The platform's expireTag() DELETES tagged entries and keeps no queryable
859
- * history, so invalidateTags() below writes its own "tm" marker entries and
860
- * this compares them against the baked entry's build-time createdAt
861
- * (>= so a same-millisecond invalidation wins). Fails open to `false` on
862
- * read errors — the same posture as the CF marker check.
888
+ * Shell tag-generation gate (SegmentCacheStore.isTagsInvalidatedSince).
889
+ * expireTag() keeps no queryable history, so invalidateTags() writes "tm"
890
+ * markers for runtime capture races and immutable build shells. Marker >=
891
+ * generation start wins; read errors fail open like the CF marker check.
863
892
  */
864
893
  async isTagsInvalidatedSince(
865
894
  tags: string[],
@@ -1163,7 +1192,7 @@ export class VercelCacheStore<
1163
1192
 
1164
1193
  private asShellEnvelope(raw: unknown): VercelShellEnvelope | null {
1165
1194
  if (!isRecord(raw)) return null;
1166
- const { p, po, rv, bv, c, s, e, t, i, sn, lh } = raw;
1195
+ const { p, po, rv, bv, c, s, e, t, i, sn, lh, tw } = raw;
1167
1196
  if (typeof p !== "string" || typeof rv !== "string") return null;
1168
1197
  if (po !== null && typeof po !== "string") return null;
1169
1198
  if (typeof c !== "number") return null;
@@ -1180,6 +1209,7 @@ export class VercelCacheStore<
1180
1209
  i: typeof i === "string" ? i : undefined,
1181
1210
  sn: Array.isArray(sn) ? (sn as ShellSnapshotRecord[]) : undefined,
1182
1211
  lh: lh === true ? true : undefined,
1212
+ tw: tw === true ? true : undefined,
1183
1213
  };
1184
1214
  }
1185
1215
 
@@ -0,0 +1,11 @@
1
+ export const DEV_DISCOVERY_READY_EVENT = "rango:dev-discovery-ready";
2
+
3
+ export const DEV_DISCOVERY_QUERY_EVENT = "rango:dev-discovery-query";
4
+
5
+ export const DEV_DISCOVERY_PROBE_HEADER = "x-rango-dev-discovery-probe";
6
+
7
+ export const DEV_DISCOVERY_EPOCH_HEADER = "x-rango-dev-discovery-epoch";
8
+
9
+ export function isValidDevDiscoveryEpoch(value: unknown): value is number {
10
+ return typeof value === "number" && Number.isFinite(value) && value >= 0;
11
+ }
@@ -44,7 +44,7 @@ import { isRouteNotFoundError } from "../errors.js";
44
44
  import { createReverseFunction } from "../router/handler-context.js";
45
45
  import { executeMiddleware, matchMiddleware } from "../router/middleware.js";
46
46
  import type { MiddlewareEntry } from "../router/middleware.js";
47
- import { getGlobalRouteMap } from "../route-map-builder.js";
47
+ import { getGlobalRouteMap, isRouteRootScoped } from "../route-map-builder.js";
48
48
  import {
49
49
  resolvePprConfig,
50
50
  type ResolvedPprConfig,
@@ -183,6 +183,9 @@ async function attemptBuildCapture(
183
183
  stateCookieName: router.resolvedStateCookieName,
184
184
  version: opts.buildVersion,
185
185
  });
186
+ // Scope registry lookups (root-scope/search-schema) per router during the
187
+ // bake, mirroring rsc/handler.ts on the request path (#762).
188
+ baseCtx._routerId = router.id;
186
189
 
187
190
  // Entry collector: captureAndStoreShell's sink. putShell never fails here,
188
191
  // so a "stored" outcome always carries the entry.
@@ -235,6 +238,9 @@ async function attemptBuildCapture(
235
238
  getGlobalRouteMap(),
236
239
  preview?.routeKey,
237
240
  preview?.params ?? {},
241
+ preview?.routeKey
242
+ ? isRouteRootScoped(preview.routeKey, router.id)
243
+ : undefined,
238
244
  );
239
245
 
240
246
  const runCapture = () =>
@@ -54,6 +54,11 @@ declare global {
54
54
  var __STATIC_MANIFEST:
55
55
  | Record<string, () => Promise<{ default: string | StaticEntry }>>
56
56
  | undefined;
57
+ // Injected by closeBundle post-processing: lazy loader for the static
58
+ // manifest module (which assigns __STATIC_MANIFEST on evaluation). Resolved
59
+ // by static-store.ts on the first Static() lookup — issue #760.
60
+ // eslint-disable-next-line no-var
61
+ var __loadStaticManifestModule: (() => Promise<unknown>) | undefined;
57
62
  // Injected by virtual module in dev mode for on-demand prerender
58
63
  // eslint-disable-next-line no-var
59
64
  var __PRERENDER_DEV_URL: string | undefined;
@@ -12,6 +12,7 @@ import {
12
12
  getNamePrefix,
13
13
  getUrlPrefix,
14
14
  requireDslContext,
15
+ stampStaticDefScope,
15
16
  type EntryData,
16
17
  type EntryPropDatas,
17
18
  type EntryPropSegments,
@@ -560,6 +561,7 @@ const parallel: RouteHelpers<any, any>["parallel"] = (slots, use) => {
560
561
  if (ctx.namePrefix) {
561
562
  (slotHandler as any).$$routePrefix = ctx.namePrefix;
562
563
  }
564
+ stampStaticDefScope(slotHandler);
563
565
  }
564
566
  } else {
565
567
  unwrappedSlots[slotName] = slotHandler;
@@ -1016,8 +1018,11 @@ const layout: RouteHelpers<any, any>["layout"] = (handler, use) => {
1016
1018
  } satisfies EntryData;
1017
1019
 
1018
1020
  // Capture namespace prefix on static handler for build-time reverse() resolution
1019
- if (isStatic && handler.$$id && ctx.namePrefix) {
1020
- (handler as any).$$routePrefix = ctx.namePrefix;
1021
+ if (isStatic && handler.$$id) {
1022
+ if (ctx.namePrefix) {
1023
+ (handler as any).$$routePrefix = ctx.namePrefix;
1024
+ }
1025
+ stampStaticDefScope(handler);
1021
1026
  }
1022
1027
 
1023
1028
  // Merge handler.use defaults with explicit use
@@ -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,
@@ -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;