@rangojs/router 0.28.0 → 0.29.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 (79) hide show
  1. package/dist/bin/rango.js +1 -1
  2. package/dist/types/browser/action-fence.d.ts +2 -1
  3. package/dist/types/browser/event-controller.d.ts +6 -1
  4. package/dist/types/browser/partial-update.d.ts +11 -0
  5. package/dist/types/browser/react/use-action.d.ts +7 -2
  6. package/dist/types/browser/react/use-handle.d.ts +19 -16
  7. package/dist/types/browser/react/use-link-status.d.ts +5 -2
  8. package/dist/types/browser/segment-reconciler.d.ts +26 -0
  9. package/dist/types/browser/types.d.ts +22 -4
  10. package/dist/types/browser/wire-protocol.d.ts +1 -0
  11. package/dist/types/cache/tag-invalidation.d.ts +0 -10
  12. package/dist/types/router/error-handling.d.ts +7 -1
  13. package/dist/types/router/match-context.d.ts +2 -0
  14. package/dist/types/router/match-middleware/background-revalidation.d.ts +3 -2
  15. package/dist/types/router/navigation-snapshot.d.ts +7 -0
  16. package/dist/types/router/segment-resolution/helpers.d.ts +19 -3
  17. package/dist/types/router/segment-resolution/resolution-pass.d.ts +2 -0
  18. package/dist/types/router/segment-resolution/streamed-handler-boundary.d.ts +2 -0
  19. package/dist/types/router/types.d.ts +1 -1
  20. package/dist/types/server/request-context.d.ts +30 -1
  21. package/dist/types/testing/render-route.d.ts +43 -8
  22. package/dist/types/testing/serve-shell-request.d.ts +8 -1
  23. package/dist/types/types/error-types.d.ts +27 -8
  24. package/dist/types/wire-params.d.ts +5 -0
  25. package/dist/vite/index.js +15 -19
  26. package/package.json +1 -1
  27. package/skills/client-urls/SKILL.md +4 -2
  28. package/skills/hooks/SKILL.md +1 -1
  29. package/skills/hooks/data.md +8 -1
  30. package/skills/hooks/handle-and-actions.md +33 -2
  31. package/skills/hooks/navigation.md +121 -15
  32. package/skills/hooks/state.md +6 -4
  33. package/skills/hooks/urls.md +16 -5
  34. package/skills/intercept/SKILL.md +6 -0
  35. package/skills/observability/SKILL.md +30 -0
  36. package/skills/server-actions/SKILL.md +8 -1
  37. package/skills/testing/client-components.md +25 -18
  38. package/skills/testing/serve-shell-request.md +7 -7
  39. package/skills/view-transitions/SKILL.md +1 -1
  40. package/src/browser/action-fence.ts +2 -1
  41. package/src/browser/event-controller.ts +32 -10
  42. package/src/browser/navigation-bridge.ts +45 -13
  43. package/src/browser/navigation-client.ts +2 -0
  44. package/src/browser/network-error-handler.ts +4 -0
  45. package/src/browser/partial-update.ts +33 -6
  46. package/src/browser/react/use-action.ts +24 -48
  47. package/src/browser/react/use-handle.ts +22 -18
  48. package/src/browser/react/use-link-status.ts +48 -60
  49. package/src/browser/react/use-navigation.ts +92 -41
  50. package/src/browser/segment-reconciler.ts +43 -0
  51. package/src/browser/server-action-bridge.ts +35 -8
  52. package/src/browser/types.ts +23 -4
  53. package/src/browser/wire-protocol.ts +2 -0
  54. package/src/cache/cache-runtime.ts +28 -31
  55. package/src/cache/document-cache.ts +34 -37
  56. package/src/cache/read-through-swr.ts +20 -18
  57. package/src/cache/tag-invalidation.ts +3 -18
  58. package/src/router/error-handling.ts +18 -6
  59. package/src/router/intercept-resolution.ts +9 -4
  60. package/src/router/match-api.ts +1 -0
  61. package/src/router/match-context.ts +3 -0
  62. package/src/router/match-middleware/background-revalidation.ts +5 -4
  63. package/src/router/match-middleware/cache-lookup.ts +5 -3
  64. package/src/router/navigation-snapshot.ts +10 -0
  65. package/src/router/segment-resolution/helpers.ts +49 -13
  66. package/src/router/segment-resolution/loader-cache.ts +4 -3
  67. package/src/router/segment-resolution/resolution-pass.ts +2 -0
  68. package/src/router/segment-resolution/revalidation.ts +57 -28
  69. package/src/router/segment-resolution/streamed-handler-boundary.ts +3 -0
  70. package/src/router/types.ts +1 -1
  71. package/src/router.ts +7 -4
  72. package/src/rsc/request-stages.ts +4 -2
  73. package/src/rsc/shell-capture.ts +63 -58
  74. package/src/server/request-context.ts +49 -0
  75. package/src/testing/dispatch.ts +3 -0
  76. package/src/testing/render-route.tsx +199 -13
  77. package/src/testing/serve-shell-request.ts +19 -10
  78. package/src/types/error-types.ts +27 -8
  79. package/src/wire-params.ts +5 -0
