@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
@@ -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);
@@ -26,24 +26,30 @@
26
26
  * - navigate() drives the navigation lifecycle as a click does:
27
27
  * useNavigation() reads loading from the call and idle with the commit,
28
28
  * and useLinkStatus().pending is true for a link to the destination until
29
- * the commit is handed to React. A `transition` spec plus a pending
29
+ * the commit, which React holds with it. A `transition` spec plus a pending
30
30
  * Promise seeded via navigate(url, { loaders }) commits through
31
31
  * production's commitInTransition (browser/partial-update.ts), which React
32
32
  * holds: useNavigation() stays loading and the held reader's
33
33
  * useLoader().isLoading reads true until the data lands. Without
34
34
  * `transition` the commit is urgent (production's same-structure hold is
35
- * not modeled). useAction().state stays idle: no action runs.
35
+ * not modeled). navigate(url, { streamUntil }) gives the navigation a
36
+ * response stream that stays open past the commit (isStreaming).
37
+ * useAction().state stays idle: no action runs.
36
38
  * - A spec's transition({ when }) decides every commit with production's
37
39
  * browser decision: navigate() and useRouter().push model kind "push",
38
- * useRouter().replace kind "replace", refresh() kind "revalidate".
39
- * Back/forward, action and optimistic clientUrls() swap decisions are
40
- * e2e territory.
40
+ * useRouter().replace kind "replace", refresh() kind "revalidate", and a
41
+ * back/forward that restores a page kind "pop". Action and optimistic
42
+ * clientUrls() swap decisions are e2e territory.
41
43
  * - Location state reaches readers the way it does in production:
42
44
  * NavigationProvider holds the committed entry's state next to the
43
45
  * payload, so a navigation whose loader the test holds keeps the entry
44
46
  * being left on screen with its own state. A `popstate` event is a
45
- * back/forward onto the entry history.state holds; the tree has one
46
- * location, so no page is restored with it. A server action's state, a
47
+ * back/forward onto the entry history.state holds. When it is the
48
+ * mounted entry or one a navigate() wrote, history still holds the state
49
+ * written there, and another page is on screen, its page is restored
50
+ * from the history cache, with the handle values it had, as production's
51
+ * restore is; otherwise the tree keeps its location. A stale entry's
52
+ * revalidation, a restore that fetches, a server action's state, a
47
53
  * redirect's and an intercept's are e2e territory.
48
54
  * What it DOES cover: client hooks that read NavigationProvider /
49
55
  * OutletContext — useParams, useReverse, useHref, useMount, useNavigation,
@@ -61,6 +67,7 @@
61
67
  import { decideTransitionGatedOff } from "../browser/transition-when.js";
