@rangojs/router 0.20.0 → 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 (178) 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/testing/build-versions.d.ts +32 -0
  35. package/dist/types/testing/dom.entry.d.ts +1 -1
  36. package/dist/types/testing/flight.entry.d.ts +1 -1
  37. package/dist/types/testing/index.d.ts +2 -0
  38. package/dist/types/testing/internal/seed-vars.d.ts +7 -2
  39. package/dist/types/testing/location-state-key.d.ts +5 -1
  40. package/dist/types/testing/render-route.d.ts +77 -6
  41. package/dist/types/testing/serve-shell-request.d.ts +21 -0
  42. package/dist/types/testing/vitest-stubs/version.d.ts +1 -0
  43. package/dist/types/types/handler-context.d.ts +8 -5
  44. package/dist/types/vite/discovery/build-versions.d.ts +393 -0
  45. package/dist/types/vite/discovery/installed-packages.d.ts +26 -0
  46. package/dist/types/vite/discovery/router-versions-phase.d.ts +52 -0
  47. package/dist/types/vite/discovery/shell-prerender-phase.d.ts +7 -17
  48. package/dist/types/vite/discovery/state.d.ts +14 -0
  49. package/dist/types/vite/encryption-key.d.ts +18 -0
  50. package/dist/types/vite/plugin-types.d.ts +27 -0
  51. package/dist/types/vite/plugins/{version-injector.d.ts → entry-bootstrap-injector.d.ts} +4 -6
  52. package/dist/types/vite/plugins/expose-ids/export-analysis.d.ts +1 -0
  53. package/dist/types/vite/plugins/expose-ids/router-transform.d.ts +14 -0
  54. package/dist/types/vite/plugins/stable-rsc-output.d.ts +51 -0
  55. package/dist/types/vite/plugins/version-plugin.d.ts +9 -11
  56. package/dist/types/vite/plugins/virtual-entries.d.ts +18 -1
  57. package/dist/types/vite/router-discovery.d.ts +6 -0
  58. package/dist/types/vite/utils/client-chunks.d.ts +2 -0
  59. package/dist/types/vite/utils/compare-strings.d.ts +7 -0
  60. package/dist/types/vite/utils/package-resolution.d.ts +8 -0
  61. package/dist/types/vite/utils/prerender-utils.d.ts +2 -0
  62. package/dist/vite/index.js +1537 -444
  63. package/package.json +1 -2
  64. package/skills/cache-guide/SKILL.md +84 -20
  65. package/skills/caching/SKILL.md +16 -5
  66. package/skills/client-urls/SKILL.md +16 -0
  67. package/skills/cloudflare/SKILL.md +2 -1
  68. package/skills/comparison/references/framework-comparison.md +16 -12
  69. package/skills/hooks/SKILL.md +3 -0
  70. package/skills/hooks/handle-and-actions.md +6 -0
  71. package/skills/hooks/navigation.md +28 -0
  72. package/skills/hooks/state.md +209 -8
  73. package/skills/hooks/urls.md +40 -1
  74. package/skills/host-router/SKILL.md +13 -0
  75. package/skills/intercept/SKILL.md +6 -0
  76. package/skills/loader/SKILL.md +19 -1
  77. package/skills/middleware/SKILL.md +2 -0
  78. package/skills/ppr/SKILL.md +119 -40
  79. package/skills/prerender/SKILL.md +7 -3
  80. package/skills/rango/SKILL.md +8 -7
  81. package/skills/router-setup/SKILL.md +15 -1
  82. package/skills/testing/SKILL.md +30 -26
  83. package/skills/testing/client-components.md +193 -8
  84. package/skills/testing/handles.md +2 -0
  85. package/skills/testing/loader.md +1 -1
  86. package/skills/testing/middleware.md +1 -1
  87. package/skills/testing/render-handler.md +18 -18
  88. package/skills/testing/serve-shell-request.md +63 -14
  89. package/skills/testing/server-actions.md +17 -17
  90. package/skills/testing/server-tree.md +1 -1
  91. package/skills/typesafety/generated-files-and-cli.md +1 -1
  92. package/skills/use-cache/SKILL.md +16 -5
  93. package/skills/vercel/SKILL.md +3 -3
  94. package/src/bin/rango.ts +1 -1
  95. package/src/browser/event-controller.ts +75 -2
  96. package/src/browser/history-state.ts +39 -17
  97. package/src/browser/navigation-bridge.ts +41 -18
  98. package/src/browser/navigation-transaction.ts +15 -10
  99. package/src/browser/partial-update.ts +10 -2
  100. package/src/browser/rango-state.ts +3 -1
  101. package/src/browser/react/Link.tsx +2 -3
  102. package/src/browser/react/NavigationProvider.tsx +25 -1
  103. package/src/browser/react/context.ts +13 -0
  104. package/src/browser/react/location-state-shared.ts +200 -34
  105. package/src/browser/react/location-state.ts +40 -75
  106. package/src/browser/react/subscribe-to-nothing.ts +7 -0
  107. package/src/browser/react/use-handle.ts +20 -1
  108. package/src/browser/rsc-router.tsx +33 -11
  109. package/src/browser/server-action-bridge.ts +17 -1
  110. package/src/cache/cache-scope.ts +18 -8
  111. package/src/cache/cf/cf-cache-store.ts +75 -55
  112. package/src/cache/cf/cf-cache-types.ts +12 -4
  113. package/src/cache/handle-snapshot.ts +69 -87
  114. package/src/cache/index.ts +4 -0
  115. package/src/cache/shell-snapshot.ts +35 -5
  116. package/src/cache/types.ts +21 -10
  117. package/src/cache/vercel/vercel-cache-store.ts +48 -20
  118. package/src/client-urls/client-root.tsx +1 -0
  119. package/src/client-urls/navigation.ts +10 -0
  120. package/src/client-urls/optimistic-location.ts +10 -4
  121. package/src/handles/deferred-resolution.ts +31 -0
  122. package/src/prerender/build-shell-capture.ts +11 -9
  123. package/src/router/manifest.ts +2 -0
  124. package/src/router/match-middleware/cache-lookup.ts +11 -18
  125. package/src/router/navigation-snapshot.ts +25 -6
  126. package/src/router/request-classification.ts +15 -12
  127. package/src/router/router-interfaces.ts +2 -1
  128. package/src/router/router-options.ts +17 -7
  129. package/src/router/segment-resolution/loader-cache.ts +95 -60
  130. package/src/router-versions.ts +60 -0
  131. package/src/rsc/full-payload.ts +6 -2
  132. package/src/rsc/handler-context.ts +5 -0
  133. package/src/rsc/handler.ts +14 -4
  134. package/src/rsc/rsc-rendering.ts +12 -1
  135. package/src/rsc/shell-build-manifest.ts +10 -7
  136. package/src/rsc/shell-capture.ts +36 -26
  137. package/src/rsc/types.ts +4 -6
  138. package/src/server/build-version-table.ts +115 -0
  139. package/src/server/handle-store.ts +344 -127
  140. package/src/server/request-context.ts +62 -11
  141. package/src/ssr/ssr-root.tsx +16 -4
  142. package/src/testing/build-versions.ts +64 -0
  143. package/src/testing/dispatch.ts +6 -0
  144. package/src/testing/dom.entry.ts +1 -0
  145. package/src/testing/flight.entry.ts +1 -0
  146. package/src/testing/index.ts +3 -0
  147. package/src/testing/internal/context.ts +2 -1
  148. package/src/testing/internal/seed-vars.ts +14 -2
  149. package/src/testing/location-state-key.ts +18 -8
  150. package/src/testing/render-handler.ts +2 -1
  151. package/src/testing/render-route.tsx +277 -43
  152. package/src/testing/serve-shell-request.ts +80 -11
  153. package/src/testing/vitest-stubs/version.ts +4 -1
  154. package/src/types/handler-context.ts +8 -5
  155. package/src/vite/discovery/build-versions.ts +1281 -0
  156. package/src/vite/discovery/installed-packages.ts +159 -0
  157. package/src/vite/discovery/prerender-collection.ts +38 -1
  158. package/src/vite/discovery/router-versions-phase.ts +336 -0
  159. package/src/vite/discovery/shell-prerender-phase.ts +28 -38
  160. package/src/vite/discovery/state.ts +19 -0
  161. package/src/vite/discovery/virtual-module-codegen.ts +7 -2
  162. package/src/vite/encryption-key.ts +101 -9
  163. package/src/vite/plugin-types.ts +28 -0
  164. package/src/vite/plugins/{version-injector.ts → entry-bootstrap-injector.ts} +7 -22
  165. package/src/vite/plugins/expose-ids/export-analysis.ts +1 -1
  166. package/src/vite/plugins/expose-ids/router-transform.ts +36 -15
  167. package/src/vite/plugins/expose-internal-ids.ts +12 -3
  168. package/src/vite/plugins/stable-rsc-output.ts +147 -0
  169. package/src/vite/plugins/version-plugin.ts +19 -24
  170. package/src/vite/plugins/version.d.ts +17 -2
  171. package/src/vite/plugins/virtual-entries.ts +31 -5
  172. package/src/vite/rango.ts +28 -3
  173. package/src/vite/router-discovery.ts +82 -13
  174. package/src/vite/utils/client-chunks.ts +5 -1
  175. package/src/vite/utils/compare-strings.ts +9 -0
  176. package/src/vite/utils/package-resolution.ts +9 -0
  177. package/src/vite/utils/prerender-utils.ts +4 -1
  178. package/src/vite/utils/shared-utils.ts +1 -1