@@ -22,6 +22,7 @@ import {
22
22
  } from "../deps/rsc-client.js";
23
23
  import {
24
24
  getRequestContext,
25
+ runAsBackgroundWork,
25
26
  type RequestContext,
26
27
  } from "../server/request-context.js";
27
28
  import { isUnderTestRunner } from "../runtime-env.js";
@@ -75,7 +76,6 @@ import {
75
76
  executionStart,
76
77
  invalidatedSince,
77
78
  predatesInvalidation,
78
- runAsBackgroundRefresh,
79
79
  type ExecutionStart,
80
80
  } from "./tag-invalidation.js";
81
81
  import type { CacheItemResult } from "./types.js";
@@ -727,22 +727,22 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
727
727
  // user's value into the shared entry — with zero visibility to the
728
728
  // concurrent foreground.
729
729
 
730
- try {
731
- // Re-establish the request-context ALS so a "use cache" body that
732
- // reads the ambient getRequestContext() (e.g.
733
- // getRequestContext().env.ApiKey) resolves during the background
734
- // revalidation instead of throwing "called outside of a request
735
- // context". runAsBackgroundRefresh sets the store for fn's
736
- // synchronous kickoff; its async continuations inherit it. No
737
- // visitor made the invalidations it runs: they set no
738
- // fresh-reads cookie.
739
- //
740
- // The span opens inside the request ALS and wraps the WHOLE task —
741
- // fn kickoff through the store write — so its platform spans nest
742
- // under rango.background and a slow or failing setItem is part of
743
- // the traced revalidation, not an untraced tail.
744
- await runAsBackgroundRefresh(requestCtx, () =>
745
- observePhase(
730
+ // Re-establish the request-context ALS so a "use cache" body that
731
+ // reads the ambient getRequestContext() (e.g.
732
+ // getRequestContext().env.ApiKey) resolves during the background
733
+ // revalidation instead of throwing "called outside of a request
734
+ // context". runAsBackgroundWork sets the store for fn's synchronous
735
+ // kickoff; its async continuations inherit it. No visitor made the
736
+ // invalidations it runs (no fresh-reads cookie), and onError marks
737
+ // its errors `background`, the failure caught below included.
738
+ //
739
+ // The span opens inside the request ALS and wraps the WHOLE task —
740
+ // fn kickoff through the store write — so its platform spans nest
741
+ // under rango.background and a slow or failing setItem is part of
742
+ // the traced revalidation, not an untraced tail.
743
+ await runAsBackgroundWork(requestCtx, async () => {
744
+ try {
745
+ await observePhase(
746
746
  PHASES.background("use-cache-revalidation"),
747
747
  async () => {
748
748
  const refreshArgs = args.map(refreshView);
@@ -796,20 +796,17 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
796
796
  noteWarmWrite(requestCtx, "item");
797
797
  }
798
798
  },
799
- ),
800
- );
801
- } catch (bgError) {
802
- bgCapture?.stop();
803
- // Pass requestCtx explicitly: this runs in a detached background
804
- // task where the ALS context is gone, so onError can only fire if
805
- // we hand it the context captured up front.
806
- reportCacheError(
807
- bgError,
808
- "stale-revalidation",
809
- "[use cache] background revalidation failed",
810
- requestCtx,
811
- );
812
- }
799
+ );
800
+ } catch (bgError) {
801
+ bgCapture?.stop();
802
+ reportCacheError(
803
+ bgError,
804
+ "stale-revalidation",
805
+ "[use cache] background revalidation failed",
806
+ requestCtx,
807
+ );
808
+ }
809
+ });
813
810
  }),
