@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
@@ -96,6 +96,9 @@ export class RecordingShellStore<
96
96
  get keyGenerator(): SegmentCacheStore<TEnv>["keyGenerator"] {
97
97
  return this.inner.keyGenerator;
98
98
  }
99
+ get supportsPassiveShellReads(): true | undefined {
100
+ return this.inner.supportsPassiveShellReads;
101
+ }
99
102
 
100
103
  private record(
101
104
  family: ShellSnapshotRecord["family"],
@@ -232,8 +235,9 @@ export class RecordingShellStore<
232
235
 
233
236
  async getShell(
234
237
  key: string,
238
+ options?: { claimRevalidation?: boolean },
235
239
  ): Promise<{ entry: ShellCacheEntry; shouldRevalidate?: boolean } | null> {
236
- return this.inner.getShell ? this.inner.getShell(key) : null;
240
+ return this.inner.getShell ? this.inner.getShell(key, options) : null;
237
241
  }
238
242
 
239
243
  async putShell(
@@ -242,7 +246,7 @@ export class RecordingShellStore<
242
246
  ttlSeconds?: number,
243
247
  swrSeconds?: number,
244
248
  tags?: string[],
245
- ): Promise<void> {
249
+ ): Promise<"stored" | "invalidated" | void> {
246
250
  return this.inner.putShell?.(key, entry, ttlSeconds, swrSeconds, tags);
247
251
  }
248
252
 
@@ -360,28 +364,42 @@ export async function buildShellLoaderSeed(
360
364
  * the snapshot it serves the recorded value AS FRESH (shouldRevalidate: false —
361
365
  * a pinned key must NOT kick SWR background revalidation) so the tail's payload
362
366
  * matches the frozen prelude. Every other read falls through to the real store
363
- * (the holes — masked loaders were never recorded — stay live). ALL writes pass
364
- * through unchanged: a live hole's loader may legitimately write. The shell
365
- * family always passes through.
367
+ * (the holes — masked loaders were never recorded — stay live). Writes pass
368
+ * through so a live hole may legitimately write, except that `segmentsOnly`
369
+ * fully isolates the segment family: misses do not fall through, and writes or
370
+ * deletes stay local to the navigation overlay. The shell family always passes
371
+ * through. With `segmentsOnly`, item and response reads also pass through so a
372
+ * partial navigation cannot freeze captured data or write a partial result into
373
+ * the canonical document namespace.
366
374
  */
367
375
  export class SeededShellStore<
368
376
  TEnv = unknown,
369
377
  > implements SegmentCacheStore<TEnv> {
370
- private readonly items = new Map<string, ShellSnapshotItemValue>();
378
+ private readonly items: Map<string, ShellSnapshotItemValue> | undefined;
371
379
  private readonly segments = new Map<string, CachedEntryData>();
372
- private readonly responses = new Map<string, ShellSnapshotResponseValue>();
380
+ private readonly responses:
381
+ | Map<string, ShellSnapshotResponseValue>
382
+ | undefined;
383
+ private readonly segmentsOnly: boolean;
373
384
 
374
385
  constructor(
375
386
  private readonly inner: SegmentCacheStore<TEnv>,
376
387
  snapshot: ShellSnapshotRecord[],
388
+ options?: { segmentsOnly?: boolean },
377
389
  ) {
390
+ this.segmentsOnly = options?.segmentsOnly === true;
391
+ this.items = this.segmentsOnly ? undefined : new Map();
392
+ this.responses = this.segmentsOnly ? undefined : new Map();
378
393
  for (const rec of snapshot) {
379
- if (rec.family === "item") {
380
- this.items.set(rec.key, rec.value as ShellSnapshotItemValue);
381
- } else if (rec.family === "segment") {
394
+ if (!rec || typeof rec !== "object") continue;
395
+ if (rec.family === "segment") {
382
396
  this.segments.set(rec.key, rec.value as CachedEntryData);
397
+ } else if (this.segmentsOnly) {
398
+ continue;
399
+ } else if (rec.family === "item") {
400
+ this.items?.set(rec.key, rec.value as ShellSnapshotItemValue);
383
401
  } else if (rec.family === "response") {
384
- this.responses.set(rec.key, rec.value as ShellSnapshotResponseValue);
402
+ this.responses?.set(rec.key, rec.value as ShellSnapshotResponseValue);
385
403
  }
386
404
  // "loader" family records are not store reads — serveShellHit seeds them
387
405
  // onto the tail context (_shellLoaderSeed) for the resolveLoaderData
@@ -395,10 +413,14 @@ export class SeededShellStore<
395
413
  get keyGenerator(): SegmentCacheStore<TEnv>["keyGenerator"] {
396
414
  return this.inner.keyGenerator;
397
415
  }
416
+ get supportsPassiveShellReads(): true | undefined {
417
+ return this.inner.supportsPassiveShellReads;
418
+ }
398
419
 
399
420
  async get(key: string): Promise<CacheGetResult | null> {
400
421
  const seeded = this.segments.get(key);
401
422
  if (seeded) return { data: seeded, shouldRevalidate: false };
423
+ if (this.segmentsOnly) return null;
402
424
  return this.inner.get(key);
403
425
  }
404
426
 
@@ -408,10 +430,17 @@ export class SeededShellStore<
408
430
  ttl: number,
409
431
  swr?: number,
410
432
  ): Promise<void> {
433
+ if (this.segmentsOnly) {
434
+ this.segments.set(key, data);
435
+ return;
436
+ }
411
437
  return this.inner.set(key, data, ttl, swr);
412
438
  }
413
439
 
414
440
  async delete(key: string): Promise<boolean> {
441
+ if (this.segmentsOnly) {
442
+ return this.segments.delete(key);
443
+ }
415
444
  return this.inner.delete(key);
416
445
  }
417
446
 
@@ -422,7 +451,7 @@ export class SeededShellStore<
422
451
  async getResponse(
423
452
  key: string,
424
453
  ): Promise<{ response: Response; shouldRevalidate: boolean } | null> {
425
- const seeded = this.responses.get(key);
454
+ const seeded = this.responses?.get(key);
426
455
  if (seeded) {
427
456
  return { response: deserializeResponse(seeded), shouldRevalidate: false };
428
457
  }
@@ -440,7 +469,7 @@ export class SeededShellStore<
440
469
  }
441
470
 
442
471
  async getItem(key: string): Promise<CacheItemResult | null> {
443
- const seeded = this.items.get(key);
472
+ const seeded = this.items?.get(key);
444
473
  if (seeded) {
445
474
  return {
446
475
  value: seeded.value,
@@ -462,8 +491,9 @@ export class SeededShellStore<
462
491
 
463
492
  async getShell(
464
493
  key: string,
494
+ options?: { claimRevalidation?: boolean },
465
495
  ): Promise<{ entry: ShellCacheEntry; shouldRevalidate?: boolean } | null> {
466
- return this.inner.getShell ? this.inner.getShell(key) : null;
496
+ return this.inner.getShell ? this.inner.getShell(key, options) : null;
467
497
  }
468
498
 
469
499
  async putShell(
@@ -472,7 +502,7 @@ export class SeededShellStore<
472
502
  ttlSeconds?: number,
473
503
  swrSeconds?: number,
474
504
  tags?: string[],
475
- ): Promise<void> {
505
+ ): Promise<"stored" | "invalidated" | void> {
476
506
  return this.inner.putShell?.(key, entry, ttlSeconds, swrSeconds, tags);
477
507
  }
478
508
 
@@ -36,6 +36,13 @@ export interface CacheGetResult {
36
36
  * @typeParam TEnv - Platform bindings type (e.g., Cloudflare env)
37
37
  */
38
38
  export interface SegmentCacheStore<TEnv = unknown> {
39
+ /**
40
+ * The store honors getShell(..., { claimRevalidation: false }) without
41
+ * claiming an SWR lock. Navigation replay requires this opt-in because it
42
+ * cannot recapture an HTML shell after observing a stale entry.
43
+ */
44
+ readonly supportsPassiveShellReads?: true;
45
+
39
46
  /**
40
47
  * Default cache options for this store.
41
48
  * Used by cache() boundaries when ttl/swr are not explicitly specified.
@@ -144,9 +151,12 @@ export interface SegmentCacheStore<TEnv = unknown> {
144
151
  *
145
152
  * Optional: a store that does not implement the shell family disables the
146
153
  * shell-cache middleware (it fails open to the normal HTML render path).
154
+ * A passive read reports a stale entry as `shouldRevalidate: true` without
155
+ * claiming store-specific revalidation ownership.
147
156
  */
148
157
  getShell?(
149
158
  key: string,
159
+ options?: { claimRevalidation?: boolean },
150
160
  ): Promise<{ entry: ShellCacheEntry; shouldRevalidate?: boolean } | null>;
151
161
 
152
162
  /**
@@ -160,6 +170,8 @@ export interface SegmentCacheStore<TEnv = unknown> {
160
170
  * @param swrSeconds - Optional stale-while-revalidate window in seconds
161
171
  * @param tags - Optional cache tags for invalidation (participates in
162
172
  * invalidateTags via the same tag machinery as the item family)
173
+ * @returns `invalidated` when a generation marker rejected the write,
174
+ * `stored` when acknowledged, or void for stores without acknowledgements.
163
175
  */
164
176
  putShell?(
165
177
  key: string,
@@ -167,7 +179,7 @@ export interface SegmentCacheStore<TEnv = unknown> {
167
179
  ttlSeconds?: number,
168
180
  swrSeconds?: number,
169
181
  tags?: string[],
170
- ): Promise<void>;
182
+ ): Promise<"stored" | "invalidated" | void>;
171
183
 
172
184
  /**
173
185
  * Get a cached function result by key.
@@ -200,10 +212,9 @@ export interface SegmentCacheStore<TEnv = unknown> {
200
212
  /**
201
213
  * True when ANY of `tags` was invalidated (invalidateTags/updateTag) at or
202
214
  * after `sinceMs` (>= so a same-millisecond invalidation wins, favouring
203
- * freshness). Consulted by the build-time shell read-through
204
- * (rsc/shell-build-manifest.ts): a baked shell entry is immutable in the
205
- * build manifest, so "was it evicted" is answered by the store's tag
206
- * markers against the entry's createdAt rather than by deleting anything.
215
+ * freshness). Consulted by build-shell read-through and runtime shell stores:
216
+ * "was it evicted" is answered by tag markers against the entry's createdAt,
217
+ * including when invalidation races a capture that has not been written yet.
207
218
  * Optional: without it, TAGGED build entries are not served (untagged ones
208
219
  * are unaffected — they are evictable only by deploy/buildVersion anyway).
209
220
  * Fail open to `false` on marker-read errors: a transient store fault must
@@ -307,7 +318,14 @@ export interface ShellCacheEntry {
307
318
  * their holes always fill.
308
319
  */
309
320
  handlerLiveHoles?: boolean;
310
- /** Epoch ms when the shell was captured. */
321
+ /**
322
+ * Legacy/fallback marker for a transition({ when }) predicate that was not
323
+ * evaluated by the PPR pre-handler gate. Such an entry stays conservatively
324
+ * ineligible for handler-free replay. New PPR matches evaluate known segment
325
+ * predicates before the pipeline and do not set this marker.
326
+ */
327
+ transitionWhen?: true;
328
+ /** Capture-generation start time; tag invalidations at or after it win. */
311
329
  createdAt: number;
312
330
  }
313
331
 
@@ -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
@@ -460,11 +460,11 @@ export type RouteHelpers<T extends RouteDefinition, TEnv> = {
460
460
  * startTransition only when the router sets viewTransition: false.
461
461
  *
462
462
  * Conditional hold: pass `when: (ctx) => boolean` to gate the transition per
463
- * request. It runs server-side AFTER the route handler (so it can read state
464
- * the handler set via `ctx.get(...)`); returning false drops this transition
465
- * for the request, so the navigation streams its loading() skeleton instead of
466
- * holding. This is a post-handler predicate distinct from intercept()'s
467
- * match-time `when` config selector (`intercept(slot, route, Comp, { when })`).
463
+ * request. It normally runs server-side after the route handler. On a `ppr`
464
+ * route it is automatically hoisted before route handlers and reevaluated on
465
+ * every replay, so it may read URL/params/action/env and middleware-set context
466
+ * but not handler-set context. Returning false drops this transition for the
467
+ * request. This is distinct from intercept()'s match-time `when` selector.
468
468
  *
469
469
  * ```typescript
470
470
  * // Attach to a single route
@@ -491,7 +491,7 @@ export type RouteHelpers<T extends RouteDefinition, TEnv> = {
491
491
  * @param config - ViewTransition configuration (enter, exit, update, share,
492
492
  * default, name), `viewTransition: "auto" | false` to toggle the router
493
493
  * boundary (createRouter({ viewTransition }) sets the app-wide default), and
494
- * `when: (ctx) => boolean` to gate the transition per request post-handler
494
+ * `when: (ctx) => boolean` to gate the transition per request
495
495
  * @param children - Optional callback returning child routes to wrap
496
496
  */
497
497
  transition: {