@rangojs/router 0.5.2 → 0.6.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 (173) hide show
  1. package/dist/bin/rango.js +343 -125
  2. package/dist/types/browser/react/use-router.d.ts +10 -3
  3. package/dist/types/browser/react/use-search-params.d.ts +57 -10
  4. package/dist/types/browser/types.d.ts +22 -0
  5. package/dist/types/build/merge-full-manifests.d.ts +3 -0
  6. package/dist/types/build/route-trie.d.ts +4 -73
  7. package/dist/types/build/route-types/per-module-writer.d.ts +6 -4
  8. package/dist/types/build/route-types/router-processing.d.ts +2 -3
  9. package/dist/types/cache/cache-exec-scope.d.ts +31 -0
  10. package/dist/types/cache/taint.d.ts +12 -6
  11. package/dist/types/client-urls/client-root.d.ts +38 -0
  12. package/dist/types/client-urls/client-urls.d.ts +5 -0
  13. package/dist/types/client-urls/navigation.d.ts +38 -0
  14. package/dist/types/client-urls/revalidation-protocol.d.ts +25 -0
  15. package/dist/types/client-urls/server-projection.d.ts +62 -0
  16. package/dist/types/client-urls/types.d.ts +144 -0
  17. package/dist/types/client.d.ts +12 -4
  18. package/dist/types/client.rsc.d.ts +4 -1
  19. package/dist/types/decode-loader-results.d.ts +37 -0
  20. package/dist/types/errors.d.ts +1 -0
  21. package/dist/types/index.d.ts +1 -1
  22. package/dist/types/loader-redirect.d.ts +27 -0
  23. package/dist/types/outlet-context.d.ts +12 -0
  24. package/dist/types/outlet-provider.d.ts +3 -1
  25. package/dist/types/redirect-origin.d.ts +4 -0
  26. package/dist/types/route-content-wrapper.d.ts +42 -1
  27. package/dist/types/route-definition/helpers-types.d.ts +13 -2
  28. package/dist/types/router/error-handling.d.ts +35 -1
  29. package/dist/types/router/intercept-resolution.d.ts +12 -0
  30. package/dist/types/router/loader-resolution.d.ts +24 -2
  31. package/dist/types/router/revalidation.d.ts +7 -0
  32. package/dist/types/router/route-trie-builder.d.ts +77 -0
  33. package/dist/types/router/router-interfaces.d.ts +20 -0
  34. package/dist/types/router/segment-resolution/helpers.d.ts +1 -1
  35. package/dist/types/router/trie-matching.d.ts +1 -1
  36. package/dist/types/rsc/manifest-init.d.ts +5 -5
  37. package/dist/types/rsc/shell-capture.d.ts +9 -0
  38. package/dist/types/rsc/shell-serve.d.ts +11 -0
  39. package/dist/types/rsc/types.d.ts +30 -0
  40. package/dist/types/segment-system.d.ts +2 -0
  41. package/dist/types/server/context.d.ts +10 -0
  42. package/dist/types/server/handle-store.d.ts +34 -3
  43. package/dist/types/server/request-context.d.ts +11 -1
  44. package/dist/types/server.d.ts +1 -0
  45. package/dist/types/ssr/index.d.ts +22 -0
  46. package/dist/types/ssr/ssr-root.d.ts +10 -0
  47. package/dist/types/testing/dom.entry.d.ts +1 -1
  48. package/dist/types/testing/render-route.d.ts +16 -6
  49. package/dist/types/testing/run-loader.d.ts +9 -0
  50. package/dist/types/types/boundaries.d.ts +22 -0
  51. package/dist/types/types/index.d.ts +1 -1
  52. package/dist/types/types/loader-types.d.ts +57 -5
  53. package/dist/types/types/segments.d.ts +7 -0
  54. package/dist/types/urls/path-helper-types.d.ts +10 -4
  55. package/dist/types/vite/discovery/client-urls-projection.d.ts +53 -0
  56. package/dist/types/vite/discovery/discover-routers.d.ts +1 -1
  57. package/dist/types/vite/discovery/state.d.ts +8 -1
  58. package/dist/vite/index.js +5313 -2365
  59. package/package.json +1 -1
  60. package/skills/breadcrumbs/SKILL.md +39 -9
  61. package/skills/catalog.json +7 -1
  62. package/skills/client-urls/SKILL.md +338 -0
  63. package/skills/comparison/references/framework-comparison.md +23 -9
  64. package/skills/hooks/SKILL.md +2 -2
  65. package/skills/hooks/data.md +11 -2
  66. package/skills/hooks/handle-and-actions.md +7 -0
  67. package/skills/hooks/outlets.md +26 -5
  68. package/skills/hooks/urls.md +40 -3
  69. package/skills/loader/SKILL.md +132 -20
  70. package/skills/migrate-nextjs/SKILL.md +70 -10
  71. package/skills/migrate-react-router/SKILL.md +49 -13
  72. package/skills/migrate-react-router/component-migration.md +18 -13
  73. package/skills/migrate-react-router/data-and-actions.md +14 -3
  74. package/skills/migrate-react-router/route-mapping.md +15 -2
  75. package/skills/parallel/SKILL.md +32 -1
  76. package/skills/ppr/SKILL.md +16 -6
  77. package/skills/prerender/SKILL.md +8 -4
  78. package/skills/rango/SKILL.md +21 -17
  79. package/skills/react-compiler/SKILL.md +3 -3
  80. package/skills/route/SKILL.md +5 -2
  81. package/skills/router-setup/SKILL.md +16 -2
  82. package/skills/scripts/SKILL.md +16 -6
  83. package/skills/shell-manifest/SKILL.md +16 -7
  84. package/skills/testing/SKILL.md +2 -2
  85. package/skills/testing/client-components.md +6 -0
  86. package/skills/testing/handles.md +30 -8
  87. package/skills/testing/loader.md +51 -49
  88. package/skills/testing/middleware.md +1 -1
  89. package/skills/theme/SKILL.md +8 -5
  90. package/src/bin/rango.ts +7 -3
  91. package/src/browser/navigation-bridge.ts +6 -0
  92. package/src/browser/navigation-client.ts +5 -0
  93. package/src/browser/partial-update.ts +65 -13
  94. package/src/browser/react/use-router.ts +40 -11
  95. package/src/browser/react/use-search-params.ts +140 -17
  96. package/src/browser/rsc-router.tsx +59 -0
  97. package/src/browser/server-action-bridge.ts +26 -0
  98. package/src/browser/types.ts +22 -0
  99. package/src/build/merge-full-manifests.ts +161 -0
  100. package/src/build/route-trie.ts +9 -332
  101. package/src/build/route-types/include-resolution.ts +66 -11
  102. package/src/build/route-types/per-module-writer.ts +11 -6
  103. package/src/build/route-types/router-processing.ts +184 -153
  104. package/src/build/runtime-discovery.ts +23 -12
  105. package/src/cache/cache-exec-scope.ts +47 -0
  106. package/src/cache/cache-runtime.ts +24 -25
  107. package/src/cache/taint.ts +28 -9
  108. package/src/client-urls/client-root.tsx +168 -0
  109. package/src/client-urls/client-urls.ts +698 -0
  110. package/src/client-urls/navigation.ts +237 -0
  111. package/src/client-urls/revalidation-protocol.ts +56 -0
  112. package/src/client-urls/server-projection.ts +579 -0
  113. package/src/client-urls/types.ts +195 -0
  114. package/src/client.rsc.tsx +12 -0
  115. package/src/client.tsx +49 -6
  116. package/src/decode-loader-results.ts +113 -0
  117. package/src/errors.ts +14 -0
  118. package/src/handles/deferred-resolution.ts +14 -7
  119. package/src/index.ts +1 -0
  120. package/src/loader-redirect.tsx +64 -0
  121. package/src/outlet-context.ts +12 -0
  122. package/src/outlet-provider.tsx +15 -1
  123. package/src/redirect-origin.ts +29 -0
  124. package/src/route-content-wrapper.tsx +96 -3
  125. package/src/route-definition/dsl-helpers.ts +28 -3
  126. package/src/route-definition/helpers-types.ts +13 -0
  127. package/src/route-definition/redirect.ts +17 -18
  128. package/src/router/error-handling.ts +65 -11
  129. package/src/router/intercept-resolution.ts +29 -0
  130. package/src/router/loader-resolution.ts +261 -28
  131. package/src/router/match-result.ts +7 -0
  132. package/src/router/revalidation.ts +24 -11
  133. package/src/router/route-trie-builder.ts +334 -0
  134. package/src/router/router-interfaces.ts +38 -0
  135. package/src/router/segment-resolution/fresh.ts +47 -0
  136. package/src/router/segment-resolution/helpers.ts +9 -11
  137. package/src/router/segment-resolution/loader-cache.ts +14 -24
  138. package/src/router/segment-resolution/revalidation.ts +20 -1
  139. package/src/router/trie-matching.ts +3 -3
  140. package/src/router.ts +46 -1
  141. package/src/rsc/full-payload.ts +6 -0
  142. package/src/rsc/handler.ts +10 -7
  143. package/src/rsc/loader-fetch.ts +2 -2
  144. package/src/rsc/manifest-init.ts +28 -9
  145. package/src/rsc/rsc-rendering.ts +15 -1
  146. package/src/rsc/shell-capture.ts +12 -0
  147. package/src/rsc/shell-serve.ts +15 -2
  148. package/src/rsc/ssr-setup.ts +10 -1
  149. package/src/rsc/types.ts +31 -2
  150. package/src/segment-system.tsx +83 -26
  151. package/src/server/context.ts +10 -0
  152. package/src/server/cookie-store.ts +19 -19
  153. package/src/server/handle-store.ts +185 -48
  154. package/src/server/request-context.ts +30 -6
  155. package/src/server.ts +7 -0
  156. package/src/ssr/index.tsx +37 -2
  157. package/src/ssr/ssr-root.tsx +29 -2
  158. package/src/testing/dom.entry.ts +1 -1
  159. package/src/testing/render-route.tsx +22 -8
  160. package/src/testing/run-loader.ts +51 -13
  161. package/src/types/boundaries.ts +19 -0
  162. package/src/types/index.ts +1 -0
  163. package/src/types/loader-types.ts +60 -5
  164. package/src/types/segments.ts +7 -0
  165. package/src/urls/include-helper.ts +22 -4
  166. package/src/urls/path-helper-types.ts +14 -1
  167. package/src/use-loader.tsx +67 -6
  168. package/src/vite/discovery/client-urls-projection.ts +322 -0
  169. package/src/vite/discovery/discover-routers.ts +43 -17
  170. package/src/vite/discovery/state.ts +11 -1
  171. package/src/vite/discovery/virtual-module-codegen.ts +20 -0
  172. package/src/vite/plugins/virtual-entries.ts +12 -3
  173. package/src/vite/router-discovery.ts +163 -12
