@rangojs/router 0.28.0 → 0.30.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 (84) 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 +10 -4
  6. package/dist/types/browser/react/use-handle.d.ts +20 -16
  7. package/dist/types/browser/react/use-link-status.d.ts +5 -2
  8. package/dist/types/browser/react/use-route-state.d.ts +45 -0
  9. package/dist/types/browser/segment-reconciler.d.ts +26 -0
  10. package/dist/types/browser/types.d.ts +30 -8
  11. package/dist/types/browser/wire-protocol.d.ts +1 -0
  12. package/dist/types/cache/tag-invalidation.d.ts +0 -10
  13. package/dist/types/router/error-handling.d.ts +7 -1
  14. package/dist/types/router/match-context.d.ts +2 -0
  15. package/dist/types/router/match-middleware/background-revalidation.d.ts +3 -2
  16. package/dist/types/router/navigation-snapshot.d.ts +7 -0
  17. package/dist/types/router/segment-resolution/helpers.d.ts +19 -3
  18. package/dist/types/router/segment-resolution/resolution-pass.d.ts +2 -0
  19. package/dist/types/router/segment-resolution/streamed-handler-boundary.d.ts +2 -0
  20. package/dist/types/router/types.d.ts +1 -1
  21. package/dist/types/server/request-context.d.ts +30 -1
  22. package/dist/types/testing/render-route.d.ts +43 -8
  23. package/dist/types/testing/serve-shell-request.d.ts +8 -1
  24. package/dist/types/types/error-types.d.ts +27 -8
  25. package/dist/types/wire-params.d.ts +5 -0
  26. package/dist/vite/index.js +62 -67
  27. package/package.json +1 -1
  28. package/skills/client-urls/SKILL.md +6 -8
  29. package/skills/hooks/SKILL.md +1 -1
  30. package/skills/hooks/data.md +8 -1
  31. package/skills/hooks/handle-and-actions.md +40 -2
  32. package/skills/hooks/navigation.md +121 -15
  33. package/skills/hooks/state.md +6 -4
  34. package/skills/hooks/urls.md +17 -5
  35. package/skills/intercept/SKILL.md +6 -0
  36. package/skills/observability/SKILL.md +30 -0
  37. package/skills/server-actions/SKILL.md +8 -1
  38. package/skills/testing/client-components.md +25 -18
  39. package/skills/testing/serve-shell-request.md +7 -7
  40. package/skills/view-transitions/SKILL.md +1 -1
  41. package/src/browser/action-fence.ts +2 -1
  42. package/src/browser/event-controller.ts +78 -19
  43. package/src/browser/navigation-bridge.ts +45 -13
  44. package/src/browser/navigation-client.ts +2 -0
  45. package/src/browser/network-error-handler.ts +4 -0
  46. package/src/browser/partial-update.ts +33 -6
  47. package/src/browser/react/use-action.ts +27 -50
  48. package/src/browser/react/use-handle.ts +27 -18
  49. package/src/browser/react/use-link-status.ts +48 -60
  50. package/src/browser/react/use-navigation.ts +76 -51
  51. package/src/browser/react/use-params.ts +6 -35
  52. package/src/browser/react/use-pathname.ts +3 -32
  53. package/src/browser/react/use-route-state.ts +141 -0
  54. package/src/browser/react/use-search-params.ts +7 -40
  55. package/src/browser/segment-reconciler.ts +43 -0
  56. package/src/browser/server-action-bridge.ts +35 -8
  57. package/src/browser/types.ts +31 -8
  58. package/src/browser/wire-protocol.ts +2 -0
  59. package/src/cache/cache-runtime.ts +28 -31
  60. package/src/cache/document-cache.ts +34 -37
  61. package/src/cache/read-through-swr.ts +20 -18
  62. package/src/cache/tag-invalidation.ts +3 -18
  63. package/src/router/error-handling.ts +18 -6
  64. package/src/router/intercept-resolution.ts +9 -4
  65. package/src/router/match-api.ts +1 -0
  66. package/src/router/match-context.ts +3 -0
  67. package/src/router/match-middleware/background-revalidation.ts +5 -4
  68. package/src/router/match-middleware/cache-lookup.ts +5 -3
  69. package/src/router/navigation-snapshot.ts +10 -0
  70. package/src/router/segment-resolution/helpers.ts +49 -13
  71. package/src/router/segment-resolution/loader-cache.ts +4 -3
  72. package/src/router/segment-resolution/resolution-pass.ts +2 -0
  73. package/src/router/segment-resolution/revalidation.ts +57 -28
  74. package/src/router/segment-resolution/streamed-handler-boundary.ts +3 -0
  75. package/src/router/types.ts +1 -1
  76. package/src/router.ts +7 -4
  77. package/src/rsc/request-stages.ts +4 -2
  78. package/src/rsc/shell-capture.ts +63 -58
  79. package/src/server/request-context.ts +49 -0
  80. package/src/testing/dispatch.ts +3 -0
  81. package/src/testing/render-route.tsx +204 -16
  82. package/src/testing/serve-shell-request.ts +19 -10
  83. package/src/types/error-types.ts +27 -8
  84. package/src/wire-params.ts +5 -0
