@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
@@ -18,10 +18,28 @@ import type {
18
18
  ErrorBoundaryHandler,
19
19
  ErrorBoundaryFallbackProps,
20
20
  ErrorInfo,
21
+ NotFoundBoundaryHandler,
22
+ NotFoundBoundaryFallbackProps,
21
23
  } from "../types";
24
+ import { createElement } from "react";
25
+ import { isDataNotFoundError } from "../errors.js";
26
+ import { isRedirectResponse } from "../response-utils.js";
27
+ import {
28
+ resolveSoftRedirectUrl,
29
+ getRedirectState,
30
+ isExternalRedirect,
31
+ } from "../redirect-origin.js";
32
+ import {
33
+ createNotFoundInfo,
34
+ renderNotFoundFallback,
35
+ resolveNotFoundFallback,
36
+ } from "./error-handling.js";
22
37
  import { isHandle, collectHandleData, type Handle } from "../handle.js";
23
38
  import { withDefer } from "../defer.js";
24
- import { buildHandleSnapshot } from "../server/handle-store.js";
39
+ import {
40
+ buildHandleSnapshot,
41
+ type HandleStore,
42
+ } from "../server/handle-store.js";
25
43
  import { getFetchableLoader } from "../server/fetchable-loader-store.js";
26
44
  import { _getRequestContext } from "../server/request-context.js";
27
45
  import {
@@ -46,11 +64,66 @@ export type LoaderErrorCallback = (
46
64
  },
47
65
  ) => void;
48
66
 