62
68
  import {
63
69
  StrictMode,
70
+ startTransition,
64
71
  useEffect,
65
72
  type ReactNode,
66
73
  type ComponentType,
@@ -78,10 +85,12 @@ import {
78
85
  shouldStartViewTransition,
79
86
  } from "../browser/partial-update.js";
80
87
  import type {
88
+ HandleData,
81
89
  NavigateOptions,
82
90
  NavigateOptionsInternal,
83
91
  NavigationStore,
84
92
  NavigationBridge,
93
+ NavigationUpdate,
85
94
  UpdateSubscriber,
86
95
  } from "../browser/types.js";
87
96
  import type {
@@ -101,6 +110,7 @@ import type {
101
110
  TransitionWhenKind,
102
111
  } from "../types.js";
103
112
  import {
113
+ setCurrentHistoryStateListener,
104
114
  setLocationStateVersion,
105
115
  type LocationStateDefinition,
106
116
  } from "../browser/react/location-state-shared.js";
@@ -447,11 +457,40 @@ export interface TestRouterHandle {
447
457
  * `handles`). They arrive on the response's handle stream, so `useHandle`
448
458
  * readers take them with the page's commit: while React holds the page,
449
459
  * readers keep the values of the page being left.
460
+ *
461
+ * `options.streamUntil` gives the navigation a response stream, as a real
462
+ * response has: it opens in a task after the call, before the commit (the
463
+ * response arrives, and `useNavigation().pendingUrl` clears), and stays open
464
+ * until the promise settles (a page that streams a tail past its commit:
465
+ * `isStreaming` reads true until then). Without it the navigation has no
466
+ * stream. A navigation that a newer one or a back/forward aborts before its
467
+ * response arrives opens none and commits nothing; one aborted after its
468
+ * response arrived stops counting as streaming at the abort, as in
469
+ * production, where its response streams on and is ignored.
470
+ *
471
+ * @example
472
+ * const tail = deferred();
473
+ * await router.navigate("/products/2", { streamUntil: tail.promise });
474
+ * // useNavigation(): idle, isStreaming true
475
+ * await act(async () => tail.resolve());
476
+ * // useNavigation(): idle, isStreaming false
477
+ *
478
+ * A navigation started while React holds a page shows its page once the
479
+ * call resolves, with or without act(). A `<Link>` click over a held page
480
+ * still goes inside `await act(async () => ...)` (docs/testing.md).
481
+ *
482
+ * @example
483
+ * await router.navigate("/products/2", { loaders: [[ProductLoader, next]] });
484
+ * // React holds /products/2
485
+ * await router.navigate("/products/3", { transition: false });
486
+ * // /products/3 is on screen
450
487
  */
451
488
  navigate(
452
489
  url: string,
453
490
  options?: Pick<RenderRouteOptions, "loaders" | "handles"> &
454
- Pick<NavigateOptions, "state" | "replace" | "transition">,
491
+ Pick<NavigateOptions, "state" | "replace" | "transition"> & {
492
+ streamUntil?: Promise<unknown>;
493
+ },
455
494
  ): Promise<void>;
456
495
  /**
457
496
  * Re-render the current location, as router.refresh() does: a spec's
@@ -821,7 +860,49 @@ export async function renderRoute(
821
860
  history?: Pick<NavigateOptionsInternal, "state" | "replace" | "transition">;
822
861
  kind?: TransitionWhenKind;
823
862
  transition?: boolean;
863
+ streamUntil?: Promise<unknown>;
824
864
  };
865
+
866
+ // The page each history entry shows, by the entry's `idx` stamp (the
867
+ // seeded entry has none: 0), so a back/forward can restore it as
868
+ // production restores a history-cache entry. `state` is the entry's
869
+ // history state as the router last wrote it: an entry the test wrote over
870
+ // it (no `idx` reads as 0) is not that page. `handleData` is the page's
871
+ // pushes, taken when it is left.
872
+ interface EntryPage {
873
+ url: URL;
874
+ segments: ResolvedSegment[];
875
+ params: Record<string, string>;
876
+ state: unknown;
877
+ handleData?: HandleData;
878
+ }
879
+ const entries = new Map<number, EntryPage>();
880
+ const entryIndex = (): number => {
881
+ const idx = (window.history.state as { idx?: unknown } | null)?.idx;
882
+ return typeof idx === "number" ? idx : 0;
883
+ };
884
+ let entryOnScreen = entryIndex();
885
+ entries.set(entryOnScreen, {
886
+ url,
887
+ segments: initialSegments,
888
+ params: initialMatch.params,
889
+ state: window.history.state,
890
+ });
891
+ // A write the router makes to the entry history is on (a flash read,
892
+ // Def.write() or delete()) stays that entry's state, as production's
893
+ // per-entry memory follows it (navigation-store.ts). A test's own
894
+ // history.replaceState() does not.
895
+ setCurrentHistoryStateListener(() => {
896
+ store.rememberDisplayedEntry();
897
+ const written = entries.get(entryIndex());
898
+ if (written) written.state = window.history.state;
899
+ });
900
+ /** The page being left keeps its pushes for a back/forward onto it. */
901
+ const leaveEntry = (): void => {
902
+ const left = entries.get(entryOnScreen);
903
+ if (left) left.handleData = eventController.getHandleState().data;
904
+ };
905
+ let popGeneration = 0;
825
906
  // The navigation's lifecycle, as navigation-transaction.ts drives it:
826
907
  // useNavigation() reads loading from the call, delivered before the commit
827
908
  // as a click's notification goes out while its response is in flight, and
@@ -833,10 +914,35 @@ export async function renderRoute(
833
914
  const handle = eventController.startNavigation(
834
915
  new URL(target, TEST_ORIGIN).href,
835
916
  );
836
- // Synchronous, in the caller's task: a <Link> click's own act() scope
837
- // takes it, and no act() of the caller's can overlap it.
838
- act(() => eventController.flushRouteState());
917
+ // Runs in the caller's task, so a <Link> click's own act() takes it.
918
+ // Awaited: React retries a held page with this update, and an unawaited
919
+ // act() drops that suspended retry for good.
920
+ await act(() => eventController.flushRouteState());
839
921
  try {
922
+ const streamUntil = navOptions?.streamUntil;
923
+ if (streamUntil) {
924
+ // The response arrives a task after the request, ahead of the
925
+ // commit (navigation-transaction.ts: the stream opens with it).
926
+ await act(async () => {
927
+ await new Promise((resolve) => setTimeout(resolve, 0));
928
+ });
929
+ if (handle.signal.aborted) return;
930
+ const stream = handle.startStreaming();
931
+ await act(() => eventController.flushRouteState());
932
+ // A navigation that is left stops counting as streaming at the abort
933
+ // (partial-update.ts endStream); the abort's notification goes out
934
+ // with the newer navigation's.
935
+ handle.signal.addEventListener("abort", () => stream.end(), {
936
+ once: true,
937
+ });
938
+ const end = async (): Promise<void> => {
939
+ await act(() => {
940
+ stream.end();
941
+ eventController.flushRouteState();
942
+ });
943
+ };
944
+ void streamUntil.then(end, end);
945
+ }
840
946
  await commitNavigation(handle, target, navOptions);
841
947
  } finally {
842
948
  // Back to idle when the commit never came (a render that threw).
@@ -922,6 +1028,7 @@ export async function renderRoute(
922
1028
  // A newer navigate() started meanwhile: this one commits nothing, as
923
1029
  // production's (browser/partial-update.ts) returns on its aborted signal.
924
1030
  if (handle.signal.aborted) return;
1031
+ leaveEntry();
925
1032
  handle.complete(nextUrl);
926
1033
  eventController.setParams(match.params);
927
1034
  store.setCurrentUrl(nextUrl.href);
@@ -934,6 +1041,13 @@ export async function renderRoute(
934
1041
  );
935
1042
  eventController.commitLocationState(historyState);
936
1043
  }
1044
+ entryOnScreen = entryIndex();
1045
+ entries.set(entryOnScreen, {
1046
+ url: nextUrl,
1047
+ segments,
1048
+ params: match.params,
1049
+ state: window.history.state,
1050
+ });
937
1051
  const emit: UpdateSubscriber = (update) => store.emitUpdate(update);
938
1052
  await act(async () => {
939
1053
  // As production's emitters name their cause (suspense-audit.ts I6).
@@ -961,17 +1075,90 @@ export async function renderRoute(
961
1075
  return navigate(current.pathname + current.search, { kind: "revalidate" });
962
1076
  };
963
1077
 
1078
+ // A back/forward onto the mounted entry or one a navigation wrote, whose
1079
+ // page is not the one on screen: its page is restored from the entry, as production's
1080
+ // history-cache restore does (navigation-bridge.ts handlePopstate). Not
1081
+ // modeled: a stale entry's revalidation and a restore that has to fetch.
1082
+ const restoreEntry = async (index: number, page: EntryPage) => {
1083
+ const generation = ++popGeneration;
1084
+ eventController.abortNavigation();
1085
+ const historyState = window.history.state;
1086
+ // transition({ when }) decides the restore (kind "pop") against the
1087
+ // entry being left.
1088
+ const gatedOff = decideTransitionGatedOff(page.segments, () => ({
1089
+ kind: "pop",
1090
+ from: {
1091
+ url: eventController.getLocation().href,
1092
+ params: eventController.getParams(),
1093
+ routeName: leaf.name,
1094
+ state: entries.get(entryOnScreen)?.state ?? null,
1095
+ },
1096
+ to: {
1097
+ url: page.url,
1098
+ params: page.params,
1099
+ routeName: leaf.name,
1100
+ state: historyState,
1101
+ },
1102
+ }));
1103
+ const root = await renderSegments(page.segments, {
1104
+ forceAwait: true,
1105
+ outletPending: options.outletPending,
1106
+ transitionGatedOff: gatedOff,
1107
+ });
1108
+ // A later back/forward owns the screen.
1109
+ if (generation !== popGeneration) return;
1110
+ leaveEntry();
1111
+ entryOnScreen = index;
1112
+ store.setCurrentUrl(page.url.href);
1113
+ store.setSegmentIds(page.segments.map((s) => s.id));
1114
+ const update: NavigationUpdate = {
1115
+ root,
1116
+ metadata: {
1117
+ ...makeMetadata(page.url.pathname, page.segments, page.params),
1118
+ isPartial: true,
1119
+ cachedHandleData: page.handleData,
1120
+ },
1121
+ };
1122
+ await act(async () => {
1123
+ // The restored entry's location and params, set where its tree is
1124
+ // handed over, so the hooks take them in that batch.
1125
+ eventController.setLocation(page.url);
1126
+ eventController.setParams(page.params);
1127
+ eventController.commitLocationState(historyState);
1128
+ auditTreeCause("popstate");
1129
+ if (!shouldStartViewTransition(page.segments, gatedOff)) {
1130
+ store.emitUpdate(update);
1131
+ } else {
1132
+ startTransition(() => {
1133
+ store.emitUpdate({ ...update, inTransition: true });
1134
+ });
1135
+ }
1136
+ });
1137
+ };
1138
+
964
1139
  let prefetchRoot: HTMLElement | undefined;
965
1140
  const bridge: NavigationBridge = {
966
1141
  navigate: (target, navigateOptions) =>
967
1142
  navigate(target, { history: navigateOptions ?? {} }),
968
1143
  refresh: () => refresh(),
969
- // Back/forward onto the entry history.state holds. The tree has one
970
- // location, so nothing is restored: a treeless commit of that entry, as
971
- // production's traversal that keeps every segment
972
- // (navigation-transaction.ts commit). Flushed, not left to the notify
973
- // timer: the dispatching test's act() has returned by then.
1144
+ // Back/forward onto the entry history.state holds. Its page is restored
1145
+ // when the mount or a navigation wrote the entry, history still holds that
1146
+ // state and another page is on screen (restoreEntry). Otherwise the tree
1147
+ // keeps its location: a treeless commit of that entry, as production's
1148
+ // traversal that keeps every segment (navigation-transaction.ts commit).
1149
+ // Flushed, not left to the notify timer: the dispatching test's act()
1150
+ // has returned by then.
974
1151
  handlePopstate: async () => {
1152
+ const index = entryIndex();
1153
+ const page = entries.get(index);
1154
+ if (
1155
+ page &&
1156
+ index !== entryOnScreen &&
1157
+ page.state === window.history.state
1158
+ ) {
1159
+ await restoreEntry(index, page);
1160
+ return;
1161
+ }
975
1162
  eventController.commitLocationState(window.history.state, true);
976
1163
  eventController.flushRouteState();
977
1164
  },
@@ -1091,6 +1278,7 @@ export async function renderRoute(
1091
1278
  loaders: navOptions?.loaders,
1092
1279
  handles: navOptions?.handles,
1093
1280
  transition: navOptions?.transition,
1281
+ streamUntil: navOptions?.streamUntil,
1094
1282
  history:
1095
1283
  navOptions?.state !== undefined || navOptions?.replace
1096
1284
  ? navOptions
@@ -76,7 +76,6 @@ import {
76
76
  type PprReplayStatus,
77
77
  type ShellStatus,
78
78
  } from "./shell-status.js";
79
- import { WIRE } from "../wire-params.js";
80
79
  import { writePartialParams } from "../browser/wire-protocol.js";
81
80
 
82
81
  /** Options for {@link serveShellRequest}. */
@@ -116,7 +115,13 @@ export interface ServeShellRequestOptions<TEnv = any> {
116
115
  * and segments take an action's revalidation defaults; it renders live, as
117
116
  * an action does, so neither the route cache nor the document cache answers
118
117
  * or stores it, and a `ppr` route's `replayStatus` is
119
- * `{ outcome: "BYPASS", reason: "stale" }`.
118
+ * `{ outcome: "BYPASS", reason: "stale" }`. `boundary` names the error
119
+ * segment of a thrown action's boundary the page at `from` shows
120
+ * (`_rsc_boundary`), as the follow-up an earlier action owes that page
121
+ * sends it, with `stale: true` and no `segments`: the response renders
122
+ * every segment above the boundary and nothing at or below it, claims the
123
+ * boundary in `matched` and sends no segment for it. The server reads it
124
+ * only beside `stale`.
120
125
  */
121
126
  partial?:
122
127
  | true
@@ -128,6 +133,7 @@ export interface ServeShellRequestOptions<TEnv = any> {
128
133
  fill?: boolean;
129
134
  interceptSource?: string;
130
135
  stale?: boolean;
136
+ boundary?: string;
131
137
  };
132
138
  /**
133
139
  * POST this form as a submit before hydration: the no-JS request the
@@ -627,20 +633,23 @@ async function buildRequest(
627
633
  fill = false,
628
634
  interceptSource,
629
635
  stale = false,
636
+ boundary,
630
637
  } = partial === true ? {} : partial;
631
638
  if (stale && prefetch) {
632
639
  throw new Error(
633
640
  "serveShellRequest: partial.stale cannot be combined with partial.prefetch",
634
641
  );
635
642
  }
636
- const target = new URL(url);
637
- target.searchParams.set(WIRE.partial, "true");
638
- target.searchParams.set(WIRE.segments, segments.join(","));
639
- if (rewriteTarget) target.searchParams.set(WIRE.rewriteTarget, rewriteTarget);
640
- // As the browser sends them: browser/navigation-client.ts (fill, stale) and
641
- // browser/prefetch/fetch.ts (prefetch).
642
- if (fill) target.searchParams.set(WIRE.fill, "1");
643
- if (stale) target.searchParams.set(WIRE.stale, "true");
643
+ // As the browser writes them: browser/navigation-client.ts, and
644
+ // browser/prefetch/fetch.ts for the prefetch header.
645
+ const target = writePartialParams(new URL(url), {
646
+ segmentIds: segments,
647
+ emptySegments: "send",
648
+ stale,
649
+ boundary,
650
+ rewriteTarget,
651
+ fill,
652
+ });
644
653
  if (prefetch && !headers.has(PREFETCH_HEADER)) {
645
654
  headers.set(PREFETCH_HEADER, "1");
646
655
  }
@@ -81,19 +81,38 @@ export interface OnErrorContext<TEnv = any> {
81
81
  actionId?: string;
82
82
  /** Environment/bindings (platform context) */
83
83
  env?: TEnv;
84
- /** Duration from request start to error (milliseconds) */
84
+ /**
85
+ * Duration from request start to error (milliseconds). For a `background`
86
+ * error it counts from the start of the request that started the work, so
87
+ * it includes that response and any wait before the work ran.
88
+ */
85
89
  duration?: number;
86
90
  /**
87
91
  * Whether this is a partial/navigation request, on an error the segment
88
- * walk catches (a handler that throws, a DSL loader that rejects): `true`
89
- * for a navigation, unset for a document. An error in the background
90
- * re-render of a `cache()` record (a stale HIT's refresh, the write of a
91
- * navigation that kept a layout mounted) reports the request that started
92
- * it. A streamed (`loading()`) handler's rejection does not carry it, in
93
- * either render; the `routing` phase reports `false`.
92
+ * walk catches (a handler that throws, a streamed `loading()` handler that
93
+ * rejects, a DSL loader that rejects): `true` for a navigation, unset for a
94
+ * document. A `cache()` record's refresh or proactive write reports the
95
+ * request that started it; a PPR shell capture renders a document. The
96
+ * `routing` phase reports `false`.
94
97
  */
95
98
  isPartial?: boolean;
96
- /** Whether an error boundary caught the error */
99
+ /**
100
+ * `true` when the error came from work whose output goes to a cache and
101
+ * never to the response of the request that started it: a stale `cache()`
102
+ * record's refresh, the write of a navigation that kept a layout mounted, a
103
+ * stale document cache entry's re-run, a PPR shell capture, or a stale
104
+ * `"use cache"` function's or cached loader's refresh, the work's own
105
+ * failure included. `request` and `url` are the starting request's (a
106
+ * shell capture's handler errors name the page it captures). A
107
+ * `router.prerender()` warm's own render is its request's: unset, while a
108
+ * shell capture it makes is `true`. Unset for every other error.
109
+ */
110
+ background?: boolean;
111
+ /**
112
+ * Whether an error boundary caught the error and its fallback reached the
113
+ * response. `false` on a `background` error: the failed render is never
114
+ * stored, so no visitor sees the fallback.
115
+ */
97
116
  handledByBoundary?: boolean;
98
117
  /** Stack trace (if available) */
99
118
  stack?: string;
@@ -19,6 +19,11 @@ export const WIRE = {
19
19
  rewriteTarget: "_rsc_rt",
20
20
  /** A stale request: `true` (router/navigation-snapshot.ts). */
21
21
  stale: "_rsc_stale",
22
+ /**
23
+ * The id of an action's error boundary on screen that a stale request
24
+ * holds: read only beside `stale` (router/navigation-snapshot.ts).
25
+ */
26
+ boundary: "_rsc_boundary",
22
27
  /** A fill request (docs/design/prefetch-false.md): `1`. */
23
28
  fill: "_rsc_fill",
24
29
  /** A loader fetch's loader id (rsc/loader-fetch.ts). */