@rangojs/router 0.19.1 → 0.21.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 (185) hide show
  1. package/dist/bin/rango.js +26 -14
  2. package/dist/types/browser/event-controller.d.ts +32 -0
  3. package/dist/types/browser/history-state.d.ts +16 -4
  4. package/dist/types/browser/navigation-transaction.d.ts +7 -0
  5. package/dist/types/browser/rango-state.d.ts +3 -1
  6. package/dist/types/browser/react/context.d.ts +11 -0
  7. package/dist/types/browser/react/location-state-shared.d.ts +84 -10
  8. package/dist/types/browser/react/location-state.d.ts +7 -2
  9. package/dist/types/browser/react/subscribe-to-nothing.d.ts +7 -0
  10. package/dist/types/cache/cache-scope.d.ts +4 -5
  11. package/dist/types/cache/cf/cf-cache-store.d.ts +27 -6
  12. package/dist/types/cache/cf/cf-cache-types.d.ts +12 -4
  13. package/dist/types/cache/handle-snapshot.d.ts +36 -50
  14. package/dist/types/cache/index.d.ts +2 -0
  15. package/dist/types/cache/shell-snapshot.d.ts +19 -3
  16. package/dist/types/cache/types.d.ts +21 -10
  17. package/dist/types/cache/vercel/vercel-cache-store.d.ts +28 -16
  18. package/dist/types/client-urls/navigation.d.ts +7 -1
  19. package/dist/types/client-urls/optimistic-location.d.ts +10 -4
  20. package/dist/types/handles/deferred-resolution.d.ts +11 -1
  21. package/dist/types/prerender/build-shell-capture.d.ts +8 -6
  22. package/dist/types/router/match-middleware/cache-lookup.d.ts +1 -1
  23. package/dist/types/router/navigation-snapshot.d.ts +8 -1
  24. package/dist/types/router/router-interfaces.d.ts +2 -1
  25. package/dist/types/router/router-options.d.ts +17 -7
  26. package/dist/types/router/segment-resolution/loader-cache.d.ts +21 -9
  27. package/dist/types/router-versions.d.ts +39 -0
  28. package/dist/types/rsc/handler-context.d.ts +5 -0
  29. package/dist/types/rsc/shell-build-manifest.d.ts +3 -2
  30. package/dist/types/rsc/types.d.ts +4 -6
  31. package/dist/types/server/build-version-table.d.ts +44 -0
  32. package/dist/types/server/handle-store.d.ts +87 -46
  33. package/dist/types/server/request-context.d.ts +22 -8
  34. package/dist/types/ssr/entry-preload-priority.d.ts +69 -0
  35. package/dist/types/ssr/index.d.ts +1 -0
  36. package/dist/types/ssr/preinit-client-references.d.ts +17 -5
  37. package/dist/types/testing/build-versions.d.ts +32 -0
  38. package/dist/types/testing/dom.entry.d.ts +1 -1
  39. package/dist/types/testing/flight.entry.d.ts +1 -1
  40. package/dist/types/testing/index.d.ts +2 -0
  41. package/dist/types/testing/internal/seed-vars.d.ts +7 -2
  42. package/dist/types/testing/location-state-key.d.ts +5 -1
  43. package/dist/types/testing/render-route.d.ts +77 -6
  44. package/dist/types/testing/serve-shell-request.d.ts +21 -0
  45. package/dist/types/testing/vitest-stubs/version.d.ts +1 -0
  46. package/dist/types/types/handler-context.d.ts +8 -5
  47. package/dist/types/vite/discovery/build-versions.d.ts +393 -0
  48. package/dist/types/vite/discovery/installed-packages.d.ts +26 -0
  49. package/dist/types/vite/discovery/router-versions-phase.d.ts +52 -0
  50. package/dist/types/vite/discovery/shell-prerender-phase.d.ts +7 -17
  51. package/dist/types/vite/discovery/state.d.ts +14 -0
  52. package/dist/types/vite/encryption-key.d.ts +18 -0
  53. package/dist/types/vite/plugin-types.d.ts +47 -6
  54. package/dist/types/vite/plugins/{version-injector.d.ts → entry-bootstrap-injector.d.ts} +4 -6
  55. package/dist/types/vite/plugins/expose-ids/export-analysis.d.ts +1 -0
  56. package/dist/types/vite/plugins/expose-ids/router-transform.d.ts +14 -0
  57. package/dist/types/vite/plugins/stable-rsc-output.d.ts +51 -0
  58. package/dist/types/vite/plugins/version-plugin.d.ts +9 -11
  59. package/dist/types/vite/plugins/virtual-entries.d.ts +21 -2
  60. package/dist/types/vite/router-discovery.d.ts +6 -0
  61. package/dist/types/vite/utils/client-chunks.d.ts +24 -2
  62. package/dist/types/vite/utils/compare-strings.d.ts +7 -0
  63. package/dist/types/vite/utils/package-resolution.d.ts +8 -0
  64. package/dist/types/vite/utils/prerender-utils.d.ts +2 -0
  65. package/dist/vite/index.js +1544 -420
  66. package/package.json +1 -2
  67. package/skills/bundle-analysis/SKILL.md +1 -1
  68. package/skills/cache-guide/SKILL.md +84 -20
  69. package/skills/caching/SKILL.md +16 -5
  70. package/skills/client-urls/SKILL.md +16 -0
  71. package/skills/cloudflare/SKILL.md +2 -1
  72. package/skills/comparison/references/framework-comparison.md +16 -12
  73. package/skills/hooks/SKILL.md +3 -0
  74. package/skills/hooks/handle-and-actions.md +6 -0
  75. package/skills/hooks/navigation.md +28 -0
  76. package/skills/hooks/state.md +209 -8
  77. package/skills/hooks/urls.md +40 -1
  78. package/skills/host-router/SKILL.md +13 -0
  79. package/skills/intercept/SKILL.md +6 -0
  80. package/skills/loader/SKILL.md +19 -1
  81. package/skills/middleware/SKILL.md +2 -0
  82. package/skills/ppr/SKILL.md +119 -40
  83. package/skills/prerender/SKILL.md +7 -3
  84. package/skills/rango/SKILL.md +8 -7
  85. package/skills/router-setup/SKILL.md +23 -3
  86. package/skills/testing/SKILL.md +30 -26
  87. package/skills/testing/client-components.md +193 -8
  88. package/skills/testing/handles.md +2 -0
  89. package/skills/testing/loader.md +1 -1
  90. package/skills/testing/middleware.md +1 -1
  91. package/skills/testing/render-handler.md +18 -18
  92. package/skills/testing/serve-shell-request.md +63 -14
  93. package/skills/testing/server-actions.md +17 -17
  94. package/skills/testing/server-tree.md +1 -1
  95. package/skills/typesafety/generated-files-and-cli.md +1 -1
  96. package/skills/use-cache/SKILL.md +16 -5
  97. package/skills/vercel/SKILL.md +3 -3
  98. package/src/bin/rango.ts +1 -1
  99. package/src/browser/event-controller.ts +75 -2
  100. package/src/browser/history-state.ts +39 -17
  101. package/src/browser/navigation-bridge.ts +41 -18
  102. package/src/browser/navigation-transaction.ts +15 -10
  103. package/src/browser/partial-update.ts +10 -2
  104. package/src/browser/rango-state.ts +3 -1
  105. package/src/browser/react/Link.tsx +2 -3
  106. package/src/browser/react/NavigationProvider.tsx +25 -1
  107. package/src/browser/react/context.ts +13 -0
  108. package/src/browser/react/location-state-shared.ts +200 -34
  109. package/src/browser/react/location-state.ts +40 -75
  110. package/src/browser/react/subscribe-to-nothing.ts +7 -0
  111. package/src/browser/react/use-handle.ts +20 -1
  112. package/src/browser/rsc-router.tsx +33 -11
  113. package/src/browser/server-action-bridge.ts +17 -1
  114. package/src/cache/cache-scope.ts +18 -8
  115. package/src/cache/cf/cf-cache-store.ts +75 -55
  116. package/src/cache/cf/cf-cache-types.ts +12 -4
  117. package/src/cache/handle-snapshot.ts +69 -87
  118. package/src/cache/index.ts +4 -0
  119. package/src/cache/shell-snapshot.ts +35 -5
  120. package/src/cache/types.ts +21 -10
  121. package/src/cache/vercel/vercel-cache-store.ts +48 -20
  122. package/src/client-urls/client-root.tsx +1 -0
  123. package/src/client-urls/navigation.ts +10 -0
  124. package/src/client-urls/optimistic-location.ts +10 -4
  125. package/src/handles/deferred-resolution.ts +31 -0
  126. package/src/prerender/build-shell-capture.ts +11 -9
  127. package/src/router/manifest.ts +2 -0
  128. package/src/router/match-middleware/cache-lookup.ts +11 -18
  129. package/src/router/navigation-snapshot.ts +25 -6
  130. package/src/router/request-classification.ts +15 -12
  131. package/src/router/router-interfaces.ts +2 -1
  132. package/src/router/router-options.ts +17 -7
  133. package/src/router/segment-resolution/loader-cache.ts +95 -60
  134. package/src/router-versions.ts +60 -0
  135. package/src/rsc/full-payload.ts +6 -2
  136. package/src/rsc/handler-context.ts +5 -0
  137. package/src/rsc/handler.ts +14 -4
  138. package/src/rsc/rsc-rendering.ts +12 -1
  139. package/src/rsc/shell-build-manifest.ts +10 -7
  140. package/src/rsc/shell-capture.ts +36 -26
  141. package/src/rsc/types.ts +4 -6
  142. package/src/server/build-version-table.ts +115 -0
  143. package/src/server/handle-store.ts +344 -127
  144. package/src/server/request-context.ts +62 -11
  145. package/src/ssr/entry-preload-priority.ts +308 -0
  146. package/src/ssr/index.tsx +46 -9
  147. package/src/ssr/preinit-client-references.ts +17 -5
  148. package/src/ssr/ssr-root.tsx +16 -4
  149. package/src/testing/build-versions.ts +64 -0
  150. package/src/testing/dispatch.ts +6 -0
  151. package/src/testing/dom.entry.ts +1 -0
  152. package/src/testing/flight.entry.ts +1 -0
  153. package/src/testing/index.ts +3 -0
  154. package/src/testing/internal/context.ts +2 -1
  155. package/src/testing/internal/seed-vars.ts +14 -2
  156. package/src/testing/location-state-key.ts +18 -8
  157. package/src/testing/render-handler.ts +2 -1
  158. package/src/testing/render-route.tsx +277 -43
  159. package/src/testing/serve-shell-request.ts +80 -11
  160. package/src/testing/vitest-stubs/version.ts +4 -1
  161. package/src/types/handler-context.ts +8 -5
  162. package/src/vite/discovery/build-versions.ts +1281 -0
  163. package/src/vite/discovery/installed-packages.ts +159 -0
  164. package/src/vite/discovery/prerender-collection.ts +38 -1
  165. package/src/vite/discovery/router-versions-phase.ts +336 -0
  166. package/src/vite/discovery/shell-prerender-phase.ts +28 -38
  167. package/src/vite/discovery/state.ts +19 -0
  168. package/src/vite/discovery/virtual-module-codegen.ts +7 -2
  169. package/src/vite/encryption-key.ts +101 -9
  170. package/src/vite/plugin-types.ts +48 -6
  171. package/src/vite/plugins/{version-injector.ts → entry-bootstrap-injector.ts} +7 -22
  172. package/src/vite/plugins/expose-ids/export-analysis.ts +1 -1
  173. package/src/vite/plugins/expose-ids/router-transform.ts +36 -15
  174. package/src/vite/plugins/expose-internal-ids.ts +12 -3
  175. package/src/vite/plugins/stable-rsc-output.ts +147 -0
  176. package/src/vite/plugins/version-plugin.ts +19 -24
  177. package/src/vite/plugins/version.d.ts +17 -2
  178. package/src/vite/plugins/virtual-entries.ts +40 -7
  179. package/src/vite/rango.ts +30 -3
  180. package/src/vite/router-discovery.ts +82 -13
  181. package/src/vite/utils/client-chunks.ts +84 -14
  182. package/src/vite/utils/compare-strings.ts +9 -0
  183. package/src/vite/utils/package-resolution.ts +9 -0
  184. package/src/vite/utils/prerender-utils.ts +4 -1
  185. package/src/vite/utils/shared-utils.ts +1 -1