@@ -55,13 +55,23 @@ function createLateHandlePushError(
55
55
  interface SlotTag {
56
56
  /** Pushed inside a DSL loader scope; captureHandles drops it. */
57
57
  loader?: boolean;
58
- /** A loader-cache replay of this loader's recorded push (pushReplayed). */
58
+ /** A cached unit's replay of this loader's recorded push (pushReplayed). */
59
59
  replayOf?: string;
60
- /** Pushed by this loader's live run after it replaced its replayed slots. */
60
+ /**
61
+ * A record's copy for a loader that record does not supply the value of
62
+ * (pushPlaceholder): it stands in until the loader's own source delivers.
63
+ */
64
+ placeholderOf?: string;
65
+ /** Pushed by this loader's live run after it replaced its copies. */
61
66
  liveOf?: string;
67
+ /**
68
+ * The loaders around that run's body whose runs replaced copies too: their
69
+ * next push follows this one, so a run's pushes keep their order.
70
+ */
71
+ under?: readonly string[];
62
72
  /** Pushed by this loader's body and kept in records (push `owner`). */
63
73
  owner?: string;
64
- /** A document HIT's restore of this loader's record copy (pushRestored). */
74
+ /** A record's copy for a loader served from that record's pin (pushRestored). */
65
75
  restoredOf?: string;
66
76
  /** A restored slot a dropped settled push of its loader stood in for. */
67
77
  matched?: true;
@@ -70,6 +80,26 @@ interface SlotTag {
70
80
  // Shared by every plain DSL-loader push; tags are never mutated in place.
71
81
  const LOADER_SLOT: SlotTag = Object.freeze({ loader: true });
72
82
 
83
+ /**
84
+ * What a restored record is to one loader's handle pushes on this request
85
+ * (loader-cache.ts loaderPins; docs/design/handle-push-ownership.md). Two
86
+ * answers let the record's copies stand, two make them placeholders, and in
87
+ * each pair one also speaks for the loader's other pushes:
88
+ * - "pin": the request serves the loader's value from the pin stored with
89
+ * the record, and the record lists every settled push of that run. Its
90
+ * copies stand, and any other settled push of the loader is dropped.
91
+ * - "copies": its copies stand and nothing else is known: a dependency of
92
+ * pinned loaders, or a pin written before captures recorded every push.
93
+ * - "hole": the route runs the loader on this request (it registers it and
94
+ * the record does not pin it). Its copies are placeholders and a push made
95
+ * in its body is its own, with or without copies.
96
+ * - "placeholders": its copies are placeholders and nothing else is known: a
97
+ * dependency the route does not register while a pin is missing (the
98
+ * record does not name the loader that ran it). Without copies its push
99
+ * counts for the loader that awaits it.
100
+ */
101
+ export type RecordAuthority = "pin" | "copies" | "hole" | "placeholders";
102
+
73
103
  function cloneHandleData(data: HandleData): HandleData {
74
104
  const clone: HandleData = {};
75
105
  for (const handleName in data) {
@@ -158,15 +188,17 @@ export interface HandleStore {
158
188
  ): void;
159
189
 
160
190
  /**
161
- * push() a loader-cache HIT's replayed value, recorded from `loaderId`'s
162
- * body (loader-cache.ts replayLoaderHandles). If that loader then runs live
163
- * in this request, its first push removes every value replayed for it and
164
- * takes the first one's position (later live pushes follow it), so the
165
- * handle output stays live without a duplicate. A push counts as that
166
- * loader's when it is made in its body, or in a body it entered that has
167
- * no replayed values of its own (the capture credits a dependency's push
168
- * under a live-lane loader to that loader); a hole's run that ends without
169
- * one drops them (settleLoaderRun).
191
+ * push() a cached unit's copy of a value `loaderId`'s body pushed
192
+ * (handle-snapshot.ts appendHandles: a loader-cache or "use cache" HIT). If
193
+ * that loader then runs live in this request, its first push removes every
194
+ * copy pushed for it (replays and placeholders) and takes the first one's
195
+ * position, and the pushes of that run follow in push order, a dependency's
196
+ * included, so the handle output stays live without a duplicate. A push
197
+ * counts as that loader's when it is made in its body, or in a body it
198
+ * entered that has no copies of its own (the capture credits a dependency's
199
+ * push under a live-lane loader to that loader). A run that ends without a
200
+ * push leaves a replay in place, unless the loader is a hole
201
+ * (settleLoaderRun).
170
202
  */
171
203
  pushReplayed(
172
204
  handleName: string,
@@ -176,16 +208,27 @@ export interface HandleStore {
176
208
  ): void;
177
209
 
178
210
  /**
179
- * push() a document HIT's copy of `loaderId`'s settled push, restored from
180
- * the shell's doc record (handle-snapshot.ts restoreHandles). The record is
181
- * authoritative for that loader's settled pushes: the prelude was rendered
182
- * from it, and a run of the loader on the HIT reads the store, not the
183
- * capture's values. So from then on this request drops every settled,
184
- * thenable-free push the loader makes (live, or replayed from a cache
185
- * entry, anywhere inside its body), and keeps the ones holding a thenable
186
- * (holdsThenable: what the record could not keep, `runs`), placed among the
187
- * restored copies in the loader's push order. Only bake-lane owners are
188
- * restored this way (withCacheLookup): a live-lane loader is a hole.
211
+ * push() a record's copy of `loaderId`'s push as a placeholder
212
+ * (handle-snapshot.ts restoreHandles): the record does not supply that
213
+ * loader's value on this request, so its own source decides its pushes. A
214
+ * live run replaces it as it does a replay; a run that ends without a push
215
+ * drops it (settleLoaderRun); a cached unit that claims the loader delivers
216
+ * in its place (replacePlaceholders).
217
+ */
218
+ pushPlaceholder(
219
+ handleName: string,
220
+ segmentId: string,
221
+ data: unknown,
222
+ loaderId: string,
223
+ ): void;
224
+
225
+ /**
226
+ * push() a record's copy of `loaderId`'s settled push that stands
227
+ * (restoreHandles): the record supplies the loader's value too. From then
228
+ * on this request drops every settled, thenable-free push the loader makes
229
+ * (live, or replayed from a cache entry, anywhere inside its body), and
230
+ * keeps the ones holding a thenable (holdsThenable: what the record could
231
+ * not keep), placed among the restored copies in the loader's push order.
189
232
  */
190
233
  pushRestored(
191
234
  handleName: string,
@@ -195,41 +238,34 @@ export interface HandleStore {
195
238
  ): void;
196
239
 
197
240
  /**
198
- * The loaders the route runs on the live lane (liveLaneLoaderIds), from a
199
- * PPR shell record's restore (handle-snapshot.ts restoreHandles): the
200
- * holes. A push counts for a restored loader when that loader is the
201
- * replayed one or the innermost enclosing loader body that restored
202
- * copies; the walk out through enclosing bodies stops at a hole's body,
203
- * and a replay of a hole's push counts for none. So a restored loader
204
- * never takes a push made inside a hole it awaited, while a body inside the
205
- * hole that restored copies of its own (a dependency the capture ran under
206
- * a bake-lane loader) still takes its pushes.
241
+ * What the record this request restored is to each loader (RecordAuthority;
242
+ * CacheScope.lookupRouteDetailed sets it on a hit). A "pin" loader counts
243
+ * as restored with or without copies. A hole is a "hole" loader or one with
244
+ * placeholders: the walk out through enclosing loader bodies that finds the
245
+ * loader a push counts for stops at its body, so a pinned loader never
246
+ * takes a push made inside a hole it awaited.
207
247
  */
208
- markLiveLane(loaderIds: ReadonlySet<string>): void;
248
+ setRecordAuthority(authority: (loaderId: string) => RecordAuthority): void;
209
249
 
210
250
  /**
211
251
  * `loaderId`'s run in this request settled (resolved or rejected), its
212
- * pushes made (loader-resolution.ts createLoaderExecutor). When the loader
213
- * is a hole (markLiveLane), values replayed for it that no push of the run
214
- * replaced are dropped: the run's output decides, so a hole that throws or
215
- * skips its push shows none rather than the capture's. Any other loader's
216
- * replayed values stay: a bake-lane loader's record is authoritative for
217
- * its settled pushes, as its pin is for its data. Restored copies are not
218
- * replays and stand.
252
+ * pushes made (loader-resolution.ts createLoaderExecutor). Its placeholders
253
+ * that no push of the run replaced are dropped: the run's output decides,
254
+ * so a loader that throws or skips its push shows none rather than the
255
+ * record's. A hole's replays go with them. Any other loader's replays stay
256
+ * (a dependency another entry's HIT replayed), and so do restored copies.
219
257
  */
220
258
  settleLoaderRun(loaderId: string): void;
221
259
 
222
260
  /**
223
- * A hole's own cache() HIT (loader-cache.ts replayLoaderHandles, once it
224
- * claims the hole): the values replayed for hole `loaderId` before (its
225
- * shell copies, which restoreHandles leaves unclaimed, those of the
226
- * dependencies credited to it included) give way to the pushes the entry
227
- * recorded, none when it recorded none, so they match the entry's data.
228
- * `deliver` makes the pushReplayed calls, which take the removed values'
229
- * place in each array. Any other loader, or a hole with nothing replayed,
230
- * delivers as a plain replay.
261
+ * A cached unit's HIT delivering the pushes it recorded for `loaderIds`,
262
+ * the loaders it claimed (appendHandles). Their placeholders give way to
263
+ * what `deliver` pushes for them, nothing when it pushes nothing, so the
264
+ * page shows the pushes of the run that produced the unit's value. The
265
+ * pushReplayed calls `deliver` makes take the removed values' place in each
266
+ * array, in order. Without placeholders it is a plain replay.
231
267
  */
232
- redeliverReplays(loaderId: string, deliver: () => void): void;
268
+ replacePlaceholders(loaderIds: readonly string[], deliver: () => void): void;
233
269
 
234
270
  /**
235
271
  * Get all collected handle data after all handlers have settled.
@@ -237,6 +273,24 @@ export interface HandleStore {
237
273
  */
238
274
  getData(): Promise<HandleData>;
239
275
 
276
+ /**
277
+ * Fix the document lane at the store's current state. From then on
278
+ * `stream("settled")` yields this state and nothing newer, and
279
+ * `streamLate()` delivers every change made after it, whether or not it
280
+ * beat the handler barrier. The first call wins. It must run before the
281
+ * document stream is first read: a stream that started on the live state
282
+ * stays on it (reported through `onError` in development).
283
+ *
284
+ * A PPR shell HIT calls it once its record is replayed and before a loader
285
+ * runs (rsc-rendering.ts serveShellHit). The prelude was rendered from the
286
+ * record's handle data (resolvedHandleStream `recordedOnly`), so that is
287
+ * what the client must hydrate with. What the request's loaders then push,
288
+ * replace or drop is theirs, not the shell's: in the document snapshot it
289
+ * rendered elements the prelude never had, or left out ones it has (issue
290
+ * #1035), and it got there or not by a race with the lane's first read.
291
+ */
292
+ freezeDocumentSnapshot(): void;
293
+
240
294
  /**
241
295
  * Get an async iterator that yields handle data on each push.
242
296
  * Completes at the chosen settlement barrier: "fullySettled" (default —
@@ -245,6 +299,9 @@ export interface HandleStore {
245
299
  * blocking positions and must not wait on loader bodies).
246
300
  * Each yield contains the full accumulated state (not just the delta).
247
301
  * Safe for concurrent consumers (per-consumer version cursor).
302
+ * The "settled" lane of a frozen store (freezeDocumentSnapshot) yields the
303
+ * frozen state once, at once, and ends. That yield is the store's own
304
+ * frozen object: a consumer must not write to it.
248
305
  */
249
306
  stream(
250
307
  until?: "settled" | "fullySettled",
@@ -256,7 +313,9 @@ export interface HandleStore {
256
313
  * fullySettled. Returns without yielding when the auxiliary lane is empty
257
314
  * by the time `settled` resolves. The client consumes this post-hydration
258
315
  * (rsc-router.tsx) and merges via the same application path as nav-lane
259
- * progressive handle updates.
316
+ * progressive handle updates. On a frozen store (freezeDocumentSnapshot)
317
+ * it starts from the frozen state instead: a change made before the
318
+ * handler barrier is late too.
260
319
  */
261
320
  streamLate(): AsyncGenerator<HandleData, void, unknown>;
262
321
 
@@ -317,45 +376,51 @@ export function createHandleStore(): HandleStore {
317
376
  // (not value identity) so primitive values are covered too.
318
377
  // replaySegmentData installs fresh arrays, so restored values are untagged.
319
378
  const slotTags = new WeakMap<unknown[], (SlotTag | undefined)[]>();
320
- // Replayed loaders that have not pushed live yet: loaderId -> the arrays
321
- // holding their replayed slots.
379
+ // Copies standing in for a loader that has not pushed live yet: loaderId ->
380
+ // the arrays holding them. Kept apart by kind: only a placeholder gives way
381
+ // to a run that ends without a push, or to a cached unit's delivery.
322
382
  const pendingReplays = new Map<string, Set<unknown[]>>();
323
- // Replayed loaders whose live run already replaced those slots.
324
- const replacedReplays = new Set<string>();
325
- // Loaders a document HIT restored record copies for (pushRestored).
383
+ const pendingPlaceholders = new Map<string, Set<unknown[]>>();
384
+ // Loaders whose live run already replaced its copies.
385
+ const replaced = new Set<string>();
386
+ // Loaders a record restored standing copies for (pushRestored), and those
387
+ // it restored placeholders for (pushPlaceholder).
326
388
  const restoredLoaders = new Set<string>();
389
+ const placeholderLoaders = new Set<string>();
390
+ let authority: ((loaderId: string) => RecordAuthority) | undefined;
327
391
  const isRestored = (loaderId: string): boolean =>
328
- restoredLoaders.has(loaderId);
329
- // The route's live-lane loaders (markLiveLane): holes a restore never
330
- // reaches into.
331
- let liveLane: ReadonlySet<string> | undefined;
392
+ restoredLoaders.has(loaderId) || authority?.(loaderId) === "pin";
332
393
  const isHole = (loaderId: string): boolean =>
333
- liveLane?.has(loaderId) ?? false;
394
+ placeholderLoaders.has(loaderId) || authority?.(loaderId) === "hole";
334
395
 
335
- const hasReplays = (loaderId: string): boolean =>
336
- pendingReplays.has(loaderId) || replacedReplays.has(loaderId);
396
+ const hasCopies = (loaderId: string): boolean =>
397
+ pendingReplays.has(loaderId) ||
398
+ pendingPlaceholders.has(loaderId) ||
399
+ replaced.has(loaderId);
337
400
 
338
401
  // The restored loader a push counts for, as the capture credits it
339
402
  // (shell-capture.ts deriveShellCaptureContext): the replayed loader when it
340
- // restored one, else the innermost enclosing loader body that did. A
403
+ // is restored, else the innermost enclosing loader body that is. A
341
404
  // "use cache" hit inside a restored body replays a dependency's push the
342
405
  // capture recorded under that body. A hole's body, or a replay of a hole's
343
406
  // push, ends the search: a live loader a restored loader awaits is still
344
407
  // live.
345
408
  function restoringLoader(replayOf: string | undefined): string | undefined {
346
409
  if (replayOf !== undefined) {
347
- if (restoredLoaders.has(replayOf)) return replayOf;
410
+ if (isRestored(replayOf)) return replayOf;
348
411
  // Defensive: a hole's own cache() replay runs outside any loader body,
349
412
  // so the walk below finds no restored loader either.
350
413
  if (isHole(replayOf)) return undefined;
351
414
  }
352
415
  return findEnclosingLoaderBody(isRestored, isHole);
353
416
  }
354
- // The replay or restore whose this.push() is in flight, as the tag its
355
- // slot takes. Set around the call, not passed, so a capture wrapping
356
- // push() sees it as a push.
357
- let replaying: Pick<SlotTag, "replayOf" | "restoredOf"> | undefined;
358
- // A redeliverReplays delivery in flight: per array, where its next
417
+ // The copy whose this.push() is in flight, as the tag its slot takes. Set
418
+ // around the call, not passed, so a capture wrapping push() sees it as a
419
+ // push.
420
+ let replaying:
421
+ | Pick<SlotTag, "replayOf" | "placeholderOf" | "restoredOf">
422
+ | undefined;
423
+ // A replacePlaceholders delivery in flight: per array, where its next
359
424
  // pushReplayed value goes (the removed values' place).
360
425
  let redelivery: Map<unknown[], number> | undefined;
361
426
 
@@ -383,22 +448,23 @@ export function createHandleStore(): HandleStore {
383
448
  tags.splice(at, 0, tag);
384
449
  }
385
450
 
386
- // Remove `loaderId`'s replayed slots; returns the first one's position.
387
- function removeReplayedSlots(
451
+ // Remove the slots `drops` accepts; returns the first one's position.
452
+ function removeSlots(
388
453
  values: unknown[],
389
- loaderId: string,
454
+ drops: (tag: SlotTag) => boolean,
390
455
  ): number | undefined {
391
456
  const tags = slotTags.get(values);
392
457
  if (!tags) return undefined;
393
458
  let first: number | undefined;
394
459
  let kept = 0;
395
460
  for (let i = 0; i < values.length; i++) {
396
- if (tags[i]?.replayOf === loaderId) {
461
+ const tag = tags[i];
462
+ if (tag && drops(tag)) {
397
463
  first ??= kept;
398
464
  continue;
399
465
  }
400
466
  values[kept] = values[i];
401
- tags[kept] = tags[i];
467
+ tags[kept] = tag;
402
468
  kept++;
403
469
  }
404
470
  values.length = kept;
@@ -406,32 +472,80 @@ export function createHandleStore(): HandleStore {
406
472
  return first;
407
473
  }
408
474
 
409
- // Where a live push by a replayed loader lands in `values`: its first push
410
- // removes all of its replayed slots and takes the first one's position in
411
- // this array; later pushes follow its previous live push. undefined appends.
412
- function liveReplacementIndex(
413
- loaderId: string,
414
- values: unknown[],
415
- ): number | undefined {
416
- const pending = pendingReplays.get(loaderId);
417
- if (pending) {
418
- pendingReplays.delete(loaderId);
419
- replacedReplays.add(loaderId);
420
- let at: number | undefined;
421
- for (const replayed of pending) {
422
- const first = removeReplayedSlots(replayed, loaderId);
423
- if (replayed === values) at = first;
424
- }
425
- return at;
426
- }
475
+ // After the last push of `loaderId`'s run in `values`: its own, or one made
476
+ // in a body it entered. undefined: none yet.
477
+ function runCursor(loaderId: string, values: unknown[]): number | undefined {
427
478
  const tags = slotTags.get(values);
428
479
  if (!tags) return undefined;
429
480
  for (let i = tags.length - 1; i >= 0; i--) {
430
- if (tags[i]?.liveOf === loaderId) return i + 1;
481
+ const tag = tags[i];
482
+ if (tag?.liveOf === loaderId || tag?.under?.includes(loaderId)) {
483
+ return i + 1;
484
+ }
431
485
  }
432
486
  return undefined;
433
487
  }
434
488
 
489
+ // The loaders around `pusher`'s body, innermost first, whose runs replaced
490
+ // copies (SlotTag.under).
491
+ function replacedAround(pusher: string): string[] | undefined {
492
+ let around: string[] | undefined;
493
+ let outside = false;
494
+ findEnclosingLoaderBody((loaderId) => {
495
+ if (outside && replaced.has(loaderId)) (around ??= []).push(loaderId);
496
+ if (loaderId === pusher) outside = true;
497
+ return false;
498
+ });
499
+ return around;
500
+ }
501
+
502
+ // Remove `loaderId`'s pending placeholders, and its replays with `replays`.
503
+ // Returns the first position removed from `values`.
504
+ function dropCopies(
505
+ loaderId: string,
506
+ replays: boolean,
507
+ values?: unknown[],
508
+ ): number | undefined {
509
+ const placeholderArrays = pendingPlaceholders.get(loaderId);
510
+ const replayArrays = replays ? pendingReplays.get(loaderId) : undefined;
511
+ if (!placeholderArrays && !replayArrays) return undefined;
512
+ pendingPlaceholders.delete(loaderId);
513
+ if (replayArrays) pendingReplays.delete(loaderId);
514
+ replaced.add(loaderId);
515
+ const own = (tag: SlotTag): boolean =>
516
+ tag.placeholderOf === loaderId || (replays && tag.replayOf === loaderId);
517
+ let at: number | undefined;
518
+ for (const arrays of [placeholderArrays, replayArrays]) {
519
+ if (!arrays) continue;
520
+ for (const copied of arrays) {
521
+ const first = removeSlots(copied, own);
522
+ if (copied === values) at ??= first;
523
+ }
524
+ }
525
+ return at;
526
+ }
527
+
528
+ // Where a live push by a loader with copies lands in `values`: its first
529
+ // push removes all of its copies and takes the first one's position in this
530
+ // array; later pushes follow the last push of its run. undefined: no place
531
+ // of its own here.
532
+ function liveReplacementIndex(
533
+ loaderId: string,
534
+ values: unknown[],
535
+ ): number | undefined {
536
+ return dropCopies(loaderId, true, values) ?? runCursor(loaderId, values);
537
+ }
538
+
539
+ function addPending(
540
+ pending: Map<string, Set<unknown[]>>,
541
+ loaderId: string,
542
+ values: unknown[],
543
+ ): void {
544
+ let arrays = pending.get(loaderId);
545
+ if (!arrays) pending.set(loaderId, (arrays = new Set()));
546
+ arrays.add(values);
547
+ }
548
+
435
549
  // A restored loader's settled push: the first of its restored slots here
436
550
  // not yet matched stands in for it. Returns nothing; the push is dropped.
437
551
  function matchRestoredSlot(values: unknown[], loaderId: string): void {
@@ -561,6 +675,12 @@ export function createHandleStore(): HandleStore {
561
675
  let emissionWaiters: (() => void)[] = [];
562
676
  let completed = false;
563
677
  let streamConsumed = false;
678
+ // freezeDocumentSnapshot: the state the document lane serves, and the
679
+ // version the late channel starts after.
680
+ let documentSnapshot: { version: number; data: HandleData } | undefined;
681
+ // A stream("settled") consumer started on the live state: a freeze after
682
+ // that does not reach it.
683
+ let documentStreamRead = false;
564
684
 
565
685
  // Wake every waiting consumer (new push or a settlement barrier fired).
566
686
  function signalEmission() {
@@ -631,21 +751,27 @@ export function createHandleStore(): HandleStore {
631
751
  throw error;
632
752
  }
633
753
 
634
- const { replayOf, restoredOf } = replaying ?? {};
635
- // A restore is no loader's push. Any other push, while a loader has
636
- // replayed or restored slots to reconcile, is attributed as the capture
754
+ const { replayOf, placeholderOf, restoredOf } = replaying ?? {};
755
+ // A record's copy is no loader's push. Any other push, while a loader
756
+ // has copies or a restore to reconcile, is attributed as the capture
637
757
  // credits it: to the innermost loader body it is made in that has
638
- // replayed slots, up to a hole's body, so a replay made in a body
639
- // replaces that body's replayed copies and a dependency's push replaces
640
- // those of the live-lane loader it ran under. Its record copy stands
641
- // when a restored loader counts it (restoringLoader).
758
+ // copies, up to a hole's body, so a replay made in a body replaces
759
+ // that body's copies and a dependency's push replaces those of the
760
+ // live-lane loader it ran under. Its record copy stands when a
761
+ // restored loader counts it (restoringLoader).
642
762
  let pusher: string | undefined;
643
763
  let restoredBy: string | undefined;
644
- if (!restoredOf) {
645
- if (pendingReplays.size > 0 || replacedReplays.size > 0) {
646
- pusher = findEnclosingLoaderBody(hasReplays, isHole);
764
+ if (restoredOf === undefined && placeholderOf === undefined) {
765
+ if (
766
+ pendingReplays.size > 0 ||
767
+ pendingPlaceholders.size > 0 ||
768
+ replaced.size > 0
769
+ ) {
770
+ pusher = findEnclosingLoaderBody(hasCopies, isHole);
771
+ }
772
+ if (restoredLoaders.size > 0 || authority) {
773
+ restoredBy = restoringLoader(replayOf);
647
774
  }
648
- if (restoredLoaders.size > 0) restoredBy = restoringLoader(replayOf);
649
775
  }
650
776
  if (restoredBy !== undefined && !holdsThenable(value)) {
651
777
  const existing = data[handleName]?.[segmentId];
@@ -663,32 +789,52 @@ export function createHandleStore(): HandleStore {
663
789
  const loader = (loaderPush ?? isInsideLoaderScope()) || undefined;
664
790
  let at: number | undefined;
665
791
  let liveOf: string | undefined;
666
- if (restoredOf) {
792
+ let under: string[] | undefined;
793
+ if (restoredOf !== undefined) {
667
794
  restoredLoaders.add(restoredOf);
795
+ } else if (placeholderOf !== undefined) {
796
+ placeholderLoaders.add(placeholderOf);
668
797
  } else if (replayOf !== undefined && redelivery?.has(values)) {
669
798
  at = redelivery.get(values)!;
670
799
  redelivery.set(values, at + 1);
671
800
  } else if (pusher !== undefined) {
801
+ under = replacedAround(pusher);
672
802
  at = liveReplacementIndex(pusher, values);
803
+ // No place of its own here: it follows the run it was made in.
804
+ for (let i = 0; at === undefined && under && i < under.length; i++) {
805
+ at = runCursor(under[i]!, values);
806
+ }
673
807
  liveOf = pusher;
674
808
  } else if (restoredBy !== undefined) {
675
809
  at = restoredInsertIndex(values, restoredBy);
676
- if (!replayOf) liveOf = restoredBy;
810
+ if (replayOf === undefined) liveOf = restoredBy;
677
811
  }
678
812
  insertSlot(
679
813
  values,
680
814
  at,
681
815
  value,
682
- replayOf || liveOf || owner || restoredOf
683
- ? { loader, replayOf, liveOf, owner, restoredOf }
816
+ replayOf !== undefined ||
817
+ placeholderOf !== undefined ||
818
+ restoredOf !== undefined ||
819
+ liveOf !== undefined ||
820
+ owner !== undefined
821
+ ? {
822
+ loader,
823
+ replayOf,
824
+ placeholderOf,
825
+ restoredOf,
826
+ liveOf,
827
+ under,
828
+ owner,
829
+ }
684
830
  : loader
685
831
  ? LOADER_SLOT
686
832
  : undefined,
687
833
  );
688
- if (replayOf) {
689
- let arrays = pendingReplays.get(replayOf);
690
- if (!arrays) pendingReplays.set(replayOf, (arrays = new Set()));
691
- arrays.add(values);
834
+ if (replayOf !== undefined) {
835
+ addPending(pendingReplays, replayOf, values);
836
+ } else if (placeholderOf !== undefined) {
837
+ addPending(pendingPlaceholders, placeholderOf, values);
692
838
  }
693
839
 
694
840
  // Bump the version; each consumer's cursor decides when to clone+yield.
@@ -696,7 +842,7 @@ export function createHandleStore(): HandleStore {
696
842
  signalEmission();
697
843
  },
698
844
 
699
- // Both go through this.push: active captures see a replay like a live
845
+ // All three go through this.push: active captures see a copy like a live
700
846
  // push, and a diverting capture keeps it out of the store.
701
847
  pushReplayed(
702
848
  handleName: string,
@@ -707,6 +853,21 @@ export function createHandleStore(): HandleStore {
707
853
  replayPush(this, { replayOf: loaderId }, handleName, segmentId, value);
708
854
  },
709
855
 
856
+ pushPlaceholder(
857
+ handleName: string,
858
+ segmentId: string,
859
+ value: unknown,
860
+ loaderId: string,
861
+ ): void {
862
+ replayPush(
863
+ this,
864
+ { placeholderOf: loaderId },
865
+ handleName,
866
+ segmentId,
867
+ value,
868
+ );
869
+ },
870
+
710
871
  pushRestored(
711
872
  handleName: string,
712
873
  segmentId: string,
@@ -716,30 +877,52 @@ export function createHandleStore(): HandleStore {
716
877
  replayPush(this, { restoredOf: loaderId }, handleName, segmentId, value);
717
878
  },
718
879
 
719
- markLiveLane(loaderIds: ReadonlySet<string>): void {
720
- liveLane = loaderIds;
880
+ setRecordAuthority(of: (loaderId: string) => RecordAuthority): void {
881
+ authority = of;
721
882
  },
722
883
 
723
884
  settleLoaderRun(loaderId: string): void {
724
- const pending = pendingReplays.get(loaderId);
725
- if (!pending || completed || !isHole(loaderId)) return;
726
- pendingReplays.delete(loaderId);
727
- replacedReplays.add(loaderId);
728
- for (const values of pending) removeReplayedSlots(values, loaderId);
885
+ if (completed) return;
886
+ const hole = isHole(loaderId);
887
+ if (
888
+ !pendingPlaceholders.has(loaderId) &&
889
+ !(hole && pendingReplays.has(loaderId))
890
+ ) {
891
+ return;
892
+ }
893
+ dropCopies(loaderId, hole);
729
894
  version++;
730
895
  signalEmission();
731
896
  },
732
897
 
733
- redeliverReplays(loaderId: string, deliver: () => void): void {
734
- const pending = pendingReplays.get(loaderId);
735
- if (!pending || completed || !isHole(loaderId)) {
898
+ replacePlaceholders(
899
+ loaderIds: readonly string[],
900
+ deliver: () => void,
901
+ ): void {
902
+ // One pass per array over every loader's placeholders: an array two of
903
+ // them share gets one anchor, the first removed position.
904
+ let given: Set<string> | undefined;
905
+ let arrays: Set<unknown[]> | undefined;
906
+ if (!completed) {
907
+ for (const loaderId of loaderIds) {
908
+ const pending = pendingPlaceholders.get(loaderId);
909
+ if (!pending) continue;
910
+ pendingPlaceholders.delete(loaderId);
911
+ (given ??= new Set()).add(loaderId);
912
+ arrays ??= new Set();
913
+ for (const values of pending) arrays.add(values);
914
+ }
915
+ }
916
+ if (!given || !arrays) {
736
917
  deliver();
737
918
  return;
738
919
  }
739
- pendingReplays.delete(loaderId);
920
+ const owners = given;
921
+ const ofGiven = (tag: SlotTag): boolean =>
922
+ tag.placeholderOf !== undefined && owners.has(tag.placeholderOf);
740
923
  const anchors = new Map<unknown[], number>();
741
- for (const values of pending) {
742
- const first = removeReplayedSlots(values, loaderId);
924
+ for (const values of arrays) {
925
+ const first = removeSlots(values, ofGiven);
743
926
  if (first !== undefined) anchors.set(values, first);
744
927
  }
745
928
  const outer = redelivery;
@@ -758,6 +941,23 @@ export function createHandleStore(): HandleStore {
758
941
  return this.settled.then(() => cloneHandleData(data));
759
942
  },
760
943
 
944
+ freezeDocumentSnapshot(): void {
945
+ if (documentSnapshot) return;
946
+ if (process.env.NODE_ENV !== "production" && documentStreamRead) {
947
+ const error = new Error(
948
+ "HandleStore.freezeDocumentSnapshot() ran after the document's " +
949
+ 'handle stream (stream("settled")) was first read. That stream ' +
950
+ "keeps reading the live store, so the document hydrates with " +
951
+ "whatever a loader pushed by then instead of the frozen state " +
952
+ "(issue #1035). Freeze before the payload that carries the " +
953
+ "stream starts rendering.",
954
+ );
955
+ if (this.onError) this.onError(error);
956
+ else console.error(error);
957
+ }
958
+ documentSnapshot = { version, data: cloneHandleData(data) };
959
+ },
960
+
761
961
  async *stream(
762
962
  until: "settled" | "fullySettled" = "fullySettled",
763
963
  ): AsyncGenerator<HandleData, void, unknown> {
@@ -768,6 +968,22 @@ export function createHandleStore(): HandleStore {
768
968
  // then, so the completed flag must be armed here.
769
969
  notifyDrain();
770
970
 
971
+ if (until === "settled") {
972
+ // A frozen document lane cannot change, so it is served at once:
973
+ // SSR and the pre-hydration drain block on this stream, and the
974
+ // batching hop below would cost every shell HIT a timer tick. It is
975
+ // yielded as the frozen object itself, already a copy: the lane's
976
+ // one consumer (resolvedHandleStream) builds a new object from a
977
+ // yield and never writes to it.
978
+ if (documentSnapshot) {
979
+ if (Object.keys(documentSnapshot.data).length > 0) {
980
+ yield documentSnapshot.data;
981
+ }
982
+ return;
983
+ }
984
+ documentStreamRead = true;
985
+ }
986
+
771
987
  // Per-consumer termination flag driven by the chosen barrier. The
772
988
  // global `completed` (late-push guard) always keys on FULL drain via
773
989
  // notifyDrain — a "settled"-scoped consumer finishing does not make
@@ -806,8 +1022,9 @@ export function createHandleStore(): HandleStore {
806
1022
  // state. This channel exists only for pushes that land AFTER `settled`
807
1023
  // — streaming loader bodies writing meta/breadcrumbs mid-body.
808
1024
  await this.settled;
809
- let seen = version;
810
- if (auxInflightCount === 0) {
1025
+ // A frozen document snapshot carried nothing newer than its version.
1026
+ let seen = documentSnapshot?.version ?? version;
1027
+ if (auxInflightCount === 0 && version === seen) {
811
1028
  // Auxiliary lane already drained: every loader push (if any) beat the
812
1029
  // handler barrier and rode the normal snapshot. Nothing late follows —
813
1030
  // handler pushes after settled are illegal by contract.