@@ -15,6 +15,9 @@ import {
15
15
  import { KEEP_CACHE_HEADER } from "./cookie-name.js";
16
16
  import { createNavigationTransaction } from "./navigation-transaction.js";
17
17
  import {
18
+ actionBoundaries,
19
+ actionBoundaryAt,
20
+ idsAboveBoundary,
18
21
  reconcileSegments,
19
22
  reconcileErrorSegments,
20
23
  } from "./segment-reconciler.js";
@@ -206,6 +209,8 @@ export function createServerActionBridge(
206
209
  actionId?: string;
207
210
  /** transition({ when }) `action` fields for the refetch commit. */
208
211
  action?: TransitionWhenActionInput;
212
+ /** The follow-up's held error boundary (UpdateMode `action`). */
213
+ boundary?: string;
209
214
  };
210
215
 
211
216
  /** The follow-up's transition({ when }) fields: the last owing action's. */
@@ -255,13 +260,13 @@ export function createServerActionBridge(
255
260
  let followUpsStarted = 0;
256
261
  /** The newest follow-up, until it ends: leaveFollowUp() aborts it. */
257
262
  let followUpRunning: { handle: NavigationHandle; due: FollowUp } | undefined;
258
-
259
263
  /**
260
264
  * The follow-up revalidation: the rules are R1-R7 in
261
265
  * docs/internal/execution-model.md. Once the batch's last action exited,
262
266
  * the page on screen that missed a write is refetched once, whole (no
263
- * segments held, so every loader re-runs), in the action lane (storeOnly,
264
- * skipLoadingState: useNavigation() stays idle).
267
+ * segments held, so every loader re-runs; above an action's boundary, see
268
+ * below), in the action lane (storeOnly, skipLoadingState:
269
+ * useNavigation() stays idle).
265
270
  *
266
271
  * - Never over an intercept: a request holding no segments drops the page
267
272
  * under it (match-result.ts buildMatchResult). Checked when it would run,
@@ -271,6 +276,10 @@ export function createServerActionBridge(
271
276
  * keeps its error on screen, and the debt is dropped.
272
277
  * - Runs again when it is left before its commit (a navigation, or an action
273
278
  * that started: leaveFollowUp), for the page then on screen.
279
+ * - Holds the action error boundary the page shows (actionBoundaryAt): it
280
+ * is that action's outcome, which no GET reproduces, so the server
281
+ * renders every segment above it and nothing at or below it. With
282
+ * nothing above it (the page's first segment), the debt is dropped.
274
283
  */
275
284
  function followUp(): void {
276
285
  if (!owed || isActionFenceActive()) return;
@@ -285,8 +294,16 @@ export function createServerActionBridge(
285
294
  const due = owed;
286
295
  owed = undefined;
287
296
  if (store.getInterceptSourceUrl()) return;
297
+ // The deepest action boundary the page shows: none (-1) refetches the
298
+ // page whole, the first segment (0) leaves nothing above it.
299
+ const shown =
300
+ store.getCachedSegments(store.getHistoryKey())?.segments ?? [];
301
+ const at = actionBoundaryAt(shown);
302
+ if (at === 0) return;
303
+ // Not on `due`: a run owed again (owed ??= due) looks again.
304
+ const held = { ...due, boundary: shown[at]?.id };
288
305
  const generation = ++followUpsStarted;
289
- refetchRoute(due, {
306
+ refetchRoute(held, {
290
307
  started: (handle) => (followUpRunning = { handle, due }),
291
308
  ended: (left) => {
292
309
  if (generation !== followUpsStarted) return;
@@ -359,6 +376,7 @@ export function createServerActionBridge(
359
376
  ...(src ? { interceptSourceUrl: src } : {}),
360
377
  ...(opts.actionId !== undefined ? { actionId: opts.actionId } : {}),
361
378
  ...(opts.action ? { action: opts.action } : {}),
379
+ boundary: opts.boundary,
362
380
  },
363
381
  );
364
382
  return { started: true, error };
@@ -794,11 +812,15 @@ export function createServerActionBridge(
794
812
  // Cache (and bump the nav instance) BEFORE the UI update so a deferred
795
813
  // handle pushed by the error-boundary render still applies — see the
796
814
  // "normal" case below for why caching after onUpdate dropped it. The
797
- // page stops claiming the error segments only: the loaders the
798
- // response re-ran above the boundary stay held.
815
+ // action's boundary is the last error segment: the server's cut stops
816
+ // there (segment-resolution/revalidation.ts resolveAllSegments). The
817
+ // page holds what is above it, the loaders the response re-ran
818
+ // included, and no error segment.
819
+ const errors = segments.filter((s) => s.type === "error");
820
+ const boundary = errors[errors.length - 1];
799
821
  store.setSegmentIds(
800
- segmentState.currentSegmentIds.filter(
801
- (id) => !segments.some((s) => s.id === id && s.type === "error"),
822
+ idsAboveBoundary(segmentState.currentSegmentIds, boundary?.id).filter(
823
+ (id) => !errors.some((s) => s.id === id),
802
824
  ),
803
825
  );
804
826
  const currentHandleData = liveHandleDataForCache(
@@ -812,6 +834,7 @@ export function createServerActionBridge(
812
834
  currentHandleData,
813
835
  );
814
836
  store.rememberDisplayedEntry();
837
+ if (boundary) actionBoundaries.add(boundary);
815
838
 
816
839
  // Update UI with error boundary.
817
840
  if (process.env.NODE_ENV !== "production") auditTreeCause("action");
@@ -912,6 +935,10 @@ export function createServerActionBridge(
912
935
  metadata?.locationState,
913
936
  eventController,
914
937
  );
938
+ // A success on the page supersedes the action boundary it shows
939
+ // (R4): a repair that stands in for this commit, direct or owed to
940
+ // the batch's end, holds none.
941
+ for (const s of cachedSegments) actionBoundaries.delete(s);
915
942
  }
916
943
  whenAction = { ...whenAction, result: returnData };
917
944
 
@@ -201,13 +201,27 @@ export interface InflightAction {
201
201
  * @internal This type is an implementation detail. Use PublicNavigationState instead.
202
202
  */
203
203
  export interface NavigationState {
204
- /** Navigation lifecycle state (idle or loading during navigation) */
204
+ /**
205
+ * Lifecycle state: `"loading"` while a navigation is in flight and while a
206
+ * server action runs, `"idle"` otherwise
207
+ */
205
208
  state: "idle" | "loading";
206
209
 
207
- /** Whether RSC data is currently streaming (initial load or navigation) */
210
+ /**
211
+ * Whether RSC data is currently streaming: a navigation's response, or a
212
+ * server action's response (initial load, navigation or action)
213
+ */
208
214
  isStreaming: boolean;
209
215
 
210
- /** Current location */
216
+ /**
217
+ * True while a navigation is in flight (fetching or streaming, before its
218
+ * commit); false during a server action, whose `state` is still `"loading"`.
219
+ * Drive a navigation progress bar from this and an action spinner from
220
+ * `state`.
221
+ */
222
+ isNavigating: boolean;
223
+
224
+ /** The page on screen; moves when the next page shows (`pendingUrl` is the page being fetched) */
211
225
  location: NavigationLocation;
212
226
 
213
227
  /** URL being navigated to (null when idle) */
@@ -239,7 +253,11 @@ export type ActionLifecycleState = "idle" | "loading" | "streaming";
239
253
  * @internal This type is an implementation detail and may change without notice.
240
254
  */
241
255
  export interface TrackedActionState {
242
- /** Current lifecycle state of the action */
256
+ /**
257
+ * Current lifecycle state of the action. Once the call settles it is
258
+ * "idle" and the rest of the call's state is kept until the action's next
259
+ * call.
260
+ */
243
261
  state: ActionLifecycleState;
244
262
 
245
263
  /** Server action function ID (e.g., "addToCart") */
@@ -251,13 +269,13 @@ export interface TrackedActionState {
251
269
  /** Error if action failed */
252
270
  error: unknown | null;
253
271
 
254
- /** Result data from the action (preserved after completion) */
272
+ /** Result data from the action */
255
273
  result: unknown | null;
256
274
 
257
275
  /**
258
276
  * The error of a loader that threw while it re-ran after this successful
259
- * action (the first, when several did). The loader also throws at its
260
- * useLoader read, where the nearest error boundary catches it.
277
+ * action (the first, when several did). The loader also throws at its useLoader read, where the nearest
278
+ * error boundary catches it.
261
279
  */
262
280
  revalidationError: unknown | null;
263
281
  }
@@ -505,6 +523,8 @@ export interface NavigateOptionsInternal extends Omit<
505
523
  _skipCache?: boolean;
506
524
  /** Server redirects already followed by this navigation chain (0 for a user navigation) */
507
525
  _redirectHops?: number;
526
+ /** The URL a redirected navigation chain started from: a link to it stays pending (useLinkStatus) */
527
+ _redirectFrom?: string;
508
528
  }
509
529
 
510
530
  /**
@@ -718,6 +738,8 @@ export interface FetchPartialOptions {
718
738
  signal?: AbortSignal;
719
739
  /** If true, this is a stale cache revalidation request - server should force revalidators */
720
740
  staleRevalidation?: boolean;
741
+ /** Sent as `_rsc_boundary` (wire-params.ts WIRE.boundary). */
742
+ boundary?: string;
721
743
  interceptSourceUrl?: string;
722
744
  /** RSC version for cache invalidation detection */
723
745
  version?: string;
@@ -803,7 +825,8 @@ export interface ServerActionBridgeConfig {
803
825
  */
804
826
  export interface NavigationBridge {
805
827
  navigate(url: string, options?: NavigateOptionsInternal): Promise<void>;
806
- refresh(): Promise<void>;
828
+ /** `crossTab`: another tab's write; an action's error boundary stays. */
829
+ refresh(crossTab?: boolean): Promise<void>;
807
830
  /** `event`: the popstate event; a traversal the browser animated itself (hasUAVisualTransition) is not animated again. */
808
831
  handlePopstate(event?: PopStateEvent): Promise<void>;
809
832
  registerLinkInterception(): () => void;
@@ -13,6 +13,7 @@ export interface PartialRequestInit {
13
13
  */
14
14
  emptySegments: "send" | "omit";
15
15
  stale?: boolean;
16
+ boundary?: string;
16
17
  version?: string;
17
18
  routerId?: string;
18
19
  rewriteTarget?: string;
@@ -33,6 +34,7 @@ export function writePartialParams(url: URL, init: PartialRequestInit): URL {
33
34
  params.set(WIRE.segments, init.segmentIds.join(","));
34
35
  }
35
36
  if (init.stale) params.set(WIRE.stale, "true");
37
+ if (init.boundary) params.set(WIRE.boundary, init.boundary);
36
38
  if (init.version) params.set(WIRE.version, init.version);
37
39
  if (init.routerId) params.set(WIRE.routerId, init.routerId);
38
40
  if (init.rewriteTarget) params.set(WIRE.rewriteTarget, init.rewriteTarget);
@@ -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