@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
@@ -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
  });
@@ -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),