814
811
  );
815
812
  return result;
@@ -22,7 +22,7 @@ import {
22
22
  import {
23
23
  createRequestContext,
24
24
  getRequestContext,
25
- runWithRequestContext,
25
+ runAsBackgroundWork,
26
26
  type RequestContext,
27
27
  } from "../server/request-context.js";
28
28
  import { deriveRenderPass } from "../server/render-pass.js";
@@ -378,24 +378,25 @@ export function createDocumentCacheMiddleware<TEnv = any>(
378
378
  }),
379
379
  });
380
380
 
381
- runBackground(requestCtx, async () => {
382
- try {
383
- // Re-establish the request-context ALS around the whole background
384
- // task: next() re-runs the full handler pipeline, and on workerd a
385
- // waitUntil task runs detached from the request's I/O context, so a
386
- // handler/component reading getRequestContext() would otherwise
387
- // throw. Same fix as the route-level/use-cache background
388
- // revalidation paths. The rango.background span (kind=
389
- // document-revalidation) wraps the re-render AND the store write so
390
- // the task's spans — the re-run's own rango.* set and the drain/put
391
- // platform spans — nest under one explanatory parent instead of
392
- // dangling under the ended foreground phases. Running the put
393
- // inside the ALS matches the MISS path, where putResponse already
394
- // executes within the foreground request context. No visitor made
395
- // the invalidations the re-run makes: the pass's fresh-reads
396
- // writer is the sink's, which sets nothing.
397
- await runWithRequestContext(rerunCtx, () =>
398
- observePhase(
381
+ // Re-establish the request-context ALS around the whole background
382
+ // task: next() re-runs the full handler pipeline, and on workerd a
383
+ // waitUntil task runs detached from the request's I/O context, so a
384
+ // handler/component reading getRequestContext() would otherwise
385
+ // throw. Same fix as the route-level/use-cache background
386
+ // revalidation paths. The rango.background span (kind=
387
+ // document-revalidation) wraps the re-render AND the store write so
388
+ // the task's spans — the re-run's own rango.* set and the drain/put
389
+ // platform spans — nest under one explanatory parent instead of
390
+ // dangling under the ended foreground phases. Running the put
391
+ // inside the ALS matches the MISS path, where putResponse already
392
+ // executes within the foreground request context. No visitor made
393
+ // the invalidations the re-run makes: the pass's fresh-reads
394
+ // writer is the sink's, which sets nothing. onError marks the
395
+ // re-run's errors `background`, the failure caught below included.
396
+ runBackground(requestCtx, () =>
397
+ runAsBackgroundWork(rerunCtx, async () => {
398
+ try {
399
+ await observePhase(
399
400
  PHASES.background("document-revalidation"),
400
401
  async () => {
401
402
  const fresh = await next();
@@ -425,24 +426,20 @@ export function createDocumentCacheMiddleware<TEnv = any>(
425
426
  `[DocumentCache] REVALIDATED ${typeLabel}: ${url.pathname}`,
426
427
  );
427
428
  },
428
- ),
429
- );
430
- } catch (error) {
431
- // The route now rewrites (route middleware): nothing to refresh
432
- // this entry with.
433
- if (isRewrite(error)) return;
434
- // Pass requestCtx explicitly: this runs in a detached waitUntil task
435
- // where the ALS context is gone, so onError only fires if we hand it
436
- // the captured context (reportCacheError falls back to _getRequestContext
437
- // otherwise, which is null here).
438
- reportCacheError(
439
- error,
440
- "cache-write",
441
- "[DocumentCache] revalidation",
442
- requestCtx,
443
- );
444
- }
445
- });
429
+ );
430
+ } catch (error) {
431
+ // The route now rewrites (route middleware): nothing to refresh
432
+ // this entry with.
433
+ if (isRewrite(error)) return;
434
+ reportCacheError(
435
+ error,
436
+ "cache-write",
437
+ "[DocumentCache] revalidation",
438
+ requestCtx,
439
+ );
440
+ }
441
+ }),
442
+ );
446
443
 