67
+ /**
68
+ * Loader-thrown authority signals (notFound()/redirect()) are detected by the
69
+ * shape a consumer can actually throw: DataNotFoundError via the shared
70
+ * cross-realm-safe isDataNotFoundError, redirect by 3xx Response.
71
+ */
72
+
73
+ /**
74
+ * Deliver one `ctx.use(Handle)(value)` push to the owning segment.
75
+ *
76
+ * Shared by the handler lane (setupLoaderAccess) and the loader-body lane
77
+ * (createLoaderExecutor) so a loader push attributes and scopes EXACTLY like a
78
+ * handler push — that parity is the contract `ctx.use` in a loader promises.
79
+ *
80
+ * A function value runs inside the push-callback scope so `ctx.use(loader)`
81
+ * calls it makes — including after its own awaits, for an async callback — are
82
+ * not registered as handler-to-loader deps and do not trip the deadlock guard.
83
+ * A pushed promise value is not tracked by handleStore.settled and does not
84
+ * block segment resolution, so it cannot form a rendered() deadlock. The ALS
85
+ * scope (not a plain boolean) is what survives the callback's awaits.
86
+ */
87
+ function pushHandleValue(
88
+ store: HandleStore | undefined,
89
+ segmentId: string | undefined,
90
+ handleId: string,
91
+ dataOrFn: unknown | Promise<unknown> | (() => Promise<unknown>),
92
+ ): void {
93
+ if (!store || !segmentId) return;
94
+ if (typeof dataOrFn === "function") {
95
+ store.push(
96
+ handleId,
97
+ segmentId,
98
+ runInsidePushCallbackScope(() => (dataOrFn as () => Promise<unknown>)()),
99
+ );
100
+ return;
101
+ }
102
+ store.push(handleId, segmentId, dataOrFn);
103
+ }
104
+
49
105
  /**
50
106
  * Wrap a loader promise with error handling for deferred client-side resolution.
51
107
  * Catches errors and converts them to LoaderDataResult objects that include
52
108
  * error info and pre-rendered fallback UI when an error boundary is available.
53
109
  *
110
+ * Loader-thrown SIGNALS are routed before error handling — they are control
111
+ * flow, not failures (onError is not invoked for them):
112
+ * - notFound(): the not-found UI is resolved and rendered HERE, server-side
113
+ * (nearest notFoundBoundary → router notFound option → default), exactly
114
+ * like the consumption lane (segment-resolution/helpers.ts), and rides the
115
+ * envelope's `fallback` — the client swaps with zero extra fetches. The
116
+ * response status is opportunistically set to 404 via the request stub: if
117
+ * the rejection settles before the document Response is constructed the
118
+ * status is a real 404; after, the stub write is inert (streamed fix-up
119
+ * only). Nav-lane payloads are 200 either way — the client owns 404
120
+ * presentation there.
121
+ * - redirect() (thrown 3xx Response): the target is resolved through the
122
+ * soft-redirect same-origin rules NOW so unsafe targets never leave the
123
+ * server; the client navigates when the entry decodes. No document-lane
124
+ * 302 — pre-stream redirect authority belongs to middleware (doctrine);
125
+ * a loader redirect is always a client-side navigate.
126
+ *
54
127
  * @param onError - Optional callback invoked when loader errors occur.
55
128
  * This has a simplified signature for internal use - the caller (typically
56
129
  * wrapLoaderPromise in router.ts) is responsible for bridging to the full
@@ -70,6 +143,14 @@ export function wrapLoaderWithErrorHandling<T>(
70
143
  segmentType: ErrorInfo["segmentType"],
71
144
  ) => ErrorInfo,
72
145
  onError?: LoaderErrorCallback,
146
+ notFoundDeps?: {
147
+ findNearestNotFoundBoundary: (
148
+ entry: EntryData | null,
149
+ ) => ReactNode | NotFoundBoundaryHandler | null;
150
+ notFoundComponent?:
151
+ | ReactNode
152
+ | ((props: { pathname: string }) => ReactNode);
153
+ },
73
154
  ): Promise<LoaderDataResult<T>> {
74
155
  // Extract the trailing token from segmentId (format: "<shortCode>D<i>.<loaderId>").
75
156
  // The token is the loader's $$id (hash#export in prod, pathfrag#export in dev),
@@ -85,6 +166,92 @@ export function wrapLoaderWithErrorHandling<T>(
85
166
  }),
86
167
  )
87
168
  .catch((error): LoaderDataResult<T> => {
169
+ if (isDataNotFoundError(error)) {
170
+ // Opportunistic document 404: the stub status is read once when the
171
+ // document Response is constructed (createResponseWithMergedHeaders);
172
+ // a pre-construction write becomes the real status, a later write is
173
+ // harmless. Same mechanism as the consumption lane's setResponseStatus.
174
+ _getRequestContext()?._setStatus(404);
175
+
176
+ const effective = resolveNotFoundFallback(
177
+ notFoundDeps?.findNearestNotFoundBoundary(entry),
178
+ notFoundDeps?.notFoundComponent,
179
+ pathname,
180
+ );
181
+
182
+ const notFoundInfo = createNotFoundInfo(
183
+ error as { message: string },
184
+ segmentId,
185
+ "loader",
186
+ pathname,
187
+ );
188
+ let renderedNotFound: ReactNode;
189
+ try {
190
+ renderedNotFound = renderNotFoundFallback(effective, notFoundInfo);
191
+ } catch {
192
+ // A throwing boundary must not collapse the envelope (the wrapped
193
+ // promise is contracted to never reject); degrade to the default.
194
+ // Deliberately NOT shared with the consumption lane
195
+ // (segment-resolution/helpers.ts), which lets a throwing boundary
196
+ // propagate to the error boundary — only this lane is contracted to
197
+ // never reject.
198
+ renderedNotFound = createElement("h1", null, "Not Found");
199
+ }
200
+
201
+ debugLog("loader", "loader threw notFound()", {
202
+ segmentId,
203
+ pathname,
204
+ });
205
+
206
+ return {
207
+ __loaderResult: true,
208
+ ok: false,
209
+ notFound: true,
210
+ error: createErrorInfo(error, segmentId, "loader"),
211
+ fallback: renderedNotFound ?? null,
212
+ };
213
+ }
214
+
215
+ if (error instanceof Response && isRedirectResponse(error)) {
216
+ const reqCtx = _getRequestContext();
217
+ const requestOrigin = reqCtx?.originalUrl?.origin ?? reqCtx?.url.origin;
218
+ const location = error.headers.get("Location")!;
219
+ // Same-origin policy applied server-side; the wire never carries an
220
+ // unresolved target. redirect(url, { external: true })'s WeakSet brand
221
+ // survives (the thrown Response IS the branded object).
222
+ const to = requestOrigin
223
+ ? resolveSoftRedirectUrl(
224
+ location,
225
+ requestOrigin,
226
+ reqCtx?._basename,
227
+ isExternalRedirect(error),
228
+ )
229
+ : location;
230
+
231
+ debugLog("loader", "loader threw redirect()", {
232
+ segmentId,
233
+ to,
234
+ });
235
+
236
+ // redirect(url, { state }): the resolved state rides the thrown
237
+ // Response (attachRedirectState), NOT payload metadata — a streaming
238
+ // loader settles after the metadata flush, so the marker is the only
239
+ // carrier that reaches the client. LoaderRedirect navigates with it.
240
+ const redirectState = getRedirectState(error);
241
+
242
+ return {
243
+ __loaderResult: true,
244
+ ok: false,
245
+ redirect: { to, ...(redirectState && { state: redirectState }) },
246
+ error: createErrorInfo(
247
+ new Error(`Loader redirected to ${to}`),
248
+ segmentId,
249
+ "loader",
250
+ ),
251
+ fallback: null,
252
+ };
253
+ }
254
+
88
255
  // Find nearest error boundary
89
256
  const fallback = findNearestErrorBoundary(entry);
90
257
 
@@ -210,12 +377,23 @@ function detectLoaderCycle(
210
377
  function createLoaderExecutor<TEnv>(
211
378
  ctx: HandlerContext<any, TEnv>,
212
379
  loaderPromises: Map<string, Promise<any>>,
380
+ executorOptions?: {
381
+ /**
382
+ * Background proactive-caching mode: ctx.handle() pushes become no-ops
383
+ * (mirroring setupLoaderAccessSilent's handler-handle no-op) and loader
384
+ * bodies are NOT aux-tracked into the foreground handle store — a
385
+ * background run must neither duplicate handle data nor extend the live
386
+ * payload's handle-stream lifetime.
387
+ */
388
+ silentHandles?: boolean;
389
+ },
213
390
  ): (
214
391
  loader: LoaderDefinition<any, any>,
215
392
  callerLoaderId: string | null,
216
393
  ) => Promise<any> {
217
394
  // Capture RequestContext eagerly for cookie access (ALS protection on Cloudflare)
218
395
  const reqCtxRef = _getRequestContext();
396
+ const silentHandles = executorOptions?.silentHandles === true;
219
397
 
220
398
  // Dependency graph: loaderId -> set of loader IDs it directly depends on.
221
399
  const dependsOn = new Map<string, Set<string>>();
@@ -281,6 +459,16 @@ function createLoaderExecutor<TEnv>(
281
459
  // inside loader scope. This determines whether rendered() is allowed.
282
460
  const isDslLoader = isInsideLoaderScope();
283
461
 
462
+ // Owning segment for ctx.handle() attribution, captured SYNCHRONOUSLY at
463
+ // kickoff: segment resolution sets _currentSegmentId while resolving the
464
+ // segment that declares this loader (fresh.ts), and the value moves on as
465
+ // resolution continues — by the time the async body pushes, it would be
466
+ // stale. Attributing to the owning route/layout segment gives handle
467
+ // pushes the same granularity handler pushes have (segmentOrder-correct
468
+ // for breadcrumb accumulation).
469
+ const owningSegmentId = (ctx as InternalHandlerContext<any, TEnv>)
470
+ ._currentSegmentId;
471
+
284
472
  let renderedResolved = false;
285
473
  let renderedPromise: Promise<void> | null = null;
286
474
 
@@ -301,20 +489,20 @@ function createLoaderExecutor<TEnv>(
301
489
  env: ctx.env,
302
490
  waitUntil: ctx.waitUntil.bind(ctx),
303
491
  executionContext: ctx.executionContext,
304
- get: ((keyOrVar: any) =>
305
- contextGet(variables, keyOrVar)) as typeof ctx.get,
306
- use: ((item: LoaderDefinition<any, any> | Handle<any, any>) => {
307
- if (isHandle(item)) {
492
+ get: ((keyOrVar: any) => {
493
+ // Handle READ (moved from ctx.use(handle)): collected data, available
494
+ // after `await ctx.rendered()` the rendered-barrier contract.
495
+ if (isHandle(keyOrVar)) {
308
496
  if (!renderedResolved) {
309
497
  throw new Error(
310
- `ctx.use(handle) in a loader requires "await ctx.rendered()" first. ` +
311
- `Handle "${item.$$id}" cannot be read until the render tree has settled.`,
498
+ `ctx.get(handle) in a loader requires "await ctx.rendered()" first. ` +
499
+ `Handle "${keyOrVar.$$id}" cannot be read until the render tree has settled.`,
312
500
  );
313
501
  }
314
502
  const reqCtx = reqCtxRef ?? _getRequestContext();
315
503
  if (!reqCtx) {
316
504
  throw new Error(
317
- `ctx.use(handle) failed: request context not available.`,
505
+ `ctx.get(handle) failed: request context not available.`,
318
506
  );
319
507
  }
320
508
  const segmentOrder = reqCtx._renderBarrierSegmentOrder ?? [];
@@ -327,7 +515,35 @@ function createLoaderExecutor<TEnv>(
327
515
  const snapshot =
328
516
  reqCtx._renderBarrierHandleSnapshot ??
329
517
  buildHandleSnapshot(reqCtx._handleStore, segmentOrder);
330
- return collectHandleData(item, snapshot, segmentOrder);
518
+ return collectHandleData(keyOrVar, snapshot, segmentOrder);
519
+ }
520
+ return contextGet(variables, keyOrVar);
521
+ }) as typeof ctx.get,
522
+ use: ((item: LoaderDefinition<any, any> | Handle<any, any>) => {
523
+ if (isHandle(item)) {
524
+ // Handle WRITE — handler parity: `ctx.use(Meta)({...})` pushes from
525
+ // the loader body exactly like a handler push. Reads moved to
526
+ // ctx.get(handle) (rendered()-gated). Pushes attribute to the OWNING
527
+ // segment and are legal for the whole body: the body is tracked on
528
+ // the handle store's auxiliary lane, so the store stays open past
529
+ // the handler barrier. Delivery follows the race: pushes that beat
530
+ // the handler barrier ride the SSR snapshot, later ones stream via
531
+ // metadata.handlesLate (document) or the still-open handles
532
+ // generator (nav/action lanes).
533
+ const handleDef = item;
534
+ if (silentHandles) {
535
+ return withDefer((_dataOrFn: unknown) => {});
536
+ }
537
+ const store =
538
+ reqCtxRef?._handleStore ?? _getRequestContext()?._handleStore;
539
+ const segmentId = owningSegmentId;
540
+ return withDefer(
541
+ (
542
+ dataOrFn: unknown | Promise<unknown> | (() => Promise<unknown>),
543
+ ) => {
544
+ pushHandleValue(store, segmentId, handleDef.$$id, dataOrFn);
545
+ },
546
+ );
331
547
  }
332
548
 
333
549
  // Loader case
@@ -355,6 +571,24 @@ function createLoaderExecutor<TEnv>(
355
571
  );
356
572
  }
357
573
 
574
+ // awaitBeforeFlush cycle: segment resolution awaits this loader
575
+ // (loader(Def, { stream: "navigation" })), the barrier awaits segment
576
+ // resolution, and rendered() awaits the barrier — waiting here can
577
+ // never complete. Fail fast with the cause instead of hanging the
578
+ // document render. Only document renders populate the set (the flag is
579
+ // stamped per-isSSR), so the same loader may still use rendered() on
580
+ // navigation renders.
581
+ if (reqCtx._awaitBeforeFlushLoaderIds?.has(currentLoaderId)) {
582
+ throw new Error(
583
+ `Deadlock: loader "${currentLoaderId}" is registered with ` +
584
+ `stream: "navigation", so the document render awaits it before ` +
585
+ `the render barrier resolves — ctx.rendered() (and the ` +
586
+ `ctx.get(handle) read it gates) can never settle here. Drop ` +
587
+ `stream: "navigation" on this loader or move the handle read to ` +
588
+ `a component.`,
589
+ );
590
+ }
591
+
358
592
  // Bidirectional deadlock check: if a handler already started
359
593
  // awaiting this loader, calling rendered() would deadlock. This is the
360
594
  // real cycle guard (it holds for both streaming and non-streaming): the
@@ -458,6 +692,20 @@ function createLoaderExecutor<TEnv>(
458
692
  }),
459
693
  );
460
694
 
695
+ // Auxiliary-lane tracking: keeps the handle store open for this body's
696
+ // ctx.handle() pushes without joining `settled` (the handler barrier that
697
+ // gates SSR/hydration and rendered()-readers — joining it would block the
698
+ // document on the slowest loader and self-deadlock rendered() callers).
699
+ // DSL loaders only: handler-invoked loaders are awaited by their handler,
700
+ // so they complete inside the handler lane already. A nested DSL kickoff
701
+ // mid-body registers while its parent is still tracked, so the lane can
702
+ // never re-open after draining.
703
+ if (isDslLoader && !silentHandles) {
704
+ (reqCtxRef ?? _getRequestContext())?._handleStore?.trackAuxiliary(
705
+ promise,
706
+ );
707
+ }
708
+
461
709
  loaderPromises.set(loader.$$id, promise);
462
710
  return promise;
463
711
  }
@@ -504,24 +752,7 @@ export function setupLoaderAccess<TEnv>(
504
752
 
505
753
  return withDefer(
506
754
  (dataOrFn: unknown | Promise<unknown> | (() => Promise<unknown>)) => {
507
- if (!store) return;
508
-
509
- if (typeof dataOrFn === "function") {
510
- // Run the callback inside the push-callback scope so ctx.use(loader)
511
- // calls it makes — including after its own awaits, for an async
512
- // callback — are not registered as handler-to-loader deps and do not
513
- // trip the deadlock guard. A pushed promise value is not tracked by
514
- // handleStore.settled and does not block segment resolution, so it
515
- // cannot form a rendered() deadlock. The ALS scope (not a plain
516
- // boolean) is what survives the callback's awaits.
517
- const result = runInsidePushCallbackScope(() =>
518
- (dataOrFn as () => Promise<unknown>)(),
519
- );
520
- store.push(handle.$$id, segmentId, result);
521
- return;
522
- }
523
-
524
- store.push(handle.$$id, segmentId, dataOrFn);
755
+ pushHandleValue(store, segmentId, handle.$$id, dataOrFn);
525
756
  },
526
757
  );
527
758
  }
@@ -632,7 +863,9 @@ export function setupLoaderAccessSilent<TEnv>(
632
863
  ctx: HandlerContext<any, TEnv>,
633
864
  loaderPromises: Map<string, Promise<any>>,
634
865
  ): void {
635
- const useLoader = createLoaderExecutor(ctx, loaderPromises);
866
+ const useLoader = createLoaderExecutor(ctx, loaderPromises, {
867
+ silentHandles: true,
868
+ });
636
869
 
637
870
  ctx.use = ((item: LoaderDefinition<any, any> | Handle<any, any>) => {
638
871
  if (isHandle(item)) {
@@ -111,6 +111,7 @@ import type { MatchResult, ResolvedSegment } from "../types.js";
111
111
  import type { MatchContext, MatchPipelineState } from "./match-context.js";
112
112
  import { debugLog } from "./logging.js";
113
113
  import { appendMetric } from "./metrics.js";
114
+ import { collectInterceptTargetNames } from "./intercept-resolution.js";
114
115
 
115
116
  export async function collectSegments(
116
117
  generator: AsyncGenerator<ResolvedSegment>,
@@ -266,6 +267,11 @@ export function buildMatchResult<TEnv>(
266
267
  diff.push(s.id);
267
268
  }
268
269
 
270
+ // Intercept targets reachable when THIS location becomes a navigation
271
+ // origin — the browser-local clientUrls matcher declines its optimistic
272
+ // presentation for these (see collectInterceptTargetNames).
273
+ const interceptTargets = collectInterceptTargetNames(ctx.manifestEntry);
274
+
269
275
  return {
270
276
  segments: cleanedSegments,
271
277
  matched: matchedIds,
@@ -274,6 +280,7 @@ export function buildMatchResult<TEnv>(
274
280
  params: ctx.matched.params,
275
281
  routeName: ctx.routeKey,
276
282
  slots: Object.keys(state.slots).length > 0 ? state.slots : undefined,
283
+ ...(interceptTargets.length > 0 ? { interceptTargets } : {}),
277
284
  routeMiddleware:
278
285
  ctx.routeMiddleware.length > 0 ? ctx.routeMiddleware : undefined,
279
286
  };
@@ -96,8 +96,14 @@ interface EvaluateRevalidationOptions<TEnv> {
96
96
  * reason flows into the trace. Callers use this when client-knowledge
97
97
  * (e.g. parallel slot not in clientSegmentIds) should dictate the seed
98
98
  * instead of the params/method-based heuristic.
99
+ *
100
+ * `floor` makes a `true` seed a guarantee rather than a suggestion: user fns
101
+ * may raise the decision but never lower it. Set it when the segment has no
102
+ * client-side content to fall back on, so a `false` would render nothing
103
+ * instead of keeping a cached copy — see the parallel-slot call site in
104
+ * segment-resolution/revalidation.ts.
99
105
  */
100
- defaultOverride?: { value: boolean; reason: string };
106
+ defaultOverride?: { value: boolean; reason: string; floor?: boolean };
101
107
  }
102
108
 
103
109
  /**
@@ -144,6 +150,20 @@ export async function evaluateRevalidation<TEnv>(
144
150
  });
145
151
  }
146
152
 
153
+ // A floored seed can only be raised, never lowered (see `defaultOverride`).
154
+ // Applied at every exit so one trace entry carries the real decision — a
155
+ // second, contradicting entry would let flushRevalidationTrace count the
156
+ // segment as both revalidated and skipped.
157
+ const floorTrue = defaultOverride?.floor === true && defaultOverride.value;
158
+ function decide(finalVal: boolean, reason: string): boolean {
159
+ if (floorTrue && !finalVal) {
160
+ pushTrace(defaultShouldRevalidate, true, `${reason}:floored`);
161
+ return true;
162
+ }
163
+ pushTrace(defaultShouldRevalidate, finalVal, reason);
164
+ return finalVal;
165
+ }
166
+
147
167
  let defaultShouldRevalidate: boolean;
148
168
  let defaultReason: string;
149
169
 
@@ -213,8 +233,7 @@ export async function evaluateRevalidation<TEnv>(
213
233
  segmentId: segment.id,
214
234
  });
215
235
  }
216
- pushTrace(defaultShouldRevalidate, defaultShouldRevalidate, defaultReason);
217
- return defaultShouldRevalidate;
236
+ return decide(defaultShouldRevalidate, defaultReason);
218
237
  }
219
238
 
220
239
  const prevSegment = getPrevSegment ? await getPrevSegment() : null;
@@ -303,8 +322,7 @@ export async function evaluateRevalidation<TEnv>(
303
322
  revalidator: name,
304
323
  revalidate: result,
305
324
  });
306
- pushTrace(defaultShouldRevalidate, result, `hard:${name}`);
307
- return result;
325
+ return decide(result, `hard:${name}`);
308
326
  } else if (
309
327
  result &&
310
328
  typeof result === "object" &&
@@ -330,10 +348,5 @@ export async function evaluateRevalidation<TEnv>(
330
348
  revalidate: currentSuggestion,
331
349
  });
332
350
  const softNames = revalidations.map((r) => r.name).join(",");
333
- pushTrace(
334
- defaultShouldRevalidate,
335
- currentSuggestion,
336
- `soft-chain:${softNames}`,
337
- );
338
- return currentSuggestion;
351
+ return decide(currentSuggestion, `soft-chain:${softNames}`);
339
352
  }