@@ -14,7 +14,7 @@ import {
14
14
  getCurrentLoaderBodyId,
15
15
  isInsideHandlerInvokedLoaderBody,
16
16
  } from "./context.js";
17
- import { INSIDE_CACHE_EXEC } from "../cache/taint.js";
17
+ import { isInsideCacheExecScope } from "../cache/cache-exec-scope.js";
18
18
 
19
19
  /**
20
20
  * A single cookie entry returned by get() and getAll().
@@ -65,7 +65,7 @@ export interface CookieStore {
65
65
  */
66
66
  export function cookies(): CookieStore {
67
67
  const ctx = getRequestContext();
68
- assertNotInsideCacheContext(ctx, "cookies");
68
+ assertNotInsideCacheContext("cookies");
69
69
  assertNotInsideShellCapture(ctx, "cookies");
70
70
  return createCookieStore(ctx);
71
71
  }
@@ -91,9 +91,10 @@ type HeadersIterator<T> = IterableIterator<T>;
91
91
 
92
92
  /**
93
93
  * Throw if called inside a cache boundary — either a "use cache" function
94
- * (`INSIDE_CACHE_EXEC` stamped on ctx by the cache runtime) or a `cache()`
95
- * DSL boundary (`isInsideCacheScope()` the render-store flag set while
96
- * resolving a `type: "cache"` route entry).
94
+ * (`isInsideCacheExecScope()` the AsyncLocalStorage scope the cache runtime
95
+ * enters around the cached body) or a `cache()` DSL boundary
96
+ * (`isInsideCacheScope()` the render-store flag set while resolving a
97
+ * `type: "cache"` route entry).
97
98
  *
98
99
  * Reading request-scoped data (cookies, headers) inside a cached scope
99
100
  * produces per-request values that are NOT reflected in the cache key, so
@@ -103,18 +104,17 @@ type HeadersIterator<T> = IterableIterator<T>;
103
104
  * here is baked into the shell exactly like a `"use cache"` return value is
104
105
  * baked into its cache entry.
105
106
  *
106
- * `isInsideCacheScope()` returns false inside loaders (loaders always run
107
- * fresh on every request, even on a cache hit), so reading cookies()/headers()
108
- * from a loader is allowed — loaders are the dynamic "holes" of a cached
109
- * document.
107
+ * Both checks are scoped so loaders stay exempt — loaders are the dynamic
108
+ * "holes" of a cached document and always run fresh. `isInsideCacheScope()`
109
+ * returns false inside loaders by construction; `isInsideCacheExecScope()`
110
+ * follows the cached body's own async chain, so a loader running in PARALLEL
111
+ * with a slow "use cache" fetch on the same request reads cookies() freely.
112
+ * (Scar: the previous INSIDE_CACHE_EXEC stamp on the shared RequestContext
113
+ * made exactly that parallel read throw for the cached fetch's whole
114
+ * execution window — see cache-exec-scope.ts.)
110
115
  */