447
444
  return drainOnResponseCallbacks(
448
445
  addCacheStatusHeader(cached.response, "STALE"),
@@ -117,29 +117,31 @@ export async function readThroughItem<T>(
117
117
  // span cover the write too (a slow or failing store write is part of
118
118
  // the revalidation, not an untraced tail). The foreground miss below
119
119
  // stays unwrapped: its context is already established.
120
+ // Reported inside the task, within wrapBackground's frame, so onError
121
+ // marks it `background`; rethrown so the span records the failure.
120
122
  const revalidateTask = async () => {
121
- const fresh = await execute();
122
- const serialized = await serialize(fresh);
123
- if (serialized !== null) {
124
- await setItem(key, serialized, storeOptions);
123
+ try {
124
+ const fresh = await execute();
125
+ const serialized = await serialize(fresh);
126
+ if (serialized !== null) {
127
+ await setItem(key, serialized, storeOptions);
128
+ }
129
+ } catch (error) {
130
+ reportCacheError(
131
+ error,
132
+ "stale-revalidation",
133
+ "[read-through] background revalidation",
134
+ host ?? undefined,
135
+ );
136
+ throw error;
125
137
  }
126
138
  };
127
139
  runBackground(
128
140
  host,
129
- async () => {
130
- try {
131
- await (wrapBackground
132
- ? wrapBackground(revalidateTask)
133
- : revalidateTask());
134
- } catch (error) {
135
- reportCacheError(
136
- error,
137
- "stale-revalidation",
138
- "[read-through] background revalidation",
139
- host ?? undefined,
140
- );
141
- }
142
- },
141
+ () =>
142
+ (wrapBackground ? wrapBackground(revalidateTask) : revalidateTask())
143
+ // Reported above.
144
+ .catch(() => {}),
143
145
  true,
144
146
  );
145
147
  return data;
@@ -36,11 +36,9 @@
36
36
  * predatesInvalidation, and docs/design/caching.md "Read-your-own-writes".
37
37
  */
38
38
 
39
- import { AsyncLocalStorage } from "node:async_hooks";
40
39
  import {
41
40
  _getRequestContext,
42
- runWithRequestContext,
43
- type RequestContext,
41
+ isBackgroundWork,
44
42
  } from "../server/request-context.js";
45
43
  import { reportingAsync } from "./cache-error.js";
46
44
  import { normalizeTags } from "./cache-tag.js";
@@ -134,20 +132,6 @@ function warnPartialTagStore(fn: string, incapable: number): void {
134
132
  );
135
133
  }
136
134
 
137
- const backgroundRefresh = new AsyncLocalStorage<true>();
138
-
139
- /**
140
- * Run a stale entry's background refresh on the visitor's request context: a
141
- * "use cache" function's (cache-runtime.ts) or a cached loader's
142
- * (loader-cache.ts). No visitor made the invalidations in it, so they set no
143
- * fresh-reads cookie, as in a stale cache() HIT's re-render or a document
144
- * cache entry's re-run, whose passes take a sink's no-op writer
145
- * (server/render-pass.ts).
146
- */
147
- export function runAsBackgroundRefresh<T>(ctx: RequestContext, fn: () => T): T {
148
- return runWithRequestContext(ctx, () => backgroundRefresh.run(true, fn));
149
- }
150
-
151
135
  /**
152
136
  * Set the fresh-reads cookie on this request's response for as long as the
153
137
  * invalidated stores' isolate memos can stay stale (isolate-tag-memo.ts), so
@@ -155,7 +139,8 @@ export function runAsBackgroundRefresh<T>(ctx: RequestContext, fn: () => T): T {
155
139
  */
156
140
  function markFreshReads(stores: SegmentCacheStore[]): void {
157
141
  const ctx = _getRequestContext();
158
- if (!ctx?._setFreshReadsCookie || backgroundRefresh.getStore()) return;
142
+ // No visitor made an invalidation in background work (runAsBackgroundWork).
143
+ if (!ctx?._setFreshReadsCookie || isBackgroundWork()) return;
159
144
  let windowMs = 0;
160
145
  for (const store of stores) {
161
146
  windowMs = Math.max(windowMs, store.freshReadsWindowMs ?? 0);
@@ -8,6 +8,10 @@
8
8
  import { createElement, type ReactNode } from "react";
9
9
  import type { EntryData } from "../server/context.js";
10
10
  import { runIdentityExempt } from "../cache/cache-exec-scope.js";
11
+ import {
12
+ _getRequestContext,
13
+ isBackgroundWork,
14
+ } from "../server/request-context.js";
11
15
  import type {
12
16
  ResolvedSegment,
13
17
  ErrorInfo,
@@ -39,7 +43,7 @@ export interface InvokeOnErrorContext<TEnv = any> {
39
43
  isPartial?: boolean;
40
44
  handledByBoundary?: boolean;
41
45
  metadata?: Record<string, unknown>;
42
- /** Request start time from performance.now() for duration calculation */
46
+ /** performance.now() at request entry; the request context's by default. */
43
47
  requestStartTime?: number;
44
48
  }
45
49
 
@@ -50,6 +54,12 @@ export interface InvokeOnErrorContext<TEnv = any> {
50
54
  * This is a shared utility used by both the router and RSC handler to ensure
51
55
  * consistent error callback behavior across the codebase.
52
56
  *
57
+ * `background` is set inside runAsBackgroundWork (server/request-context.ts),
58
+ * and `duration` and `env` come from the request context on the ALS
59
+ * (`_handlerStart`, `env`) when the call site leaves them out, so no call
60
+ * site has to know. A Prerender pass's ctx.env throws without buildEnv, so
61
+ * the walk never reads it eagerly.
62
+ *
53
63
  * @param onError - The onError callback to invoke (may be undefined)
54
64
  * @param error - The error that occurred
55
65
  * @param phase - The phase where the error occurred
@@ -66,9 +76,10 @@ export function invokeOnError<TEnv = any>(
66
76
  if (!onError) return;
67
77
 
68
78
  const errorObj = error instanceof Error ? error : new Error(String(error));
69
- const duration = context.requestStartTime
70
- ? performance.now() - context.requestStartTime
71
- : undefined;
79
+ const reqCtx = _getRequestContext();
80
+ const background = isBackgroundWork() || undefined;
81
+ const start = context.requestStartTime ?? reqCtx?._handlerStart;
82
+ const duration = start !== undefined ? performance.now() - start : undefined;
72
83
 
73
84
  const errorContext: OnErrorContext<TEnv> = {
74
85
  error: errorObj,
@@ -84,10 +95,11 @@ export function invokeOnError<TEnv = any>(
84
95
  loaderName: context.loaderName,
85
96
  middlewareId: context.middlewareId,
86
97
  actionId: context.actionId,
87
- env: context.env,
98
+ env: context.env ?? (reqCtx?.env as TEnv | undefined),
88
99
  duration,
89
100
  isPartial: context.isPartial,
90
- handledByBoundary: context.handledByBoundary,
101
+ background,
102
+ handledByBoundary: background ? false : context.handledByBoundary,
91
103
  stack: errorObj.stack,
92
104
  metadata: context.metadata,
93
105
  };
@@ -164,15 +164,16 @@ export function findInterceptForRoute(
164
164
  /**
165
165
  * An intercept handler's throw or notFound() renders the declaring entry's
166
166
  * boundary as the slot component (catchSegmentError, as for route handlers on
167
- * partial navigations: 500/404 status, onError "handler"). Responses rethrow.
168
- * The background re-render (`background`) reports the error the same way and
169
- * then rethrows it, so a failed re-render aborts its cache write instead of
170
- * storing the fallback.
167
+ * partial navigations: 500/404 status, onError "handler" naming the slot).
168
+ * Responses rethrow. The background re-render (`background`) reports the
169
+ * error the same way and then rethrows it, so a failed re-render aborts its
170
+ * cache write instead of storing the fallback.
171
171
  */
172
172
  function renderInterceptHandlerError<TEnv>(
173
173
  pass: ResolutionPass<TEnv>,
174
174
  error: unknown,
175
175
  parentEntry: EntryData,
176
+ slotSegmentId: string,
176
177
  background: boolean | undefined,
177
178
  ): ReactNode {
178
179
  if (error instanceof Response) throw error;
@@ -193,6 +194,7 @@ function renderInterceptHandlerError<TEnv>(
193
194
  telemetry,
194
195
  },
195
196
  pass.context.pathname,
197
+ { id: slotSegmentId, type: "parallel" },
196
198
  );
197
199
  if (background) throw error;
198
200
  return component;
@@ -363,6 +365,7 @@ export async function resolveInterceptEntry<TEnv>(
363
365
  pass,
364
366
  error,
365
367
  parentEntry,
368
+ slotSegmentId,
366
369
  options?.background,
367
370
  );
368
371
  }
@@ -402,6 +405,7 @@ export async function resolveInterceptEntry<TEnv>(
402
405
  context,
403
406
  routeKey: interceptEntry.routeName,
404
407
  params,
408
+ isPartial: true,
405
409
  recover: !options?.background,
406
410
  });
407
411
 
@@ -429,6 +433,7 @@ export async function resolveInterceptEntry<TEnv>(
429
433
  pass,
430
434
  error,
431
435
  parentEntry,
436
+ slotSegmentId,
432
437
  options?.background,
433
438
  );
434
439
  }
@@ -514,6 +514,7 @@ export async function createMatchContextForPartial<TEnv>(
514
514
  clientSegmentIds: nav.clientSegmentIds,
515
515
  clientSegmentSet,
516
516
  stale: nav.stale,
517
+ heldBoundary: nav.heldBoundary,
517
518
  prevUrl: nav.prevUrl,
518
519
  prevParams: nav.prevParams,
519
520
  prevMatch: nav.prevMatch,
@@ -136,6 +136,8 @@ export interface MatchContext<TEnv = any> {
136
136
  clientSegmentIds: string[];
137
137
  clientSegmentSet: Set<string>;
138
138
  stale: boolean;
139
+ /** NavigationSnapshot.heldBoundary: the walk stops above it. */
140
+ heldBoundary?: string;
139
141
 
140
142
  // Previous navigation state
141
143
  prevUrl: URL;
@@ -219,6 +221,7 @@ export function resolutionPassInput<TEnv>(
219
221
  routeKey: ctx.routeKey,
220
222
  actionContext: ctx.actionContext,
221
223
  stale: ctx.stale,
224
+ heldBoundary: ctx.heldBoundary,
222
225
  };
223
226
  }
224
227
 
@@ -113,7 +113,7 @@ import { getContext } from "../../server/context.js";
113
113
  import { executionStart } from "../../cache/tag-invalidation.js";
114
114
  import {
115
115
  createRequestContext,
116
- runWithRequestContext,
116
+ runAsBackgroundWork,
117
117
  type RequestContext,
118
118
  } from "../../server/request-context.js";
119
119
  import { deriveRenderPass } from "../../server/render-pass.js";
@@ -141,8 +141,9 @@ import {
141
141
  * each field and why. The DSL store below has `metrics` unset too (track()
142
142
  * reads that one), so nothing lands on the foreground's perf timeline;
143
143
  * ctx.Store.run closes over ctx.Store, so the derived store needs its own run.
144
- * runWithRequestContext also re-establishes the request ALS, which a waitUntil
145
- * task on workerd loses; the DSL store is a different ALS (build context).
144
+ * runAsBackgroundWork also re-establishes the request ALS, which a waitUntil
145
+ * task on workerd loses, and marks the pass's onError reports `background`;
146
+ * the DSL store is a different ALS (build context).
146
147
  */
147
148
  export async function rerenderAndCacheRoute<TEnv>(
148
149
  ctx: MatchContext<TEnv>,
@@ -172,7 +173,7 @@ export async function rerenderAndCacheRoute<TEnv>(
172
173
  store.parent,
173
174
  fn,
174
175
  );
175
- return runWithRequestContext(renderCtx, async () => {
176
+ return runAsBackgroundWork(renderCtx, async () => {
176
177
  const handlerContext = routerCtx.createHandlerContext(
177
178
  ctx.matched.params,
178
179
  ctx.request,
@@ -431,13 +431,15 @@ async function* serveStoredSegments<TEnv>(
431
431
  // route is the same case, served from the overlay first (an overlay entry
432
432
  // is newer than the build one, and may be the only one). Passthrough
433
433
  // routes keep a liveHandler, so a live render (rendersLive) re-renders
434
- // them fresh. A thrown action's boundary pass walks live too
435
- // (ActionContext.boundary).
434
+ // them fresh. A pass cut at an error boundary walks live too
435
+ // (ActionContext.boundary, a follow-up's held one): the evicted handler is
436
+ // under the cut.
436
437
  if (
437
438
  (prerenderStoreShortCircuits(ctx.matched.pr, ctx.request) ||
438
439
  overlayEligible) &&
439
440
  (!rendersLive(ctx) || !isPassthroughPrerenderRoute) &&
440
- !ctx.actionContext?.boundary
441
+ !ctx.actionContext?.boundary &&
442
+ !ctx.heldBoundary
441
443
  ) {
442
444
  await ensurePrerenderDeps();
443
445
  stored = await readPrerenderSource(
@@ -18,6 +18,13 @@ export interface NavigationSnapshot {
18
18
  filteredSegmentIds: string[];
19
19
 
20
20
  stale: boolean;
21
+ /**
22
+ * The id of an action's error boundary on screen that a stale request holds
23
+ * (`_rsc_boundary`, the action follow-up): read only beside `stale`, so it
24
+ * has every stale guarantee (a live render, no route-cache or document-cache
25
+ * read or store, no PPR replay).
26
+ */
27
+ heldBoundary?: string;
21
28
 
22
29
  isSameRouteNavigation: boolean;
23
30
 
@@ -136,6 +143,8 @@ export async function resolveNavigation(
136
143
  const clientSegmentIds =
137
144
  url.searchParams.get(WIRE.segments)?.split(",").filter(Boolean) || [];
138
145
  const stale = url.searchParams.get(WIRE.stale) === "true";
146
+ const heldBoundary =
147
+ (stale && url.searchParams.get(WIRE.boundary)) || undefined;
139
148
  const previousUrl = getNavigationContextHeader(request, url.origin);
140
149
  const interceptSourceUrl = getInterceptSourceHeader(request);
141
150
  const isHmr = !!requestHeaders(request).get("X-RSC-HMR");
@@ -198,6 +207,7 @@ export async function resolveNavigation(
198
207
  clientSegmentSet,
199
208
  filteredSegmentIds,
200
209
  stale,
210
+ heldBoundary,
201
211
  isSameRouteNavigation,
202
212
  effectiveFromUrl,
203
213
  effectiveFromMatch,
@@ -204,15 +204,49 @@ export interface ErrorReportContext {
204
204
  routeKey?: string;
205
205
  env?: any;
206
206
  isPartial?: boolean;
207
- requestStartTime?: number;
208
207
  telemetry?: TelemetrySink;
209
208
  }
210
209
 
210
+ type Thrower = { id: string; type: ErrorInfo["segmentType"] };
211
+
212
+ /**
213
+ * Note that `id` threw `error`, for an error its owning entry's boundary
214
+ * catches (a parallel slot's sync throw, an orphan layout's): onError names
215
+ * the segment that threw, as a streamed slot's rejection does. Kept on the
216
+ * request context, never the module: a reused error object (a constant, a
217
+ * memoized rejection) can be thrown by another request, or by a Prerender
218
+ * pass that rethrows before catchSegmentError reads it. Returns `error`, to
219
+ * rethrow.
220
+ */
221
+ export function noteThrower(
222
+ error: unknown,
223
+ id: string,
224
+ type: ErrorInfo["segmentType"],
225
+ ): unknown {
226
+ const reqCtx = _getRequestContext();
227
+ if (reqCtx && error !== null && typeof error === "object") {
228
+ (reqCtx._throwers ??= new WeakMap()).set(error, { id, type });
229
+ }
230
+ return error;
231
+ }
232
+
233
+ /** The segment noteThrower recorded for `error`, read once. */
234
+ function takeThrower(error: unknown): Thrower | undefined {
235
+ const throwers = _getRequestContext()?._throwers;
236
+ if (!throwers || error === null || typeof error !== "object") return;
237
+ const noted = throwers.get(error) as Thrower | undefined;
238
+ throwers.delete(error);
239
+ return noted;
240
+ }
241
+
211
242
  /**
212
243
  * Handle a caught error during segment resolution by creating an
213
244
  * error or not-found segment with the nearest boundary.
214
245
  *
215
246
  * Called by resolveWithErrorBoundary to produce error/notFound segments.
247
+ * `entry` is where the boundary is looked up; `thrower` names the segment
248
+ * that threw when it is not `entry` (an intercept's slot, under its declaring
249
+ * entry), for the report, the server print and the client's ErrorInfo.
216
250
  */
217
251
  export function catchSegmentError<TEnv>(
218
252
  error: unknown,
@@ -221,7 +255,11 @@ export function catchSegmentError<TEnv>(
221
255
  deps: SegmentResolutionDeps<TEnv>,
222
256
  report?: ErrorReportContext,
223
257
  pathname?: string,
258
+ thrower?: Thrower,
224
259
  ): ResolvedSegment {
260
+ const named = thrower ?? takeThrower(error);
261
+ const segmentId = named?.id ?? entry.shortCode;
262
+ const segmentType: ErrorInfo["segmentType"] = named?.type ?? entry.type;
225
263
  const reportError = (
226
264
  handledByBoundary: boolean,
227
265
  metadata?: Record<string, unknown>,
@@ -232,13 +270,12 @@ export function catchSegmentError<TEnv>(
232
270
  url: report.url,
233
271
  routeKey: report.routeKey,
234
272
  params,
235
- segmentId: entry.shortCode,
236
- segmentType: entry.type as any,
273
+ segmentId,
274
+ segmentType: segmentType as any,
237
275
  env: report.env,
238
276
  isPartial: report.isPartial,
239
277
  handledByBoundary,
240
278
  metadata,
241
- requestStartTime: report.requestStartTime,
242
279
  });
243
280
  if (report.telemetry) {
244
281
  const errorObj =
@@ -247,8 +284,8 @@ export function catchSegmentError<TEnv>(
247
284
  type: "handler.error",
248
285
  timestamp: performance.now(),
249
286
  requestId: report.request ? getRequestId(report.request) : undefined,
250
- segmentId: entry.shortCode,
251
- segmentType: entry.type,
287
+ segmentId,
288
+ segmentType,
252
289
  error: errorObj,
253
290
  handledByBoundary,
254
291
  pathname,
@@ -274,8 +311,8 @@ export function catchSegmentError<TEnv>(
274
311
 
275
312
  const notFoundInfo = createNotFoundInfo(
276
313
  error,
277
- entry.shortCode,
278
- entry.type,
314
+ segmentId,
315
+ segmentType,
279
316
  pathname,
280
317
  );
281
318
 
@@ -285,7 +322,7 @@ export function catchSegmentError<TEnv>(
285
322
  });
286
323
 
287
324
  debugLog("segment", "notFound boundary handled error", {
288
- segmentId: entry.shortCode,
325
+ segmentId,
289
326
  message: notFoundInfo.message,
290
327
  });
291
328
 
@@ -302,19 +339,18 @@ export function catchSegmentError<TEnv>(
302
339
  // The client gets the masked ErrorInfo; the original prints here.
303
340
  printServerError(
304
341
  error,
305
- { label: "Handler error", name: entry.shortCode, pathname },
342
+ { label: "Handler error", name: segmentId, pathname },
306
343
  _getRequestContext(),
307
344
  );
308
345
 
309
346
  const fallback = deps.findNearestErrorBoundary(entry);
310
- const segmentType: ErrorInfo["segmentType"] = entry.type;
311
- const errorInfo = createErrorInfo(error, entry.shortCode, segmentType);
347
+ const errorInfo = createErrorInfo(error, segmentId, segmentType);
312
348
  const effectiveFallback = fallback ?? DefaultErrorFallback;
313
349
 
314
350
  reportError(!!effectiveFallback);
315
351
 
316
352
  debugLog("segment", "error boundary handled error", {
317
- segmentId: entry.shortCode,
353
+ segmentId,
318
354
  boundary: fallback ? "custom" : "default",
319
355
  message: errorInfo.message,
320
356
  });