@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
@@ -72,6 +72,7 @@ import { runIdentityExempt } from "../../cache/cache-exec-scope.js";
72
72
  import {
73
73
  getRequestContext,
74
74
  _getRequestContext,
75
+ runAsBackgroundWork,
75
76
  type RequestContext,
76
77
  } from "../../server/request-context.js";
77
78
  import { observePhase, PHASES } from "../instrument.js";
@@ -92,7 +93,6 @@ import { isWarmReplace, noteWarmWrite } from "../../prerender/warm-request.js";
92
93
  import {
93
94
  executionStart,
94
95
  predatesInvalidation,
95
- runAsBackgroundRefresh,
96
96
  } from "../../cache/tag-invalidation.js";
97
97
  import {
98
98
  maskNestedContainerThenables,
@@ -815,10 +815,11 @@ function executeLoaderData<TEnv>(
815
815
  // under the ended foreground phases. Inside the request context so
816
816
  // observePhase can read tracing on workerd (ALS detaches in waitUntil).
817
817
  // No visitor made the invalidations it runs: they set no fresh-reads
818
- // cookie (runAsBackgroundRefresh).
818
+ // cookie, and onError marks its errors `background`
819
+ // (runAsBackgroundWork).
819
820
  wrapBackground: (run) => {
820
821
  revalidating = true;
821
- return runAsBackgroundRefresh(requestCtxForExecute, () =>
822
+ return runAsBackgroundWork(requestCtxForExecute, () =>
822
823
  observePhase(PHASES.background("loader-revalidation"), run),
823
824
  );
824
825
  },
@@ -51,6 +51,8 @@ export interface ResolutionPassInput<TEnv = any> {
51
51
  readonly routeKey: string;
52
52
  readonly actionContext?: ActionContext;
53
53
  readonly stale?: boolean;
54
+ /** NavigationSnapshot.heldBoundary: the walk cuts there, sending no segment. */
55
+ readonly heldBoundary?: string;
54
56
  /**
55
57
  * No loader is started and no loader segment is emitted: build time
56
58
  * (prerender-match.ts) and the background re-render of a route record
@@ -62,6 +62,7 @@ import {
62
62
  resolveLayoutComponent,
63
63
  resolveWithErrorBoundary,
64
64
  buildLoaderErrorContext,
65
+ noteThrower,
65
66
  } from "./helpers.js";
66
67
  import { applyViewTransitionDefault } from "./view-transition-default.js";
67
68
  import { isLayoutLoaderCopy } from "../../client-urls/layout-loader-copies.js";
@@ -801,28 +802,34 @@ async function resolveParallelSlotComponent<TEnv>(
801
802
  handler,
802
803
  slotContext,
803
804
  );
804
- if (hasLoadingFallback) {
805
- const result = run();
806
- if (result instanceof Promise) {
807
- warnOnStreamedResponse(result, parallelId);
808
- result.finally(doneHandler).catch(() => {});
809
- component = trackStreamedHandler(deps, result, {
810
- boundaryEntry,
811
- segmentId: parallelId,
812
- segmentType: "parallel",
813
- context,
814
- routeKey,
815
- params,
816
- recover: !pass.throwOnError,
817
- });
805
+ // A throw before the slot streams reaches the owning entry's boundary.
806
+ try {
807
+ if (hasLoadingFallback) {
808
+ const result = run();
809
+ if (result instanceof Promise) {
810
+ warnOnStreamedResponse(result, parallelId);
811
+ result.finally(doneHandler).catch(() => {});
812
+ component = trackStreamedHandler(deps, result, {
813
+ boundaryEntry,
814
+ segmentId: parallelId,
815
+ segmentType: "parallel",
816
+ context,
817
+ routeKey,
818
+ params,
819
+ isPartial: reportsPartial(pass),
820
+ recover: !pass.throwOnError,
821
+ });
822
+ } else {
823
+ doneHandler();
824
+ component = result as ReactNode;
825
+ }
818
826
  } else {
827
+ // A static value (a Promise included) goes to render as is.
828
+ component = typeof handler === "function" ? await run() : handler;
819
829
  doneHandler();
820
- component = result as ReactNode;
821
830
  }
822
- } else {
823
- // A static value (a Promise included) goes to render as is.
824
- component = typeof handler === "function" ? await run() : handler;
825
- doneHandler();
831
+ } catch (error) {
832
+ throw noteThrower(error, parallelId, "parallel");
826
833
  }
827
834
  }
828
835
  }
@@ -1180,6 +1187,7 @@ async function resolveRouteComponent<TEnv>(
1180
1187
  context,
1181
1188
  routeKey,
1182
1189
  params,
1190
+ isPartial: reportsPartial(pass),
1183
1191
  recover: !pass.throwOnError,
1184
1192
  }),
1185
1193
  };
@@ -1504,6 +1512,9 @@ export async function resolveOrphanLayout<TEnv>(
1504
1512
  const doneHandler = track(`handler:${orphan.id}`, 2);
1505
1513
  try {
1506
1514
  component = await resolveLayoutComponent(orphan, context);
1515
+ } catch (error) {
1516
+ // Its owning entry's boundary catches it.
1517
+ throw noteThrower(error, orphan.shortCode, orphan.type);
1507
1518
  } finally {
1508
1519
  internal._currentSegmentId = pinned;
1509
1520
  }
@@ -1598,6 +1609,15 @@ export interface RevalidatingWalkOptions {
1598
1609
  readonly chain?: readonly EntryData[];
1599
1610
  }
1600
1611
 
1612
+ /**
1613
+ * The onError `isPartial` of an error the walk catches. FRESH: a full match
1614
+ * reports it unset. A re-render a navigation started reports it true
1615
+ * (partialRequest).
1616
+ */
1617
+ function reportsPartial(pass: ResolutionPass<any>): true | undefined {
1618
+ return pass.client !== FRESH || pass.partialRequest ? true : undefined;
1619
+ }
1620
+
1601
1621
  /** An entry's error or not-found segment, as its whole result. */
1602
1622
  function boundaryResult(seg: ResolvedSegment): SegmentRevalidationResult {
1603
1623
  return { segments: [seg], matchedIds: [seg.id] };
@@ -1633,14 +1653,12 @@ export async function resolveAllSegments<TEnv>(
1633
1653
  try {
1634
1654
  telemetry = getRouterContext()?.telemetry;
1635
1655
  } catch {}
1636
- // FRESH: a full match reports an entry's error without isPartial, and a
1637
- // build-time pass rethrows it (throwOnError). A re-render a navigation
1638
- // started reports it with isPartial (partialRequest).
1656
+ // A build-time pass rethrows an entry's error (throwOnError).
1639
1657
  const report = {
1640
1658
  request,
1641
1659
  url: context.url,
1642
1660
  routeKey,
1643
- isPartial: pass.client !== FRESH || pass.partialRequest ? true : undefined,
1661
+ isPartial: reportsPartial(pass),
1644
1662
  telemetry,
1645
1663
  };
1646
1664
  const fillMatch = {
@@ -1650,16 +1668,19 @@ export async function resolveAllSegments<TEnv>(
1650
1668
  };
1651
1669
 
1652
1670
  const boundary = pass.actionContext?.boundary;
1653
- const boundaryId = boundary?.id;
1671
+ const cutId = boundary?.id ?? pass.heldBoundary;
1672
+ let cut = false;
1654
1673
  for (const entry of entries) {
1655
- if (entry.shortCode === boundaryId) {
1674
+ if (entry.shortCode === cutId) {
1656
1675
  // The outlet's loader ids stay claimed. _handlerRan makes the browser
1657
- // clear the outlet's handle values.
1658
- allSegments.push({ ...boundary!, _handlerRan: true });
1659
- matchedIds.push(boundaryId);
1676
+ // clear the outlet's handle values. A held boundary sends no segment:
1677
+ // the client keeps its own, with its handle values.
1678
+ if (boundary) allSegments.push({ ...boundary, _handlerRan: true });
1679
+ matchedIds.push(cutId);
1660
1680
  entry.loader?.forEach((loaderEntry, i) =>
1661
1681
  matchedIds.push(`${entry.shortCode}D${i}.${loaderEntry.loader.$$id}`),
1662
1682
  );
1683
+ cut = true;
1663
1684
  break;
1664
1685
  }
1665
1686
  if (entry.type === "route" && interceptResult) {
@@ -1721,6 +1742,14 @@ export async function resolveAllSegments<TEnv>(
1721
1742
  }
1722
1743
  }
1723
1744
 
1745
+ // A held boundary that names no entry (HMR rewrote the routes since the
1746
+ // page rendered): the page renders whole, as without one.
1747
+ if (pass.heldBoundary && !cut && process.env.NODE_ENV !== "production") {
1748
+ console.warn(
1749
+ `[@rangojs/router] A refetch held the error boundary "${pass.heldBoundary}", ` +
1750
+ `which is not on the route's chain (${routeKey}): the page renders whole.`,
1751
+ );
1752
+ }
1724
1753
  deliverStartedLoaders(context, allSegments);
1725
1754
  return { segments: allSegments, matchedIds };
1726
1755
  }
@@ -44,6 +44,8 @@ export interface StreamedHandlerOptions<TEnv> {
44
44
  context: HandlerContext<any, TEnv>;
45
45
  routeKey?: string;
46
46
  params: Record<string, string>;
47
+ /** onError's `isPartial`, as the walk reports a sync handler's error. */
48
+ isPartial: true | undefined;
47
49
  /** False where the rejection must reach the caller (prerender, intercept background re-render). */
48
50
  recover: boolean;
49
51
  }
@@ -64,6 +66,7 @@ export function trackStreamedHandler<TEnv>(
64
66
  const tracked = deps.trackHandler(result as Promise<ReactNode>, {
65
67
  segmentId,
66
68
  segmentType,
69
+ isPartial: options.isPartial,
67
70
  });
68
71
  observeStreamedHandler(
69
72
  tracked,
@@ -57,7 +57,6 @@ export interface SegmentResolutionDeps<TEnv = any> {
57
57
  params?: Record<string, string>;
58
58
  env?: TEnv;
59
59
  isPartial?: boolean;
60
- requestStartTime?: number;
61
60
  },
62
61
  /** An `ssr: false` loader on a document or capture render. */
63
62
  useDefaultErrorBoundary?: boolean,
@@ -67,6 +66,7 @@ export interface SegmentResolutionDeps<TEnv = any> {
67
66
  errorContext?: {
68
67
  segmentId?: string;
69
68
  segmentType?: string;
69
+ isPartial?: boolean;
70
70
  },
71
71
  ) => Promise<T>;
72
72
  findNearestErrorBoundary: (
package/src/router.ts CHANGED
@@ -44,6 +44,7 @@ import { createHandleStore, type HandleStore } from "./server/handle-store.js";
44
44
  import {
45
45
  getRequestContext,
46
46
  _getRequestContext,
47
+ reportedErrorsOf,
47
48
  } from "./server/request-context.js";
48
49
  import type {
49
50
  ErrorPhase,
@@ -315,7 +316,8 @@ export function createRouter<TEnv = any>(
315
316
  * Wrapper for invokeOnError that binds the router's onError callback.
316
317
  * Uses the shared utility from router/error-handling.ts for consistent behavior.
317
318
  *
318
- * Deduplicates via per-request WeakSet stored on the ALS request context.
319
+ * Deduplicates via per-request WeakSet stored on the ALS request context
320
+ * (background work keeps its own, reportedErrorsOf).
319
321
  * A closure-level WeakSet would silently swallow errors if the same object
320
322
  * instance is thrown across separate requests (e.g. a singleton error).
321
323
  */
@@ -325,7 +327,8 @@ export function createRouter<TEnv = any>(
325
327
  context: Parameters<typeof invokeOnError<TEnv>>[3],
326
328
  ): void {
327
329
  if (error != null && typeof error === "object") {
328
- const reportedErrors = _getRequestContext()?._reportedErrors;
330
+ const reqCtx = _getRequestContext();
331
+ const reportedErrors = reqCtx && reportedErrorsOf(reqCtx);
329
332
  if (reportedErrors) {
330
333
  if (reportedErrors.has(error)) return;
331
334
  reportedErrors.add(error);
@@ -488,6 +491,7 @@ export function createRouter<TEnv = any>(
488
491
  errorContext?: {
489
492
  segmentId?: string;
490
493
  segmentType?: string;
494
+ isPartial?: boolean;
491
495
  },
492
496
  ): Promise<T> => {
493
497
  // One ALS read serves both the store lookup and the onError closure.
@@ -520,6 +524,7 @@ export function createRouter<TEnv = any>(
520
524
  env: reqCtx.env as TEnv,
521
525
  segmentId: errorContext?.segmentId,
522
526
  segmentType: errorContext?.segmentType as any,
527
+ isPartial: errorContext?.isPartial,
523
528
  handledByBoundary: true,
524
529
  });
525
530
  });
@@ -542,7 +547,6 @@ export function createRouter<TEnv = any>(
542
547
  params?: Record<string, string>;
543
548
  env?: TEnv;
544
549
  isPartial?: boolean;
545
- requestStartTime?: number;
546
550
  },
547
551
  useDefaultErrorBoundary?: boolean,
548
552
  ): Promise<LoaderDataResult<T>> {
@@ -592,7 +596,6 @@ export function createRouter<TEnv = any>(
592
596
  env: errorContext.env,
593
597
  isPartial: errorContext.isPartial,
594
598
  handledByBoundary: ctx.handledByBoundary,
595
- requestStartTime: errorContext.requestStartTime,
596
599
  });
597
600
  if (emitTelemetry) {
598
601
  const errorObj =
@@ -22,6 +22,7 @@ import type { MiddlewareEntry } from "../router/middleware-types.js";
22
22
  import {
23
23
  _getRequestContext,
24
24
  getRequestContext,
25
+ reportedErrorsOf,
25
26
  setRequestContextParams,
26
27
  type RequestContext,
27
28
  } from "../server/request-context.js";
@@ -158,8 +159,9 @@ export function createErrorReporter<TEnv>(router: {
158
159
  // case — the error is from a cancelled stream, not a real failure.
159
160
  const reqCtx = _getRequestContext();
160
161
  if (error != null && typeof error === "object" && reqCtx) {
161
- if (reqCtx._reportedErrors.has(error)) return;
162
- reqCtx._reportedErrors.add(error);
162
+ const reportedErrors = reportedErrorsOf(reqCtx);
163
+ if (reportedErrors.has(error)) return;
164
+ reportedErrors.add(error);
163
165
  }
164
166
  invokeOnError(router.onError, error, phase, context, "RSC");
165
167
  };
@@ -48,6 +48,7 @@ import { INTERNAL_RANGO_DEBUG } from "../internal-debug.js";
48
48
  import { observePhase, PHASES } from "../router/instrument.js";
49
49
  import type { TraceSpan } from "../router/tracing.js";
50
50
  import {
51
+ runAsBackgroundWork,
51
52
  runWithRequestContext,
52
53
  setRequestContextParams,
53
54
  UNTRACKED_BACKGROUND_TASK,
@@ -1279,69 +1280,73 @@ export function planShellCapture(input: ShellCaptureInput): void {
1279
1280
  // (observed in production: ~24s of zero I/O before capture start).
1280
1281
  // observePhase reads tracing off the ALS context captured when runBackground
1281
1282
  // registered the task — the foreground request context, tracing intact.
1283
+ // onError marks every error of the capture `background`, the task's own
1284
+ // failure (captureTask's catch) included.
1282
1285
  const serializedTask = async () => {
1283
- await observePhase(PHASES.background("shell-capture"), async (span) => {
1284
- span.setAttribute("rango.shell_key", key);
1285
- // A production page can enqueue several navigation-only captures through
1286
- // viewport prefetching. Let a later document MISS overtake that queued
1287
- // speculative work so it can become a shell HIT before the 15s queue
1288
- // budget expires. Never preempts the active capture. Priority and the
1289
- // backlog ahead at enqueue ride the span (and the skip event below), so
1290
- // a skip-queue-timeout diagnoses itself instead of needing a trace dive.
1291
- const queuePriority = descriptor.navigationOnly
1292
- ? ("navigation" as const)
1293
- : ("document" as const);
1294
- const depths = captureQueueDepths();
1295
- const queueAhead =
1296
- (depths.running ? 1 : 0) +
1297
- depths.document +
1298
- (queuePriority === "navigation" ? depths.navigation : 0);
1299
- span.setAttribute("rango.background.queue_priority", queuePriority);
1300
- span.setAttribute("rango.background.queue_ahead", queueAhead);
1301
- const queueStart = performance.now();
1302
- try {
1303
- await enqueueSerializedCapture(
1304
- () => {
1286
+ await runAsBackgroundWork(reqCtx, () =>
1287
+ observePhase(PHASES.background("shell-capture"), async (span) => {
1288
+ span.setAttribute("rango.shell_key", key);
1289
+ // A production page can enqueue several navigation-only captures through
1290
+ // viewport prefetching. Let a later document MISS overtake that queued
1291
+ // speculative work so it can become a shell HIT before the 15s queue
1292
+ // budget expires. Never preempts the active capture. Priority and the
1293
+ // backlog ahead at enqueue ride the span (and the skip event below), so
1294
+ // a skip-queue-timeout diagnoses itself instead of needing a trace dive.
1295
+ const queuePriority = descriptor.navigationOnly
1296
+ ? ("navigation" as const)
1297
+ : ("document" as const);
1298
+ const depths = captureQueueDepths();
1299
+ const queueAhead =
1300
+ (depths.running ? 1 : 0) +
1301
+ depths.document +
1302
+ (queuePriority === "navigation" ? depths.navigation : 0);
1303
+ span.setAttribute("rango.background.queue_priority", queuePriority);
1304
+ span.setAttribute("rango.background.queue_ahead", queueAhead);
1305
+ const queueStart = performance.now();
1306
+ try {
1307
+ await enqueueSerializedCapture(
1308
+ () => {
1309
+ span.setAttribute(
1310
+ "rango.background.queue_wait_ms",
1311
+ Math.round(performance.now() - queueStart),
1312
+ );
1313
+ return captureTask(span);
1314
+ },
1315
+ { priority: queuePriority },
1316
+ );
1317
+ } catch (error) {
1318
+ if (error instanceof CaptureQueueFullError) {
1319
+ releaseCaptureGuard(key, guardToken);
1320
+ span.setAttribute("rango.background.outcome", "skip-capacity");
1321
+ publishCaptureDebugEvent(descriptor, {
1322
+ key,
1323
+ outcome: "skip-capacity",
1324
+ });
1325
+ return;
1326
+ }
1327
+ if (error instanceof CaptureQueueWaitTimeoutError) {
1328
+ // Dropped unrun after waiting past the queue budget: no backoff (the
1329
+ // route is not doomed, the isolate was busy) — a later request
1330
+ // re-probes the key.
1331
+ releaseCaptureGuard(key, guardToken);
1332
+ span.setAttribute("rango.background.outcome", "skip-queue-timeout");
1305
1333
  span.setAttribute(
1306
1334
  "rango.background.queue_wait_ms",
1307
- Math.round(performance.now() - queueStart),
1335
+ Math.round(error.waitedMs),
1308
1336
  );
1309
- return captureTask(span);
1310
- },
1311
- { priority: queuePriority },
1312
- );
1313
- } catch (error) {
1314
- if (error instanceof CaptureQueueFullError) {
1315
- releaseCaptureGuard(key, guardToken);
1316
- span.setAttribute("rango.background.outcome", "skip-capacity");
1317
- publishCaptureDebugEvent(descriptor, {
1318
- key,
1319
- outcome: "skip-capacity",
1320
- });
1321
- return;
1322
- }
1323
- if (error instanceof CaptureQueueWaitTimeoutError) {
1324
- // Dropped unrun after waiting past the queue budget: no backoff (the
1325
- // route is not doomed, the isolate was busy) — a later request
1326
- // re-probes the key.
1327
- releaseCaptureGuard(key, guardToken);
1328
- span.setAttribute("rango.background.outcome", "skip-queue-timeout");
1329
- span.setAttribute(
1330
- "rango.background.queue_wait_ms",
1331
- Math.round(error.waitedMs),
1332
- );
1333
- publishCaptureDebugEvent(descriptor, {
1334
- key,
1335
- outcome: "skip-queue-timeout",
1336
- queueWaitMs: Math.round(error.waitedMs),
1337
- queuePriority,
1338
- queueAhead,
1339
- });
1340
- return;
1337
+ publishCaptureDebugEvent(descriptor, {
1338
+ key,
1339
+ outcome: "skip-queue-timeout",
1340
+ queueWaitMs: Math.round(error.waitedMs),
1341
+ queuePriority,
1342
+ queueAhead,
1343
+ });
1344
+ return;
1345
+ }
1346
+ throw error;
1341
1347
  }
1342
- throw error;
1343
- }
1344
- });
1348
+ }),
1349
+ );
1345
1350
  };
1346
1351
  // The capture's own task must NOT enter reqCtx._pendingBackgroundTasks: the
1347
1352
  // capture drains that list before rendering (the write-barrier ordering edge),
@@ -833,6 +833,13 @@ export interface RequestContext<
833
833
  /** @internal Per-request error dedup set for onError reporting */
834
834
  _reportedErrors: WeakSet<object>;
835
835
 
836
+ /**
837
+ * @internal The segment a parallel slot's or orphan layout's error came
838
+ * from, for the owning entry's catch (segment-resolution/helpers.ts
839
+ * noteThrower). Per request: an error object can be thrown by several.
840
+ */
841
+ _throwers?: WeakMap<object, { id: string; type: string }>;
842
+
836
843
  /**
837
844
  * @internal The errors this request printed to the server console, so a
838
845
  * failure prints once where it originated (server/print-server-error.ts). A derived context shares it through the prototype.
@@ -1005,6 +1012,7 @@ export type PublicRequestContext<
1005
1012
  | "_inActionRevalidation"
1006
1013
  | "_actionRan"
1007
1014
  | "_reportedErrors"
1015
+ | "_throwers"
1008
1016
  | "_printedErrors"
1009
1017
  | "_renderErrors"
1010
1018
  | "_recoveredHandlerErrors"
@@ -1140,6 +1148,46 @@ export function runWithRequestContext<TEnv, T>(
1140
1148
  return requestContextStorage.run(context, fn);
1141
1149
  }
1142
1150
 
1151
+ /** Inside background work: the errors its onError reports named. */
1152
+ const backgroundWork = new AsyncLocalStorage<WeakSet<object>>();
1153
+
1154
+ /**
1155
+ * Run work whose output goes only to a cache, never to the response of the
1156
+ * request that started it, on `context`: a stale entry's refresh (a "use
1157
+ * cache" function, a cached loader, a cache() record, a document), a
1158
+ * navigation's proactive cache() write, a PPR shell capture. No visitor made
1159
+ * the invalidations in it, so they set no fresh-reads cookie
1160
+ * (cache/tag-invalidation.ts), and onError marks every error reported inside
1161
+ * it `background` (router/error-handling.ts invokeOnError). Its failure is
1162
+ * reported inside it too: a catch outside runs on the visitor's frame.
1163
+ */
1164
+ export function runAsBackgroundWork<TEnv, T>(
1165
+ context: RequestContext<TEnv>,
1166
+ fn: () => T,
1167
+ ): T {
1168
+ const reported = backgroundWork.getStore() ?? new WeakSet<object>();
1169
+ return requestContextStorage.run(context, () =>
1170
+ backgroundWork.run(reported, fn),
1171
+ );
1172
+ }
1173
+
1174
+ /** Whether the caller runs inside runAsBackgroundWork. */
1175
+ export function isBackgroundWork(): boolean {
1176
+ return backgroundWork.getStore() !== undefined;
1177
+ }
1178
+
1179
+ /**
1180
+ * The set onError's dedupe records a reported error in. Background work keeps
1181
+ * its own: an error object both the visitor's render and background work
1182
+ * reach (a memoized rejection, a thrown constant) is reported by each, so a
1183
+ * background report never takes the place of the visitor's.
1184
+ */
1185
+ export function reportedErrorsOf(
1186
+ context: RequestContext<any>,
1187
+ ): WeakSet<object> {
1188
+ return backgroundWork.getStore() ?? context._reportedErrors;
1189
+ }
1190
+
1143
1191
  /**
1144
1192
  * Get the current request context
1145
1193
  * Throws if called outside of a request context
@@ -1745,6 +1793,7 @@ export function createRequestContext<TEnv>(
1745
1793
  _locationState: undefined,
1746
1794
 
1747
1795
  _reportedErrors: new WeakSet<object>(),
1796
+ _throwers: undefined,
1748
1797
  _printedErrors: new WeakSet<object>(),
1749
1798
  _renderErrors: [],
1750
1799
  _metricsStore: undefined,
@@ -345,6 +345,9 @@ export async function dispatch<TEnv = any>(
345
345
  cacheProfiles: router.cacheProfiles,
346
346
  versions,
347
347
  });
348
+ // The request's start, as the production handler sets it: onError's
349
+ // `duration` counts from here.
350
+ requestContext._handlerStart = performance.now();
348
351
  // Cache degradation (reportCacheError -> _reportBackgroundError) reaches the
349
352
  // router's onError, as in the production handler.
350
353
  stages.reportBackgroundErrors(requestContext, req, url, ctx.callOnError);