@@ -502,13 +502,23 @@ export interface RangoOptions<TEnv = any> {
502
502
  nonce?: NonceProvider<TEnv>;
503
503
 
504
504
  /**
505
- * RSC version string included in metadata.
506
- * The browser sends this back on partial requests to detect version mismatches.
507
- *
508
- * Defaults to the auto-generated VERSION from `@rangojs/router:version` virtual module.
509
- * Only set this if you need a custom versioning strategy.
510
- *
511
- * @default VERSION from @rangojs/router:version
505
+ * Version override. When set, this exact value is used for both of the
506
+ * router's versions: it keys the router's cached data (segments, `"use
507
+ * cache"` values, loader data) and its stored HTML (PPR shells,
508
+ * document-cache responses), is sent in payload metadata, and is what the
509
+ * browser's `_rsc_v` is compared with (a mismatch reloads the tab).
510
+ *
511
+ * Leave it unset for the default: rango computes two versions per router at
512
+ * build time, a data version from the router's server code and a document
513
+ * version that also covers the client assets. The same code builds to the
514
+ * same versions, so a deploy that does not change this router keeps its
515
+ * cache. In dev both are a stamp bumped on every RSC module edit.
516
+ *
517
+ * Set it to clear this router's cache on demand (change the value), or to
518
+ * tie the cache to your own release id (a per-deploy value clears the cache
519
+ * on every deploy).
520
+ *
521
+ * @default the router's build versions
512
522
  */
513
523
  version?: string;
514
524
 
@@ -50,19 +50,29 @@ import {
50
50
  type LoaderIdentityRead,
51
51
  } from "../../server/context.js";
52
52
  import type { HandlerContext, InternalHandlerContext } from "../../types.js";
53
- import type { HandleStore } from "../../server/handle-store.js";
53
+ import type {
54
+ HandleStore,
55
+ RecordAuthority,
56
+ } from "../../server/handle-store.js";
54
57
  import type { CacheItemResult } from "../../cache/types.js";
55
58
  import {
56
59
  startHandleCapture,
57
60
  type HandleCapture,
58
61
  } from "../../cache/handle-capture.js";
59
- import { encodeHandles, decodeHandles } from "../../cache/handle-snapshot.js";
62
+ import {
63
+ appendHandles,
64
+ encodeHandles,
65
+ decodeHandles,
66
+ type OwnedPushDelivery,
67
+ } from "../../cache/handle-snapshot.js";
68
+ import type { ShellLoaderSeedEntry } from "../../cache/shell-snapshot.js";
60
69
  import { INTERNAL_RANGO_DEBUG } from "../../internal-debug.js";
61
70
  import { runIdentityExempt } from "../../cache/cache-exec-scope.js";
62
71
  import {
63
72
  getRequestContext,
64
73
  _getRequestContext,
65
74
  runWithRequestContext,
75
+ type RequestContext,
66
76
  } from "../../server/request-context.js";
67
77
  import { observePhase, PHASES } from "../instrument.js";
68
78
  import {
@@ -256,26 +266,6 @@ function chainLoaderEntries(entry: EntryData): readonly LoaderEntry[] {
256
266
  return loaders;
257
267
  }
258
268
 
259
- /**
260
- * The ids of the loaders a request over the matched chain `entries` runs on
261
- * the live lane (no `ssr: false`), on a chain entry, its orphan layouts or
262
- * its parallel slots. A document HIT tail restores the record's pushes of
263
- * every other owner as authoritative (withCacheLookup, HandleStore
264
- * pushRestored); a live-lane loader is a hole, so its live run replaces its
265
- * recorded pushes instead. Memoized per entry.
266
- */
267
- export function liveLaneLoaderIds(
268
- entries: readonly EntryData[],
269
- ): ReadonlySet<string> {
270
- const ids = new Set<string>();
271
- for (const entry of entries) {
272
- for (const l of chainLoaderEntries(entry)) {
273
- if (!l.bake) ids.add(l.loader.$$id);
274
- }
275
- }
276
- return ids;
277
- }
278
-
279
269
  /**
280
270
  * The lane of each loader the matched chain `entries` registers: "live" (no
281
271
  * `ssr: false`) or "bake". A loader registered on both is "live". A PPR
@@ -295,6 +285,75 @@ export function routeLoaderLanes(
295
285
  return lanes;
296
286
  }
297
287
 
288
+ /**
289
+ * The pins this request serves loaders from, by loader id: the seed a
290
+ * document HIT tail (serveShellHit) or a navigation replay whose doc record
291
+ * hit (matchPartialWithPprReplay) armed from the shell entry's loader
292
+ * records. A capture serves none: its bake-lane loaders execute.
293
+ *
294
+ * The one answer to "is this loader served from the shell record on this
295
+ * request", for its value (resolveLoaderData) and for the record's copies of
296
+ * its handle pushes (loaderPins): both look the loader up here and nowhere
297
+ * else. A condition that makes pins, or one loader's pin, not apply belongs
298
+ * here or in the seed's decode (shell-snapshot.ts buildShellLoaderSeed),
299
+ * never after a lookup, or the two disagree: issues #1001 and #1003 were the
300
+ * pushes decided from the request's type while the value was decided from
301
+ * the pin.
302
+ */
303
+ function servedPins(
304
+ reqCtx: RequestContext<any> | undefined,
305
+ ): ReadonlyMap<string, ShellLoaderSeedEntry> | undefined {
306
+ return isShellCaptureActive(reqCtx) ? undefined : reqCtx?._shellLoaderSeed;
307
+ }
308
+
309
+ /**
310
+ * What a record restored on a request over the matched chain `entries` is to
311
+ * each loader's handle pushes (HandleStore RecordAuthority), from the pins
312
+ * the request serves (servedPins). Call it when the record hits: the pins are
313
+ * read here, once, because a navigation replay arms its seed for the match
314
+ * only and the store asks while loader bodies run.
315
+ *
316
+ * Each side applies its own lane to the pin. The value is per registration
317
+ * (resolveLoaderData: an `ssr: false` registration is served its pin). The
318
+ * pushes are per loader: one the route also registers without `ssr: false`
319
+ * is a hole whatever its other registration's pin says, because the capture
320
+ * credited its pushes to the live registration's run (routeLoaderLanes).
321
+ *
322
+ * A dependency the route does not register has no pin of its own, and the
323
+ * record does not name the loader that ran it at capture: its copies stand
324
+ * only while every `ssr: false` loader is pinned (exact when the pins are
325
+ * all there or all gone).
326
+ */
327
+ export function loaderPins(
328
+ entries: readonly EntryData[],
329
+ reqCtx: RequestContext<any> | undefined,
330
+ ): OwnedPushDelivery {
331
+ const pins = servedPins(reqCtx);
332
+ let lanes: ReadonlyMap<string, "live" | "bake"> | undefined;
333
+ let dependency: RecordAuthority | undefined;
334
+ return (loaderId) => {
335
+ lanes ??= routeLoaderLanes(entries);
336
+ const lane = lanes.get(loaderId);
337
+ if (lane === "live") return "hole";
338
+ if (lane === "bake") {
339
+ const pin = pins?.get(loaderId);
340
+ return pin ? (pin.complete ? "pin" : "copies") : "hole";
341
+ }
342
+ if (dependency === undefined) {
343
+ let bakeLane = 0;
344
+ let pinned = 0;
345
+ for (const [id, registered] of lanes) {
346
+ if (registered !== "bake") continue;
347
+ bakeLane++;
348
+ if (pins?.has(id)) pinned++;
349
+ }
350
+ dependency =
351
+ bakeLane > 0 && pinned === bakeLane ? "copies" : "placeholders";
352
+ }
353
+ return dependency;
354
+ };
355
+ }
356
+
298
357
  function getLoaderStore(
299
358
  loaderEntry: LoaderEntry,
300
359
  ): import("../../cache/types.js").SegmentCacheStore | null {
@@ -323,11 +382,12 @@ function getLoaderStore(
323
382
  * replay; and the store reads the body scope to tell a live push from a
324
383
  * replayed one.
325
384
  * A PPR shell capture records the replayed pushes under this loader (its
326
- * push funnel reads the pushReplayed owner, shell-capture.ts), so a shell
327
- * HIT restores them owned (HandleStore.pushRestored). This replay still
328
- * runs on that HIT: the store drops its settled values, which the record
329
- * already restored, and keeps the thenable ones the record could not keep
330
- * (a deferred push, the reason the loader's record carries `runs`).
385
+ * push funnel reads the pushReplayed owner, shell-capture.ts), so a replay
386
+ * of that shell that serves this loader from its pin restores them owned
387
+ * (HandleStore.pushRestored), on a document HIT and on a navigation alike.
388
+ * This replay still runs there: the store drops its settled values, which
389
+ * the record already restored, and keeps the thenable ones the record could
390
+ * not keep (a deferred push, the reason the loader's pin carries `runs`).
331
391
  */
332
392
  async function replayLoaderHandles(
333
393
  encoded: string | undefined,
@@ -338,34 +398,7 @@ async function replayLoaderHandles(
338
398
  ): Promise<void> {
339
399
  const recorded = encoded ? await decodeHandles(encoded) : {};
340
400
  if (!recorded) return;
341
- const delivers = new Map<string, boolean>();
342
- const claims = (owner: string): boolean => {
343
- let deliver = delivers.get(owner);
344
- if (deliver === undefined) {
345
- deliver = claim ? claim(owner) : true;
346
- delivers.set(owner, deliver);
347
- }
348
- return deliver;
349
- };
350
- const replay = (): void => {
351
- for (const key in recorded) {
352
- const owner = key.slice(key.indexOf(":") + 1);
353
- if (!claims(owner)) continue;
354
- for (const [handleName, values] of Object.entries(recorded[key])) {
355
- for (const value of values) {
356
- handleStore.pushReplayed(handleName, segmentId, value, owner);
357
- }
358
- }
359
- }
360
- };
361
- // A hole's entry, pushes or none, takes the place of the shell copies of
362
- // its pushes (HandleStore.redeliverReplays). Not once the hole delivered
363
- // in this request: a run a reader started first replaces them itself.
364
- if (claims(cachedLoaderId)) {
365
- handleStore.redeliverReplays(cachedLoaderId, replay);
366
- } else {
367
- replay();
368
- }
401
+ appendHandles(recorded, handleStore, segmentId, claim, cachedLoaderId);
369
402
  }
370
403
 
371
404
  /**
@@ -492,10 +525,11 @@ export function resolveLoaderData<TEnv>(
492
525
  return createMaskedLoaderPromise();
493
526
  }
494
527
 
495
- if (bakeSegmentKey) {
496
- const seed = reqCtx?._shellLoaderSeed;
497
- if (seed && seed.has(bakeSegmentKey)) {
498
- const recorded = seed.get(bakeSegmentKey)!;
528
+ // `bake`: the pin is the `ssr: false` registration's. The caller's key is
529
+ // passed for an unflagged loader of an entry without loading() too.
530
+ if (bakeSegmentKey && loaderEntry.bake) {
531
+ const recorded = servedPins(reqCtx)?.get(loaderEntry.loader.$$id);
532
+ if (recorded) {
499
533
  if (!recorded.holes) {
500
534
  // Pin-first (hole-free record): the pinned container is what the
501
535
  // payload serves (recorded paths win wholesale), resolved
@@ -800,7 +834,8 @@ function executeLoaderData<TEnv>(
800
834
  });
801
835
 
802
836
  // An entry without handles (none recorded, or dropped by an encode
803
- // timeout) replays none, and still stands for a hole's shell copies.
837
+ // timeout) replays none, and still takes the place of the loader's
838
+ // placeholders (appendHandles): a claimed entry is the loader's source.
804
839
  if (cachedRead && handleStore && owningSegmentId) {
805
840
  await replayLoaderHandles(
806
841
  hitHandles,
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Per-router cache versions: the shapes shared by the build (which computes
3
+ * them, vite/discovery/build-versions.ts) and the runtime (which reads them,
4
+ * server/build-version-table.ts). No imports, so the Vite plugin bundle can
5
+ * use it without resolving the `@rangojs/router:version` virtual module.
6
+ *
7
+ * Design: docs/design/per-app-cache-version.md.
8
+ */
9
+
10
+ /**
11
+ * The two versions one `createRouter()` serves with.
12
+ *
13
+ * - `data` keys cached RSC data: segment entries, `"use cache"` values and
14
+ * loader data. It follows the router's server code only.
15
+ * - `document` keys stored HTML (PPR shells, document-cache responses), is the
16
+ * value the browser echoes as `_rsc_v`, and stamps `ShellCacheEntry.buildVersion`.
17
+ * It follows the server code and the client asset URLs.
18
+ */
19
+ export interface RouterVersions {
20
+ readonly data: string;
21
+ readonly document: string;
22
+ }
23
+
24
+ /**
25
+ * The build's table as the `@rangojs/router:version` module exports it:
26
+ * router id to `[data, document]`. {@link DEFAULT_ROUTER_VERSIONS_KEY} holds
27
+ * the whole-build pair, used for a router the build could not attribute.
28
+ */
29
+ export type RouterVersionsTable = Readonly<
30
+ Record<string, readonly [data: string, document: string]>
31
+ >;
32
+
33
+ /** Table key of the whole-build versions. Not a legal `$$id` (8 hex chars). */
34
+ export const DEFAULT_ROUTER_VERSIONS_KEY = "*";
35
+
36
+ /** One value for both versions: a consumer-set `version`, or the dev stamp. */
37
+ export function uniformVersions(version: string): RouterVersions {
38
+ return { data: version, document: version };
39
+ }
40
+
41
+ /**
42
+ * The versions a router serves with under `table`: a consumer-set `version`
43
+ * for both; else the router's own entry; else the whole-build pair, for a
44
+ * router the table does not list. `undefined` when there is no table (dev,
45
+ * tests) or it has neither entry. One function for the request handler and
46
+ * the build's shell capture, which must stamp what the handler will check.
47
+ */
48
+ export function resolveVersionsFrom(
49
+ table: RouterVersionsTable | undefined,
50
+ routerId: string | undefined,
51
+ override: string | undefined,
52
+ ): RouterVersions | undefined {
53
+ if (override !== undefined) return uniformVersions(override);
54
+ if (!table) return undefined;
55
+ const pair =
56
+ (routerId !== undefined && Object.hasOwn(table, routerId)
57
+ ? table[routerId]
58
+ : undefined) ?? table[DEFAULT_ROUTER_VERSIONS_KEY];
59
+ return pair ? { data: pair[0], document: pair[1] } : undefined;
60
+ }
@@ -52,8 +52,12 @@ export function buildFullPayload(
52
52
  rootLayout: ctx.router.rootLayout,
53
53
  // Full render: resolve deferred handle values server-side so SSR markup and
54
54
  // the first sync useHandle read see resolved values. Partial payloads
55
- // (rsc-rendering.ts) keep streaming (handleStore.stream()).
56
- handles: resolvedHandleStream(handleStore),
55
+ // (rsc-rendering.ts) keep streaming (handleStore.stream()). A shell
56
+ // capture renders from what its record keeps (resolvedHandleStream).
57
+ handles: resolvedHandleStream(
58
+ handleStore,
59
+ reqCtx._shellCaptureRun === true,
60
+ ),
57
61
  // Post-handler-barrier pushes (streaming loader ctx.handle() writes)
58
62
  // stream here; the client applies them after hydration. Instantly
59
63
  // complete when the loader lane is idle — including PPR shell capture,
@@ -14,6 +14,11 @@ import type { SSRStreamMode } from "../router/router-options.js";
14
14
 
15
15
  export interface HandlerContext<TEnv = unknown> {
16
16
  router: RangoInternal<TEnv, any>;
17
+ /**
18
+ * The router's DOCUMENT version (router-versions.ts): payload metadata, the
19
+ * `_rsc_v` reload check, and PPR shell stamping and gating. The data version
20
+ * is not here; cache stores read it from the request context.
21
+ */
17
22
  version: string;
18
23
  devDiscoveryEpoch?: number;
19
24
  renderToReadableStream: RSCDependencies["renderToReadableStream"];
@@ -1,5 +1,4 @@
1
1
  /// <reference types="@vitejs/plugin-rsc/types" />
2
- /// <reference path="../vite/plugins/version.d.ts" />
3
2
  /**
4
3
  * RSC Request Handler
5
4
  *
@@ -47,7 +46,7 @@ import {
47
46
  type ResponseRouteMatch,
48
47
  } from "./response-route-handler.js";
49
48
  import { nonce as nonceToken, resolveProviderNonce } from "./nonce.js";
50
- import { VERSION } from "@rangojs/router:version";
49
+ import { resolveRouterVersions } from "../server/build-version-table.js";
51
50
  import type { ErrorPhase } from "../types.js";
52
51
  import type { RouterRequestInput } from "../router/router-interfaces.js";
53
52
  import {
@@ -169,7 +168,18 @@ export function createRSCHandler<
169
168
  TEnv = unknown,
170
169
  TRoutes extends Record<string, string> = Record<string, string>,
171
170
  >(options: CreateRSCHandlerOptions<TEnv, TRoutes>) {
172
- const { router, version = VERSION, nonce: nonceProvider } = options;
171
+ const { router, nonce: nonceProvider } = options;
172
+ // A consumer-set version (this option, else createRouter({ version })) is
173
+ // used for both; otherwise the router's own pair from the build.
174
+ const versions = resolveRouterVersions(
175
+ router.id,
176
+ options.version ?? router.version,
177
+ );
178
+ // Everything the handler itself versions is tied to the HTML a tab holds:
179
+ // payload metadata (what the browser echoes as `_rsc_v`), the reload check,
180
+ // shell stamping and gating, the rango state value. The data version is only
181
+ // a cache-key prefix, read by the stores from the request context.
182
+ const version = versions.document;
173
183
 
174
184
  // Handler-owned registry of explicit per-scope stores from cache({ store }).
175
185
  // Lives in the closure so it is scoped per handler (multi-router deployments
@@ -572,7 +582,7 @@ export function createRSCHandler<
572
582
  executionContext: executionCtx,
573
583
  themeConfig: router.themeConfig,
574
584
  stateCookieName: router.resolvedStateCookieName,
575
- version,
585
+ versions,
576
586
  });
577
587
  if (unmarkedRequest) requestContext._shellForcedMiss = true;
578
588
  // Gate on the SAME enabled-semantics withTimeout uses (isTimeoutEnabled):
@@ -1336,7 +1336,7 @@ async function matchPartialWithPprReplay<TEnv>(
1336
1336
  // null and the seeded record could not have been consulted, which used to
1337
1337
  // read as a misleading `snapshot-miss` AFTER two wasted getShell reads.
1338
1338
  // Same predicate as resolveNavigation, decided before any store I/O.
1339
- if (!getNavigationContextHeader(request)) {
1339
+ if (!getNavigationContextHeader(request, url.origin)) {
1340
1340
  return runMatch({ outcome: "BYPASS", reason: "no-navigation-context" });
1341
1341
  }
1342
1342
  // Fragment passthrough (#700), extended to partial replay. Navigation
@@ -1745,6 +1745,17 @@ function serveShellHit(
1745
1745
  // close over that same context or a streaming tail inherits the base
1746
1746
  // context's premature non-streaming handle snapshot.
1747
1747
  wireRenderBarrier(tailCtx, handleStore);
1748
+ // The tail's render barrier resolves once its record is replayed and
1749
+ // before a loader runs (cache-lookup.ts withCacheLookup, yieldFromStore).
1750
+ // The store then holds what the prelude was rendered from, so that is
1751
+ // the snapshot the document hydrates with; what this request's loaders
1752
+ // do to it afterwards reaches the client after hydration
1753
+ // (HandleStore.freezeDocumentSnapshot, issue #1035).
1754
+ const resolveRenderBarrier = tailCtx._resolveRenderBarrier;
1755
+ tailCtx._resolveRenderBarrier = (segments) => {
1756
+ handleStore.freezeDocumentSnapshot();
1757
+ resolveRenderBarrier(segments);
1758
+ };
1748
1759
  return tailCtx;
1749
1760
  };
1750
1761
 
@@ -11,8 +11,9 @@
11
11
  * served through the same serveShellHit as a captured one.
12
12
  *
13
13
  * Lifecycle:
14
- * - No expiry until the next deploy — the buildVersion gate retires entries
15
- * the moment a new build ships (a new manifest replaces them anyway).
14
+ * - No expiry until the next deploy: every build ships its own manifest, and
15
+ * its entries carry that build's document version for the router, which is
16
+ * what the buildVersion gate compares.
16
17
  * - `ppr.ttl` drives STALENESS ONLY: past createdAt + ttl the entry still
17
18
  * serves, but a runtime recapture is scheduled — SWR is the UPGRADE path
18
19
  * from build entry to fresher runtime entry, not the bootstrap path. The
@@ -90,9 +91,10 @@ function loadManifest(): Promise<ShellManifestModule | null> {
90
91
  * Only the per-request gates (tag markers, staleness) stay outside the memo;
91
92
  * the document HIT decodes the prelude once per serve (openShellDocument),
92
93
  * partial replay never does. `null` memoizes a failed verdict —
93
- * deterministically invalid. Spec-only keying is sound because buildVersion is
94
- * process-constant on the manifest path (folded into the shipped worker; dev
95
- * never loads a manifest).
94
+ * deterministically invalid. Keyed by the asking router's document version as
95
+ * well as the spec: the manifest key is pathname-only (shell-manifest-key.ts),
96
+ * so under a host router two routers with different versions can ask about one
97
+ * record.
96
98
  */
97
99
  const validatedSpecs = new Map<string, ValidatedBuildShellEntry | null>();
98
100
 
@@ -110,7 +112,8 @@ async function validatedManifestRecord(
110
112
  spec: string,
111
113
  buildVersion: string,
112
114
  ): Promise<ValidatedBuildShellEntry | undefined> {
113
- let verdict = validatedSpecs.get(spec);
115
+ const memoKey = `${buildVersion}\u0000${spec}`;
116
+ let verdict = validatedSpecs.get(memoKey);
114
117
  if (verdict === undefined) {
115
118
  const record = (await mod.loadShellAsset(spec)).default;
116
119
  verdict =
@@ -118,7 +121,7 @@ async function validatedManifestRecord(
118
121
  hasIntactShellPayload(record.entry)
119
122
  ? (record as ValidatedBuildShellEntry)
120
123
  : null;
121
- validatedSpecs.set(spec, verdict);
124
+ validatedSpecs.set(memoKey, verdict);
122
125
  }
123
126
  return verdict ?? undefined;
124
127
  }
@@ -1961,8 +1961,8 @@ export function deriveShellCaptureContext(
1961
1961
  // (settleCaptureRecord) can wait for its promises, top-level and nested,
1962
1962
  // before the doc record encodes the handles a HIT replays. A route
1963
1963
  // cache() record's restore (handle-snapshot.ts restoreHandles) lands
1964
- // here too: a value it restores through pushReplayed keeps that loader
1965
- // as owner, so a HIT restores it as that loader's (pushRestored).
1964
+ // here too: a value it restores as a placeholder keeps that loader as
1965
+ // owner, so a HIT restores it as that loader's (pushRestored).
1966
1966
  // - DSL-LOADER pushes. Only bake-lane { ssr: false } loaders and the
1967
1967
  // loaders they await via ctx.use execute at capture (live loaders are
1968
1968
  // masked), their pushes are in the captured HTML (<head> title/meta,
@@ -1970,28 +1970,33 @@ export function deriveShellCaptureContext(
1970
1970
  // loader (loader-cache.ts), so the stored record carries them: each
1971
1971
  // settled, thenable-free push from a loader body keeps that loader as
1972
1972
  // `owner` (HandleStore.push), the record stores it
1973
- // (CachedEntryData.handleOwners), and the HIT restores it through
1974
- // pushRestored. Those copies stand: a promise-carrying bake-lane loader
1975
- // that runs on the HIT reads the store, so its settled pushes (and those
1976
- // replayed inside its body) are dropped and only its thenable ones are
1977
- // added. A loader the route also runs on the live lane is a hole: its
1978
- // live run replaces its copies (pushReplayed, #936), those of the
1973
+ // (CachedEntryData.handleOwners), and a replay that serves the loader
1974
+ // from its pin restores it through pushRestored (restoreHandles). Those
1975
+ // copies stand: a promise-carrying bake-lane loader that runs on the
1976
+ // replay reads the store, so its settled pushes (and those replayed
1977
+ // inside its body) are dropped and only its thenable ones are added. A
1978
+ // loader the replay does not serve from a pin is a hole: one the route
1979
+ // also runs on the live lane, or one whose pin the entry lost. Its run
1980
+ // replaces its copies (pushPlaceholder, #936), those of the
1979
1981
  // dependencies it awaits included (they are credited to it). Nested
1980
1982
  // thenables stay masked (the promise shape is the liveness
1981
1983
  // declaration, mask-nested.ts). A deferred (thenable) push,
1982
1984
  // one with masked nested promises, or one outside a loader body (a
1983
1985
  // loader-cache replay names its loader: pushReplayed below) stays
1984
1986
  // tagged (HandleStore.push loaderPush) so the
1985
- // handle encode cannot stall on a never-resolving mask; only the
1986
- // captureHandles cache-write call site excludes tagged pushes, and the
1987
- // loader records then carry `runs: 1` so a HIT runs those bodies for
1988
- // them.
1987
+ // handle encode cannot stall on a never-resolving mask. A tagged push
1988
+ // is out of the record (captureHandles) and out of the capture's own
1989
+ // render (resolvedHandleStream `recordedOnly`, issue #1035): the
1990
+ // prelude is rendered from what the record keeps, which is what a HIT
1991
+ // hydrates with. The loader records then carry `runs: 1` so a HIT runs
1992
+ // those bodies for them, and what they push reaches the client after
1993
+ // hydration (HandleStore.freezeDocumentSnapshot).
1989
1994
  const handlerPushSettles: Promise<void>[] = [];
1990
1995
  // A replay (a loader-cache HIT, loader-cache.ts replayLoaderHandles, or a
1991
1996
  // route cache() record's owned values, restoreHandles) re-pushes a loader's
1992
- // recorded values through pushReplayed, which calls push() outside any
1993
- // loader body: name that loader for the funnel so the record keeps them
1994
- // under it too.
1997
+ // recorded values through pushReplayed or pushPlaceholder, which call
1998
+ // push() outside any loader body: name that loader for the funnel so the
1999
+ // record keeps them under it too.
1995
2000
  let replayOwner: string | undefined;
1996
2001
  // The live-lane loader whose body a push is made in, at any depth, unless
1997
2002
  // a bake-lane loader's body is nearer (createMatchContextForFull sets the
@@ -2011,15 +2016,17 @@ export function deriveShellCaptureContext(
2011
2016
  : undefined)
2012
2017
  );
2013
2018
  };
2014
- const rawPushReplayed = freshHandleStore.pushReplayed.bind(freshHandleStore);
2015
- freshHandleStore.pushReplayed = (handleName, segmentId, value, loaderId) => {
2016
- replayOwner = loaderId;
2017
- try {
2018
- rawPushReplayed(handleName, segmentId, value, loaderId);
2019
- } finally {
2020
- replayOwner = undefined;
2021
- }
2022
- };
2019
+ for (const copy of ["pushReplayed", "pushPlaceholder"] as const) {
2020
+ const raw = freshHandleStore[copy].bind(freshHandleStore);
2021
+ freshHandleStore[copy] = (handleName, segmentId, value, loaderId) => {
2022
+ replayOwner = loaderId;
2023
+ try {
2024
+ raw(handleName, segmentId, value, loaderId);
2025
+ } finally {
2026
+ replayOwner = undefined;
2027
+ }
2028
+ };
2029
+ }
2023
2030
  const rawCapturePush = freshHandleStore.push.bind(freshHandleStore);
2024
2031
  freshHandleStore.push = (
2025
2032
  handleName: string,
@@ -2043,8 +2050,11 @@ export function deriveShellCaptureContext(
2043
2050
  let owner: string | undefined;
2044
2051
  if (isThenable(value)) {
2045
2052
  // Deferred (thenable) loader pushes always keep the tag — the
2046
- // carve-out below is for settled values only.
2047
- masked = value.then(mask);
2053
+ // carve-out below is for settled values only. Promise.resolve first: a
2054
+ // loader cache() entry replays a deferred push as the thenable Flight
2055
+ // decoded it to, whose then() returns nothing, and the slot was
2056
+ // `undefined` (the empty element of issue #1035).
2057
+ masked = Promise.resolve(value).then(mask);
2048
2058
  } else {
2049
2059
  masked = mask(value);
2050
2060
  if (!maskedNestedThenable) {
package/src/rsc/types.ts CHANGED
@@ -357,13 +357,11 @@ export interface CreateRSCHandlerOptions<
357
357
  cache?: HandlerCacheConfig | ((env: TEnv) => HandlerCacheConfig);
358
358
 
359
359
  /**
360
- * RSC version string included in metadata.
361
- * The browser sends this back on partial requests to detect version mismatches.
360
+ * Version override for this handler. Same meaning as
361
+ * `createRouter({ version })`, which it takes precedence over: this exact
362
+ * value is used for both of the router's versions.
362
363
  *
363
- * Defaults to the auto-generated VERSION from `rsc-router:version` virtual module.
364
- * Only set this if you need a custom versioning strategy.
365
- *
366
- * @default VERSION from rsc-router:version
364
+ * @default createRouter({ version }), else the router's build versions
367
365
  */
368
366
  version?: string;
369
367