111
- function assertNotInsideCacheContext(ctx: unknown, fnName: string): void {
112
- if (
113
- ctx !== null &&
114
- ctx !== undefined &&
115
- typeof ctx === "object" &&
116
- (INSIDE_CACHE_EXEC as symbol) in (ctx as Record<symbol, unknown>)
117
- ) {
116
+ function assertNotInsideCacheContext(fnName: string): void {
117
+ if (isInsideCacheExecScope()) {
118
118
  throw new Error(
119
119
  `${fnName}() cannot be called inside a "use cache" function. ` +
120
120
  `Request-scoped data (cookies, headers) varies per request but is not ` +
@@ -222,7 +222,7 @@ const HEADERS_MUTATION_METHODS = new Set(["set", "append", "delete"]);
222
222
  */
223
223
  export function headers(): ReadonlyHeaders {
224
224
  const ctx = getRequestContext();
225
- assertNotInsideCacheContext(ctx, "headers");
225
+ assertNotInsideCacheContext("headers");
226
226
  assertNotInsideShellCapture(ctx, "headers");
227
227
  return new Proxy(ctx.request.headers, {
228
228
  get(target, prop, receiver) {
@@ -262,7 +262,7 @@ export function invalidateClientCache(): void {
262
262
  }
263
263
  return;
264
264
  }
265
- assertNotInsideCacheContext(ctx, "invalidateClientCache");
265
+ assertNotInsideCacheContext("invalidateClientCache");
266
266
  ctx._rotateStateCookie();
267
267
  }
268
268
 
@@ -287,7 +287,7 @@ export function keepClientCache(): void {
287
287
  }
288
288
  return;
289
289
  }
290
- assertNotInsideCacheContext(ctx, "keepClientCache");
290
+ assertNotInsideCacheContext("keepClientCache");
291
291
  ctx._setKeepCacheDirective();
292
292
  }
293
293
 
@@ -92,6 +92,26 @@ export interface HandleStore {
92
92
  */
93
93
  onError?: (error: Error) => void;
94
94
 
95
+ /**
96
+ * Track a promise on the AUXILIARY (loader) settlement lane. Aux tracking
97
+ * keeps the store OPEN for pushes (a mid-body `ctx.handle(H)(...)` from a
98
+ * streaming loader is legal until this lane drains) WITHOUT joining
99
+ * `settled` — the handler barrier that rendered()-readers, the document
100
+ * handle snapshot, and prerender/cache collection wait on. Two lanes exist
101
+ * because loader bodies stream: joining them to `settled` would block SSR
102
+ * markup and hydration on the slowest loader (ssr-root.tsx and
103
+ * rsc-router.tsx both drain the document handle stream in blocking
104
+ * positions), and would self-deadlock any loader awaiting ctx.rendered().
105
+ */
106
+ trackAuxiliary<T>(promise: Promise<T>): Promise<T>;
107
+
108
+ /**
109
+ * Promise that resolves when the store is sealed AND BOTH lanes (handler +
110
+ * auxiliary) have drained. Late-push legality and default stream completion
111
+ * key on this, not on `settled`.
112
+ */
113
+ readonly fullySettled: Promise<void>;
114
+
95
115
  /**
96
116
  * Push handle data for a specific handle and segment.
97
117
  * Multiple pushes to the same handle/segment accumulate in an array.
@@ -101,16 +121,32 @@ export interface HandleStore {
101
121
 
102
122
  /**
103
123
  * Get all collected handle data after all handlers have settled.
104
- * Waits for `settled`, then returns the finalized data.
124
+ * Waits for `settled` (handler lane), then returns the data at that point.
105
125
  */
106
126
  getData(): Promise<HandleData>;
107
127
 
108
128
  /**
109
129
  * Get an async iterator that yields handle data on each push.
110
- * The iterator completes when all handlers have settled.
130
+ * Completes at the chosen settlement barrier: "fullySettled" (default —
131
+ * nav/action payloads, whose consumer applies each yield progressively) or
132
+ * "settled" (the document lane, whose consumers drain to completion in
133
+ * blocking positions and must not wait on loader bodies).
111
134
  * Each yield contains the full accumulated state (not just the delta).
135
+ * Safe for concurrent consumers (per-consumer version cursor).
112
136
  */
113
- stream(): AsyncGenerator<HandleData, void, unknown>;
137
+ stream(
138
+ until?: "settled" | "fullySettled",
139
+ ): AsyncGenerator<HandleData, void, unknown>;
140
+
141
+ /**
142
+ * Document-lane late channel: yields full-state updates for pushes that
143
+ * land AFTER the handler barrier (streaming loader bodies), completing at
144
+ * fullySettled. Returns without yielding when the auxiliary lane is empty
145
+ * by the time `settled` resolves. The client consumes this post-hydration
146
+ * (rsc-router.tsx) and merges via the same application path as nav-lane
147
+ * progressive handle updates.
148
+ */
149
+ streamLate(): AsyncGenerator<HandleData, void, unknown>;
114
150
 
115
151
  /**
116
152
  * Get handle data for a specific segment (for caching).
@@ -151,20 +187,64 @@ export interface HandleStore {
151
187
  export function createHandleStore(): HandleStore {
152
188
  const data: HandleData = {};
153
189
 
154
- // Settlement barrier: resolved only when sealed AND inflight === 0.
155
- // seal() signals "no more track() calls". Each track() increments
156
- // inflightCount, each promise.finally() decrements. settled resolves
157
- // once both conditions are met even if tracks are added while
158
- // earlier ones are still in flight.
190
+ // Settlement barriers: `settled` (handler lane) resolves when sealed AND
191
+ // handler inflight === 0. `fullySettled` additionally waits for the
192
+ // auxiliary (loader) lane. seal() signals "no more track() calls"; each
193
+ // track/trackAuxiliary increments its lane's count, each promise settle
194
+ // decrements. Barriers resolve once their conditions are met even if
195
+ // tracks are added while earlier ones are still in flight.
159
196
  let sealed = false;
160
197
  let inflightCount = 0;
198
+ let auxInflightCount = 0;
161
199
  let drainWaiters: (() => void)[] = [];
200
+ let fullDrainWaiters: (() => void)[] = [];
201
+
202
+ // Swap the waiter list out before resolving: a resolver may synchronously
203
+ // re-await and push onto the fresh list, which must not be drained by this
204
+ // pass. All three waiter lists (drain, full-drain, emission) flush through
205
+ // here so that ordering rule cannot drift between them.
206
+ function flushWaiters(waiters: (() => void)[]): void {
207
+ for (const resolve of waiters) resolve();
208
+ }
209
+
210
+ // Both lanes settle identically — the handler lane (`track`) gates `settled`,
211
+ // the auxiliary loader lane (`trackAuxiliary`) gates `fullySettled`. Shared so
212
+ // neither copy can lose this rule: .then() rather than .finally(), because
213
+ // .finally() re-throws on a NEW branch, producing an unhandled rejection that
214
+ // can crash the process when the tracked promise rejects.
215
+ function trackSettlement<T>(
216
+ promise: Promise<T>,
217
+ release: () => void,
218
+ ): Promise<T> {
219
+ const onSettle = () => {
220
+ release();
221
+ notifyDrain();
222
+ };
223
+ promise.then(onSettle, onSettle);
224
+ return promise;
225
+ }
162
226
 
163
227
  function notifyDrain() {
164
228
  if (sealed && inflightCount === 0 && drainWaiters.length > 0) {
165
229
  const waiters = drainWaiters;
166
230
  drainWaiters = [];
167
- for (const resolve of waiters) resolve();
231
+ flushWaiters(waiters);
232
+ }
233
+ if (sealed && inflightCount === 0 && auxInflightCount === 0) {
234
+ if (streamConsumed && !completed) {
235
+ // Late-push guard + stream termination key on FULL drain, so a
236
+ // streaming loader body's ctx.handle() push stays legal until the
237
+ // auxiliary lane empties. Armed only once a stream consumer exists —
238
+ // getData()-only paths (prerender collection) keep their historical
239
+ // no-guard behavior.
240
+ completed = true;
241
+ signalEmission();
242
+ }
243
+ if (fullDrainWaiters.length > 0) {
244
+ const waiters = fullDrainWaiters;
245
+ fullDrainWaiters = [];
246
+ flushWaiters(waiters);
247
+ }
168
248
  }
169
249
  }
170
250
 
@@ -174,44 +254,50 @@ export function createHandleStore(): HandleStore {
174
254
  notifyDrain();
175
255
  }
176
256
 
177
- // Dirty flag for pending emissions and resolver for waiting consumer.
178
- // stream() only ever yields the latest full state, so we track a single
179
- // dirty bit and clone `data` once at yield time instead of per push.
180
- let hasPendingEmission = false;
181
- let emissionResolver: (() => void) | null = null;
257
+ // Emission versioning. Each push bumps `version`; every stream consumer
258
+ // keeps its own cursor and yields whenever the version advanced past it.
259
+ // Multi-consumer safe (the document "settled" stream and the late channel
260
+ // overlap until the handler barrier): a single dirty bit + single resolver
261
+ // slot would drop wakeups for all but one consumer.
262
+ let version = 0;
263
+ let emissionWaiters: (() => void)[] = [];
182
264
  let completed = false;
265
+ let streamConsumed = false;
183
266
 
184
- // Signal that a new emission is available
267
+ // Wake every waiting consumer (new push or a settlement barrier fired).
185
268
  function signalEmission() {
186
- if (emissionResolver) {
187
- const resolver = emissionResolver;
188
- emissionResolver = null;
189
- resolver();
269
+ if (emissionWaiters.length > 0) {
270
+ const waiters = emissionWaiters;
271
+ emissionWaiters = [];
272
+ flushWaiters(waiters);
190
273
  }
191
274
  }
192
275
 
193
276
  // Wait for the next emission or completion
194
- function waitForEmission(): Promise<void> {
195
- if (hasPendingEmission || completed) {
277
+ // Resolve when the consumer's cursor is behind the current version, the
278
+ // given done-flag reader reports termination, or a new signal arrives.
279
+ function waitForEmission(seen: number, isDone: () => boolean): Promise<void> {
280
+ if (version > seen || isDone()) {
196
281
  return Promise.resolve();
197
282
  }
198
283
  return new Promise((resolve) => {
199
- emissionResolver = resolve;
284
+ emissionWaiters.push(resolve);
200
285
  });
201
286
  }
202
287
 
203
288
  return {
204
289
  track<T>(promise: Promise<T>): Promise<T> {
205
290
  inflightCount++;
206
- // Use .then() instead of .finally() to avoid creating an unhandled rejection
207
- // branch when the promise rejects. .finally() re-throws on a new branch that
208
- // can crash the process if not caught.
209
- const onSettle = () => {
291
+ return trackSettlement(promise, () => {
210
292
  inflightCount--;
211
- notifyDrain();
212
- };
213
- promise.then(onSettle, onSettle);
214
- return promise;
293
+ });
294
+ },
295
+
296
+ trackAuxiliary<T>(promise: Promise<T>): Promise<T> {
297
+ auxInflightCount++;
298
+ return trackSettlement(promise, () => {
299
+ auxInflightCount--;
300
+ });
215
301
  },
216
302
 
217
303
  seal() {
@@ -225,6 +311,15 @@ export function createHandleStore(): HandleStore {
225
311
  });
226
312
  },
227
313
 
314
+ get fullySettled(): Promise<void> {
315
+ if (sealed && inflightCount === 0 && auxInflightCount === 0) {
316
+ return Promise.resolve();
317
+ }
318
+ return new Promise<void>((resolve) => {
319
+ fullDrainWaiters.push(resolve);
320
+ });
321
+ },
322
+
228
323
  push(handleName: string, segmentId: string, value: unknown): void {
229
324
  if (completed) {
230
325
  const error = createLateHandlePushError(handleName, segmentId);
@@ -240,8 +335,8 @@ export function createHandleStore(): HandleStore {
240
335
  }
241
336
  data[handleName][segmentId].push(value);
242
337
 
243
- // Mark dirty; the actual snapshot is cloned once at yield time.
244
- hasPendingEmission = true;
338
+ // Bump the version; each consumer's cursor decides when to clone+yield.
339
+ version++;
245
340
  signalEmission();
246
341
  },
247
342
 
@@ -250,33 +345,75 @@ export function createHandleStore(): HandleStore {
250
345
  return this.settled.then(() => cloneHandleData(data));
251
346
  },
252
347
 
253
- async *stream(): AsyncGenerator<HandleData, void, unknown> {
348
+ async *stream(
349
+ until: "settled" | "fullySettled" = "fullySettled",
350
+ ): AsyncGenerator<HandleData, void, unknown> {
351
+ streamConsumed = true;
254
352
  sealInternal();
255
-
256
- this.settled.then(() => {
257
- completed = true;
353
+ // Re-evaluate: the store may have been sealed and drained BEFORE this
354
+ // consumer existed (seal() then stream()); sealInternal early-returns
355
+ // then, so the completed flag must be armed here.
356
+ notifyDrain();
357
+
358
+ // Per-consumer termination flag driven by the chosen barrier. The
359
+ // global `completed` (late-push guard) always keys on FULL drain via
360
+ // notifyDrain — a "settled"-scoped consumer finishing does not make
361
+ // later loader pushes illegal.
362
+ let done = false;
363
+ const barrier = until === "settled" ? this.settled : this.fullySettled;
364
+ barrier.then(() => {
365
+ done = true;
258
366
  signalEmission();
259
367
  });
260
368
 
261
369
  // Batch rapid synchronous pushes with initial delay
262
370
  await new Promise((resolve) => setTimeout(resolve, 0));
263
371
 
264
- if (Object.keys(data).length > 0) {
265
- hasPendingEmission = false;
266
- yield cloneHandleData(data);
372
+ let seen = 0;
373
+ while (true) {
374
+ if (version > seen && Object.keys(data).length > 0) {
375
+ seen = version;
376
+ yield cloneHandleData(data);
377
+ }
378
+ if (done) break;
379
+ await waitForEmission(seen, () => done);
267
380
  }
381
+ },
268
382
 
269
- while (!completed) {
270
- await waitForEmission();
383
+ async *streamLate(): AsyncGenerator<HandleData, void, unknown> {
384
+ streamConsumed = true;
385
+ sealInternal();
386
+ // Re-evaluate: the store may have been sealed and drained BEFORE this
387
+ // consumer existed (seal() then stream()); sealInternal early-returns
388
+ // then, so the completed flag must be armed here.
389
+ notifyDrain();
390
+
391
+ // Skip everything the handler-barrier snapshot already carried; the
392
+ // document consumers (SSR seed + pre-hydration drain) deliver that
393
+ // state. This channel exists only for pushes that land AFTER `settled`
394
+ // — streaming loader bodies writing meta/breadcrumbs mid-body.
395
+ await this.settled;
396
+ let seen = version;
397
+ if (auxInflightCount === 0) {
398
+ // Auxiliary lane already drained: every loader push (if any) beat the
399
+ // handler barrier and rode the normal snapshot. Nothing late follows —
400
+ // handler pushes after settled are illegal by contract.
401
+ return;
402
+ }
271
403
 
272
- if (hasPendingEmission) {
273
- hasPendingEmission = false;
404
+ let done = false;
405
+ this.fullySettled.then(() => {
406
+ done = true;
407
+ signalEmission();
408
+ });
409
+
410
+ while (true) {
411
+ if (version > seen) {
412
+ seen = version;
274
413
  yield cloneHandleData(data);
275
414
  }
276
- }
277
-
278
- if (hasPendingEmission) {
279
- yield cloneHandleData(data);
415
+ if (done) break;
416
+ await waitForEmission(seen, () => done);
280
417
  }
281
418
  },
282
419
 
@@ -303,7 +440,7 @@ export function createHandleStore(): HandleStore {
303
440
  // segment, not accumulate on top of data from a different route.
304
441
  data[handleName][segmentId] = [...segmentHandles[handleName]];
305
442
  }
306
- hasPendingEmission = true;
443
+ version++;
307
444
  signalEmission();
308
445
  },
309
446
  };
@@ -618,6 +618,17 @@ export interface RequestContext<
618
618
  */
619
619
  _handlerLoaderDeps?: Set<string>;
620
620
 
621
+ /**
622
+ * @internal Loader IDs ($$id) whose entries carry `awaitBeforeFlush`
623
+ * (loader(Def, { stream: "navigation" })): segment resolution awaits these
624
+ * before returning, so the render barrier cannot resolve until they settle.
625
+ * rendered() checks this set to fail fast — a flagged loader awaiting the
626
+ * barrier is a guaranteed cycle, not a race. Registered by resolveLoaders
627
+ * (fresh.ts) before loader kickoff; only ever populated on document renders
628
+ * (the flag is stamped per-isSSR at DSL evaluation).
629
+ */
630
+ _awaitBeforeFlushLoaderIds?: Set<string>;
631
+
621
632
  /**
622
633
  * @internal Cached HandleData snapshot built at barrier resolution time.
623
634
  * Avoids rebuilding the snapshot on every loader ctx.use(handle) call.
@@ -765,6 +776,7 @@ export type PublicRequestContext<
765
776
  | "_treeHasStreaming"
766
777
  | "_renderBarrierWaiters"
767
778
  | "_handlerLoaderDeps"
779
+ | "_awaitBeforeFlushLoaderIds"
768
780
  | "_renderBarrierHandleSnapshot"
769
781
  | "_renderBarrierGuardClosed"
770
782
  | "_reportBackgroundError"
@@ -1384,6 +1396,7 @@ export function wireRenderBarrier(
1384
1396
  ctx._renderBarrierHandleSnapshot = undefined;
1385
1397
  ctx._renderBarrierGuardClosed = undefined;
1386
1398
  ctx._handlerLoaderDeps = undefined;
1399
+ ctx._awaitBeforeFlushLoaderIds = undefined;
1387
1400
  ctx._treeHasStreaming = undefined;
1388
1401
 
1389
1402
  // Lazy allocation: only create the Promise when a loader calls rendered().
@@ -1626,12 +1639,23 @@ export function createUseFunction<TEnv>(
1626
1639
  env: ctx.env as any,
1627
1640
  waitUntil: ctx.waitUntil.bind(ctx),
1628
1641
  executionContext: ctx.executionContext,
1629
- get: ctx.get as any,
1630
- use: (<TDep, TDepParams = any>(
1631
- dep: LoaderDefinition<TDep, TDepParams>,
1632
- ): Promise<TDep> => {
1633
- return ctx.use(dep);
1634
- }) as LoaderContext["use"],
1642
+ get: ((keyOrVar: any) => {
1643
+ // Handle READS need the rendered() barrier, which this lane
1644
+ // (action/dispatch-invoked loaders) does not run. Fail with guidance
1645
+ // instead of returning a misleading empty collect.
1646
+ if (isHandle(keyOrVar)) {
1647
+ throw new Error(
1648
+ `ctx.get(handle) is only available in DSL loaders after ` +
1649
+ `"await ctx.rendered()". It cannot be used from request-context ` +
1650
+ `loaders or server actions.`,
1651
+ );
1652
+ }
1653
+ return (ctx.get as any)(keyOrVar);
1654
+ }) as any,
1655
+ // Pass items through: loaders delegate to the request ctx's loader
1656
+ // executor; a handle yields the ctx's push function (write parity —
1657
+ // ctx.use(Meta)({...}) works in this lane exactly like in a handler).
1658
+ use: ((item: any) => ctx.use(item)) as LoaderContext["use"],
1635
1659
  method: "GET",
1636
1660
  body: undefined,
1637
1661
  reverse: createReverseFunction(
package/src/server.ts CHANGED
@@ -47,3 +47,10 @@ export {
47
47
 
48
48
  // Component utilities (used internally for server/client boundary checks)
49
49
  export { isClientComponent, assertClientComponent } from "./component-utils.js";
50
+
51
+ // Client URL projection registry (Vite discovery/bootstrap bridge)
52
+ export {
53
+ clearClientUrlProjections,
54
+ setClientUrlProjection,
55
+ type ClientUrlProjection,
56
+ } from "./client-urls/server-projection.js";
package/src/ssr/index.tsx CHANGED
@@ -105,6 +105,15 @@ export interface SSRRenderOptions {
105
105
  * - `"allReady"` — await `stream.allReady` before returning.
106
106
  */
107
107
  streamMode?: import("../router/router-options.js").SSRStreamMode;
108
+
109
+ /**
110
+ * The live request's query string (`?`-prefixed or empty). Seeds the SSR
111
+ * navigation store location so `useSearchParams` carries real values
112
+ * during document renders. Absent on the build-time prerender pass —
113
+ * build shells capture bare pathnames and serve search-less requests
114
+ * only (runtime captures own the search variants, seeded per key).
115
+ */
116
+ search?: string;
108
117
  }
109
118
 
110
119
  /**
@@ -347,6 +356,13 @@ interface ShellCaptureOptions {
347
356
  quiesce: Promise<void>;
348
357
  /** Upper bound on how long to wait for `quiesce`. Default SHELL_CAPTURE_MAX_WAIT_MS. */
349
358
  maxWaitMs?: number;
359
+ /**
360
+ * The SHELL KEY's search string (`?`-prefixed, sorted, cache.searchParams
361
+ * filter applied — shellSearchSeed in rsc/shell-serve.ts), seeding the SSR
362
+ * store so static-part `useSearchParams` reads bake markup consistent with
363
+ * the shell's own key. MUST equal the resume pass's seed for the same key.
364
+ */
365
+ search?: string;
350
366
  }
351
367
 
352
368
  /**
@@ -368,6 +384,13 @@ interface ShellResumeOptions {
368
384
  postponed: string | null;
369
385
  /** Nonce for CSP. */
370
386
  nonce?: string;
387
+ /**
388
+ * The SHELL KEY's search string — same derivation as the capture pass
389
+ * (shellSearchSeed). A HIT shares the capture's key, so seeding the same
390
+ * string keeps the resume tree identical to the captured tree above the
391
+ * postponed holes.
392
+ */
393
+ search?: string;
371
394
  }
372
395
 
373
396
  /**
@@ -449,7 +472,7 @@ export function createSSRHandler<TEnv = unknown>(deps: SSRDependencies<TEnv>) {
449
472
  rscStream: ReadableStream<Uint8Array>,
450
473
  options?: SSRRenderOptions,
451
474
  ): Promise<ReadableStream<Uint8Array>> {
452
- const { nonce, formState, streamMode } = options ?? {};
475
+ const { nonce, formState, streamMode, search } = options ?? {};
453
476
 
454
477
  try {
455
478
  // Tee the stream:
@@ -461,6 +484,12 @@ export function createSSRHandler<TEnv = unknown>(deps: SSRDependencies<TEnv>) {
461
484
  createFromReadableStream,
462
485
  rscStream: rscStream1,
463
486
  nonce,
487
+ // Live fizz seeds the request's RAW search. The shell capture pass
488
+ // below and its resume twin seed the SHELL KEY's search instead
489
+ // (sorted, cache.searchParams filter applied — shellSearchSeed):
490
+ // same key => same seed, so the resume tree matches the captured
491
+ // tree while static-part search reads render what the key names.
492
+ search,
464
493
  });
465
494
 
466
495
  // Get bootstrap script content
@@ -562,6 +591,9 @@ export function createShellCaptureHandler<TEnv = unknown>(
562
591
  createFromReadableStream,
563
592
  rscStream,
564
593
  onPayloadSettled: settlePayload,
594
+ // The shell key's own search: static-part search reads render what
595
+ // the key names, and the resume pass seeds the identical string.
596
+ search: opts.search,
565
597
  });
566
598
 
567
599
  // Bootstrap load raced against the deadline. A load that never resolves
@@ -735,7 +767,7 @@ export function createShellResumeHandler<TEnv = unknown>(
735
767
  rscStream: ReadableStream<Uint8Array>,
736
768
  opts: ShellResumeOptions,
737
769
  ): Promise<ReadableStream<Uint8Array>> {
738
- const { postponed, nonce } = opts;
770
+ const { postponed, nonce, search } = opts;
739
771
 
740
772
  try {
741
773
  if (postponed === null) {
@@ -765,6 +797,9 @@ export function createShellResumeHandler<TEnv = unknown>(
765
797
  createFromReadableStream,
766
798
  rscStream: rscStream1,
767
799
  nonce,
800
+ // Same seed as the capture pass for this key: the resume tree must
801
+ // match the captured tree above the holes, search reads included.
802
+ search,
768
803
  });
769
804
 
770
805
  // EAGER injection (resume-only): the stored prelude — a complete document
@@ -63,11 +63,21 @@ async function consumeAsyncGenerator(
63
63
  */
64
64
  function createSsrEventController(opts: {
65
65
  pathname: string;
66
+ /**
67
+ * Query string seeding the store location. Live fizz passes the request's
68
+ * raw search; ppr capture/resume pass the SHELL KEY's search (sorted,
69
+ * cache.searchParams filter applied) so the shell renders what its key
70
+ * names and both passes agree byte-for-byte.
71
+ */
72
+ search?: string;
66
73
  params?: Record<string, string>;
67
74
  handleData?: HandleData;
68
75
  matched?: string[];
69
76
  }): EventController {
70
- const location = new URL(opts.pathname, "http://localhost");
77
+ const location = new URL(
78
+ `${opts.pathname}${opts.search ?? ""}`,
79
+ "http://localhost",
80
+ );
71
81
  let params = opts.params ?? {};
72
82
  const rawMatched = opts.matched ?? [];
73
83
  const handleState = {
@@ -142,6 +152,16 @@ export interface SsrRootOptions {
142
152
  * they postpone below the root.
143
153
  */
144
154
  onPayloadSettled?: () => void;
155
+ /**
156
+ * The query string seeding the SSR store location (`?`-prefixed or
157
+ * empty), so `useSearchParams`/`useNavigation` carry real values during
158
+ * document renders. Live fizz passes the request's raw search. The shell
159
+ * capture and resume passes pass the SHELL KEY's search (sorted,
160
+ * cache.searchParams filter applied — search is part of shell identity):
161
+ * a HIT shares the capture's key, so both passes seed the SAME string and
162
+ * the resume tree matches the captured tree above the postponed holes.
163
+ */
164
+ search?: string;
145
165
  }
146
166
 
147
167
  /**
@@ -162,7 +182,13 @@ export interface SsrRootOptions {
162
182
  * re-running the whole segment-tree build unless the promise is memoized.
163
183
  */
164
184
  export function createSsrRootComponent(opts: SsrRootOptions): React.FC {
165
- const { createFromReadableStream, rscStream, nonce, onPayloadSettled } = opts;
185
+ const {
186
+ createFromReadableStream,
187
+ rscStream,
188
+ nonce,
189
+ onPayloadSettled,
190
+ search,
191
+ } = opts;
166
192
 
167
193
  let payload: Promise<RscPayload> | undefined;
168
194
  let handlesPromise: Promise<HandleData> | undefined;
@@ -211,6 +237,7 @@ export function createSsrRootComponent(opts: SsrRootOptions): React.FC {
211
237
  store: null as any,
212
238
  eventController: createSsrEventController({
213
239
  pathname,
240
+ search,
214
241
  params: resolved.metadata?.params,
215
242
  handleData,
216
243
  matched: resolved.metadata?.matched,
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Component-render testing: `renderRoute`, the React-Testing-Library-style stub
5
5
  * for client components that read router context (useParams / useReverse /
6
- * Outlet / useNavigation / useLoader).
6
+ * Outlet / useOutlet / useNavigation / useLoader).
7
7
  *
8
8
  * Separate from the main `@rangojs/router/testing` barrel so unit suites that
9
9
  * only test loaders, middleware, or `dispatch` never reference React, the