@rangojs/router 0.26.1 → 0.26.3

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 (47) hide show
  1. package/dist/bin/rango.js +11 -7
  2. package/dist/types/cache/cache-error.d.ts +2 -0
  3. package/dist/types/decode-loader-results.d.ts +12 -1
  4. package/dist/types/errors.d.ts +20 -0
  5. package/dist/types/router/loader-resolution.d.ts +10 -4
  6. package/dist/types/router/types.d.ts +3 -1
  7. package/dist/types/rsc/types.d.ts +14 -0
  8. package/dist/types/server/print-server-error.d.ts +27 -0
  9. package/dist/types/server/request-context.d.ts +6 -1
  10. package/dist/types/ssr/index.d.ts +14 -0
  11. package/dist/types/ssr/shell-error-document.d.ts +24 -0
  12. package/dist/types/ssr/ssr-root.d.ts +6 -0
  13. package/dist/types/testing/render-route.d.ts +14 -1
  14. package/dist/types/types/boundaries.d.ts +4 -1
  15. package/dist/vite/index.js +14 -9
  16. package/package.json +1 -1
  17. package/skills/loader/SKILL.md +53 -4
  18. package/skills/ppr/SKILL.md +19 -10
  19. package/skills/route/SKILL.md +4 -1
  20. package/skills/router-setup/SKILL.md +29 -1
  21. package/skills/server-actions/SKILL.md +3 -1
  22. package/skills/testing/client-components.md +30 -16
  23. package/src/cache/cache-error.ts +11 -2
  24. package/src/decode-loader-results.ts +29 -1
  25. package/src/errors.ts +39 -0
  26. package/src/router/loader-resolution.ts +41 -6
  27. package/src/router/segment-resolution/fresh.ts +2 -0
  28. package/src/router/segment-resolution/helpers.ts +12 -1
  29. package/src/router/types.ts +2 -0
  30. package/src/router.ts +16 -1
  31. package/src/rsc/handler.ts +31 -9
  32. package/src/rsc/loader-fetch.ts +32 -7
  33. package/src/rsc/progressive-enhancement.ts +13 -4
  34. package/src/rsc/render-pipeline.ts +9 -1
  35. package/src/rsc/rsc-rendering.ts +4 -0
  36. package/src/rsc/server-action.ts +16 -0
  37. package/src/rsc/ssr-setup.ts +23 -1
  38. package/src/rsc/types.ts +15 -0
  39. package/src/server/print-server-error.ts +87 -0
  40. package/src/server/request-context.ts +8 -0
  41. package/src/ssr/index.tsx +113 -10
  42. package/src/ssr/shell-error-document.tsx +139 -0
  43. package/src/ssr/ssr-root.tsx +15 -5
  44. package/src/testing/render-route.tsx +52 -11
  45. package/src/theme/ThemeProvider.tsx +31 -13
  46. package/src/types/boundaries.ts +8 -2
  47. package/src/vite/plugins/virtual-entries.ts +10 -6
@@ -2,6 +2,7 @@ import { appendMetric } from "../router/metrics.js";
2
2
  import type { MetricsStore } from "../server/context.js";
3
3
  import { observePhase, PHASES } from "../router/instrument.js";
4
4
  import { _getRequestContext } from "../server/request-context.js";
5
+ import { printServerError } from "../server/print-server-error.js";
5
6
  import type { TraceSpan } from "../router/tracing.js";
6
7
  import type { RenderMode, RenderPhase } from "../router/timeout.js";
7
8
  import type { HandlerContext } from "./handler-context.js";
@@ -156,11 +157,18 @@ function createFlightStage<TEnv>(
156
157
  input.payload.metadata.devDiscoveryEpoch = input.ctx.devDiscoveryEpoch;
157
158
  }
158
159
  // Read once here: onError can fire outside the ALS frame.
159
- const renderErrors = _getRequestContext()?._renderErrors;
160
+ const requestContext = _getRequestContext();
161
+ const renderErrors = requestContext?._renderErrors;
160
162
  const stream = input.ctx.renderToReadableStream<RscPayload>(input.payload, {
161
163
  temporaryReferences: input.temporaryReferences,
162
164
  onError: (error: unknown) => {
163
165
  renderErrors?.push(error);
166
+ // The client gets a masked copy (an error row); the original prints here.
167
+ printServerError(
168
+ error,
169
+ { label: "Rendering error", pathname: input.url.pathname },
170
+ requestContext,
171
+ );
164
172
  input.ctx.callOnError(error, "rendering", {
165
173
  request: input.request,
166
174
  url: input.url,
@@ -28,6 +28,7 @@ import { getSSRSetup, createSsrHtmlStage, isRscRequest } from "./ssr-setup.js";
28
28
  import type { RscPayload } from "./types.js";
29
29
  import type { SSRModule } from "./types.js";
30
30
  import type { RequestContext } from "../server/request-context.js";
31
+ import { markServerErrorPrinted } from "../server/print-server-error.js";
31
32
  import type { PprReplayBypassReason } from "./shell-capture-constants.js";
32
33
  import {
33
34
  createResponseWithMergedHeaders,
@@ -1988,8 +1989,11 @@ function serveShellHit(
1988
1989
  // The HIT request's origin — same host as the capture's (key-scoped).
1989
1990
  origin: url.origin,
1990
1991
  // The document cache reads the list before storing this composite.
1992
+ // resumeShellHTML printed what started in the resume, or it is a copy
1993
+ // whose original printed upstream: a refusing writer prints nothing.
1991
1994
  onError: (error) => {
1992
1995
  reqCtx._renderErrors?.push(error);
1996
+ markServerErrorPrinted(error, reqCtx);
1993
1997
  },
1994
1998
  }),
1995
1999
  );
@@ -17,9 +17,11 @@
17
17
 
18
18
  import { requestHeaders } from "../server/request-headers.js";
19
19
  import {
20
+ _getRequestContext,
20
21
  getRequestContext,
21
22
  setRequestContextParams,
22
23
  } from "../server/request-context.js";
24
+ import { printServerError } from "../server/print-server-error.js";
23
25
  import { observePhase, PHASES } from "../router/instrument.js";
24
26
  import { resolveActionRefId } from "../router/is-action.js";
25
27
  import type { TraceSpan } from "../router/tracing.js";
@@ -161,6 +163,12 @@ export async function executeServerAction<TEnv>(
161
163
  actionId,
162
164
  handledByBoundary: !!decodeBoundary,
163
165
  });
166
+ // The server log gets the decode error itself, not the sanitized wrapper.
167
+ printServerError(
168
+ error,
169
+ { label: "Action decode error", name: actionId, pathname: url.pathname },
170
+ _getRequestContext(),
171
+ );
164
172
 
165
173
  if (decodeBoundary) {
166
174
  return {
@@ -248,6 +256,14 @@ export async function executeServerAction<TEnv>(
248
256
 
249
257
  returnValue = { ok: false, data: actionResultData };
250
258
  actionStatus = 500;
259
+ // Flight writes the error into the action's result without reporting it:
260
+ // printed here, once. A thrown non-redirect Response prints the Error that
261
+ // replaced it.
262
+ printServerError(
263
+ actionResultData,
264
+ { label: "Action error", name: actionId, pathname: url.pathname },
265
+ _getRequestContext(),
266
+ );
251
267
 
252
268
  // Try to render error boundary.
253
269
  // Report the action error first so it is not lost if matchError throws.
@@ -23,6 +23,7 @@ import {
23
23
  } from "../router/content-negotiation.js";
24
24
  import { _getRequestContext } from "../server/request-context.js";
25
25
  import { renderedLocation } from "./full-payload.js";
26
+ import { markServerErrorPrinted } from "../server/print-server-error.js";
26
27
 
27
28
  export type SSRSetup = readonly [SSRModule, SSRStreamMode];
28
29
 
@@ -134,9 +135,30 @@ export function createSsrHtmlStage<TEnv>(
134
135
  origin: location.origin,
135
136
  // A component that throws inside a Suspense boundary leaves an
136
137
  // errored boundary in a completed document; the document cache
137
- // reads the list before storing it.
138
+ // reads the list before storing it. renderHTML printed what Fizz
139
+ // reports, or it was printed where it originated (a loader's or a
140
+ // Flight copy), so the writer that refuses it prints nothing more.
138
141
  onError: (error) => {
139
142
  renderErrors?.push(error);
143
+ markServerErrorPrinted(error, reqCtx);
144
+ },
145
+ // Reported once, phase "rendering", only when the error started in
146
+ // the SSR render: a copy (a loader's masked rebuild, a Flight error,
147
+ // a payload failure) was reported with its original where it started,
148
+ // and a loader's notFound()/redirect() is control flow. Marked
149
+ // printed, so the handler's catch neither reports nor prints what
150
+ // dev rethrows.
151
+ onShellError: ({ error, originated, status, location }) => {
152
+ if (status !== undefined) reqCtx?._setStatus(status);
153
+ if (location) reqCtx?.res.headers.set("Location", location);
154
+ markServerErrorPrinted(error, reqCtx);
155
+ if (!originated) return;
156
+ options.ctx.callOnError(error, "rendering", {
157
+ request: options.request,
158
+ url: options.url,
159
+ env: options.env,
160
+ handledByBoundary: false,
161
+ });
140
162
  },
141
163
  }),
142
164
  ...(options.init && { init: options.init }),
package/src/rsc/types.ts CHANGED
@@ -215,6 +215,21 @@ export interface SSRRenderOptions {
215
215
  * otherwise completed document. React's default console.error is kept.
216
216
  */
217
217
  onError?: (error: unknown) => void;
218
+
219
+ /**
220
+ * Called once when the render failed before any HTML was produced (a shell
221
+ * error), before renderHTML answers it with a recovery document or rethrows
222
+ * it to the host (dev). `status` and `location` are the answer's, absent on
223
+ * a rethrow. `originated`: the error started in this render; a copy (a
224
+ * loader's masked rebuild, an error Flight decoded, a payload that did not
225
+ * decode) was reported where it started. See ssr/shell-error-document.tsx.
226
+ */
227
+ onShellError?: (shellError: {
228
+ error: unknown;
229
+ originated: boolean;
230
+ status?: number;
231
+ location?: string;
232
+ }) => void;
218
233
  }
219
234
 
220
235
  /**
@@ -0,0 +1,87 @@
1
+ /**
2
+ * The server console's record of a failure: its original error, message and
3
+ * stack, printed once per request where it originated, in dev and production.
4
+ * Only what reaches the client is masked (router/error-handling.ts
5
+ * createErrorInfo, React's Flight error rows). A leaf module: the cache layer
6
+ * reads it too.
7
+ */
8
+ import { isAbortedRender, isLoaderSignalError, isSkip } from "../errors.js";
9
+
10
+ /** The request context fields the record reads. */
11
+ interface PrintContext {
12
+ /** The errors this request printed (request-context.ts). */
13
+ _printedErrors?: WeakSet<object>;
14
+ request?: Request;
15
+ }
16
+
17
+ /**
18
+ * Control flow, never printed: notFound(), a redirect (a thrown Response or a
19
+ * loader's redirect marker), Skip, and the render's own cancellation.
20
+ */
21
+ function isControlFlow(
22
+ error: unknown,
23
+ requestContext: PrintContext | undefined,
24
+ ): boolean {
25
+ return (
26
+ error instanceof Response ||
27
+ isLoaderSignalError(error) ||
28
+ isSkip(error) ||
29
+ isAbortedRender(error, requestContext?.request?.signal)
30
+ );
31
+ }
32
+
33
+ function printedSet(
34
+ requestContext: PrintContext | undefined,
35
+ ): WeakSet<object> | undefined {
36
+ if (!requestContext) return undefined;
37
+ return (requestContext._printedErrors ??= new WeakSet());
38
+ }
39
+
40
+ /**
41
+ * Print a server-side failure where it originates:
42
+ * `[RSC] <label> (<name>) <pathname>:` and the error object. Once per request:
43
+ * another place that sees the same error object in `requestContext` prints
44
+ * nothing more. A caller that can run outside the request's ALS frame (a
45
+ * Flight onError) passes the context it captured.
46
+ */
47
+ export function printServerError(
48
+ error: unknown,
49
+ origin: { label: string; name?: string; pathname?: string },
50
+ requestContext: PrintContext | undefined,
51
+ ): void {
52
+ if (isControlFlow(error, requestContext)) return;
53
+ if (typeof error === "object" && error !== null) {
54
+ const printed = printedSet(requestContext);
55
+ if (printed?.has(error)) return;
56
+ printed?.add(error);
57
+ }
58
+ const name = origin.name ? ` (${origin.name})` : "";
59
+ const path = origin.pathname ? ` ${origin.pathname}` : "";
60
+ console.error(`[RSC] ${origin.label}${name}${path}:`, error);
61
+ }
62
+
63
+ /**
64
+ * Record an error this module did not print as done: the SSR render printed it
65
+ * in its own module graph, or it is a copy (a loader's masked rebuild, an
66
+ * error Flight decoded) whose original printed where it started.
67
+ */
68
+ export function markServerErrorPrinted(
69
+ error: unknown,
70
+ requestContext: PrintContext | undefined,
71
+ ): void {
72
+ if (typeof error === "object" && error !== null) {
73
+ printedSet(requestContext)?.add(error);
74
+ }
75
+ }
76
+
77
+ /** Whether this request printed `error`, or recorded it as done. */
78
+ export function wasServerErrorPrinted(
79
+ error: unknown,
80
+ requestContext: PrintContext | undefined,
81
+ ): boolean {
82
+ return (
83
+ typeof error === "object" &&
84
+ error !== null &&
85
+ requestContext?._printedErrors?.has(error) === true
86
+ );
87
+ }
@@ -909,6 +909,12 @@ export interface RequestContext<
909
909
  /** @internal Per-request error dedup set for onError reporting */
910
910
  _reportedErrors: WeakSet<object>;
911
911
 
912
+ /**
913
+ * @internal The errors this request printed to the server console, so a
914
+ * failure prints once where it originated (server/print-server-error.ts). A derived context shares it through the prototype.
915
+ */
916
+ _printedErrors?: WeakSet<object>;
917
+
912
918
  /**
913
919
  * @internal Errors this response's Flight or Fizz render reported through
914
920
  * onError: a component that threw does not fail the stream, which completes
@@ -1090,6 +1096,7 @@ export type PublicRequestContext<
1090
1096
  | "_inActionRevalidation"
1091
1097
  | "_actionRan"
1092
1098
  | "_reportedErrors"
1099
+ | "_printedErrors"
1093
1100
  | "_renderErrors"
1094
1101
  | "_recoveredHandlerErrors"
1095
1102
  | "_renderBarrier"
@@ -1828,6 +1835,7 @@ export function createRequestContext<TEnv>(
1828
1835
  _locationState: undefined,
1829
1836
 
1830
1837
  _reportedErrors: new WeakSet<object>(),
1838
+ _printedErrors: new WeakSet<object>(),
1831
1839
  _renderErrors: [],
1832
1840
  _metricsStore: undefined,
1833
1841
  _renderForeground: undefined,
package/src/ssr/index.tsx CHANGED
@@ -12,6 +12,16 @@ import {
12
12
  SHELL_CAPTURE_MAX_WAIT_MS,
13
13
  } from "../rsc/shell-capture-constants.js";
14
14
  import { isThenable } from "../handles/is-thenable.js";
15
+ import {
16
+ isAbortedRender,
17
+ isFlightDecodedError,
18
+ isLoaderSignalError,
19
+ } from "../errors.js";
20
+ import { isRebuiltLoaderError } from "../decode-loader-results.js";
21
+ import {
22
+ answerShellError,
23
+ type ShellErrorRecovery,
24
+ } from "./shell-error-document.js";
15
25
  import type { ErrorPhase } from "../types.js";
16
26
  import type { HeadScriptsOption } from "../vite/plugin-types.js";
17
27
 
@@ -141,6 +151,21 @@ export interface SSRRenderOptions {
141
151
  * otherwise completed document. React's default console.error is kept.
142
152
  */
143
153
  onError?: (error: unknown) => void;
154
+
155
+ /**
156
+ * Called once when the render failed before any HTML was produced (a shell
157
+ * error), before renderHTML answers it or rethrows it. `status` and
158
+ * `location` are the answer's; both are absent when the error is rethrown
159
+ * to the host (dev). `originated`: the error started in this render. When
160
+ * false it is a copy (a loader's masked rebuild, an error Flight decoded, a
161
+ * payload that did not decode) whose original was reported where it started.
162
+ */
163
+ onShellError?: (shellError: {
164
+ error: unknown;
165
+ originated: boolean;
166
+ status?: number;
167
+ location?: string;
168
+ }) => void;
144
169
  }
145
170
 
146
171
  /**
@@ -459,6 +484,23 @@ async function settleSsrFalseFirstFlush(
459
484
  }
460
485
  }
461
486
 
487
+ /**
488
+ * Whether this render prints `error`. A failure prints once, where it
489
+ * originated: what reaches the SSR render from the server is a copy (a
490
+ * loader's masked rebuild, an error Flight decoded) whose original the server
491
+ * printed, and a loader's notFound()/redirect() is control flow, as is the
492
+ * render's own abort. A component that throws during this render is the
493
+ * original.
494
+ */
495
+ function originatedHere(error: unknown): boolean {
496
+ return (
497
+ !isLoaderSignalError(error) &&
498
+ !isRebuiltLoaderError(error) &&
499
+ !isFlightDecodedError(error) &&
500
+ !isAbortedRender(error)
501
+ );
502
+ }
503
+
462
504
  /**
463
505
  * Route an SSR error through the deps.onError notification callback with the
464
506
  * "rendering" phase. Swallows callback failures so a broken reporter never
@@ -849,13 +891,22 @@ export function createSSRHandler<TEnv = unknown>(deps: SSRDependencies<TEnv>) {
849
891
  search,
850
892
  origin,
851
893
  onError: onRenderError,
894
+ onShellError,
852
895
  } = options ?? {};
853
896
 
897
+ // What a shell error is answered with (ssr/shell-error-document.tsx).
898
+ let flight: ReadableStream<Uint8Array> | undefined;
899
+ let recovery: ShellErrorRecovery | undefined;
900
+ let payloadFailed = false;
901
+ // The errors Fizz reported, which its onError below printed.
902
+ let reportedByFizz: Set<unknown> | undefined;
903
+
854
904
  try {
855
905
  // Tee the stream:
856
906
  // - rscStream1: For SSR rendering (deserialize to React VDOM)
857
907
  // - rscStream2: For browser hydration (inject as __FLIGHT_DATA__)
858
908
  const [rscStream1, rscStream2] = rscStream.tee();
909
+ flight = rscStream2;
859
910
 
860
911
  // Deserialization starts here (not lazily on SsrRoot's first render) so
861
912
  // the auto-raise below can read payload metadata before the fizz call.
@@ -880,6 +931,10 @@ export function createSSRHandler<TEnv = unknown>(deps: SSRDependencies<TEnv>) {
880
931
  // tree while static-part search reads render what the key names.
881
932
  search,
882
933
  origin,
934
+ // Runs before Fizz rejects (attached when the tree build starts).
935
+ onTreeError: () => {
936
+ recovery = undefined;
937
+ },
883
938
  });
884
939
 
885
940
  const bootstrap = await resolveBootstrap(deps);
@@ -888,7 +943,18 @@ export function createSSRHandler<TEnv = unknown>(deps: SSRDependencies<TEnv>) {
888
943
  // Awaiting the payload here is latency-neutral: fizz cannot emit even
889
944
  // <html> until this same promise settles. A rejection lands in the
890
945
  // catch below — the same reportRenderError path a shell error takes.
891
- const awaitsBeforeFlush = hasAwaitBeforeFlush(await payload);
946
+ let decoded: Awaited<typeof payload>;
947
+ try {
948
+ decoded = await payload;
949
+ } catch (error) {
950
+ payloadFailed = true;
951
+ throw error;
952
+ }
953
+ recovery = {
954
+ bootstrap,
955
+ themeConfig: decoded.metadata?.themeConfig ?? null,
956
+ };
957
+ const awaitsBeforeFlush = hasAwaitBeforeFlush(decoded);
892
958
  const progressiveChunkSize =
893
959
  deps.progressiveChunkSize ??
894
960
  (awaitsBeforeFlush ? Number.MAX_SAFE_INTEGER : undefined);
@@ -904,13 +970,12 @@ export function createSSRHandler<TEnv = unknown>(deps: SSRDependencies<TEnv>) {
904
970
  formState,
905
971
  nonce,
906
972
  ...(progressiveChunkSize !== undefined && { progressiveChunkSize }),
907
- ...(onRenderError && {
908
- onError: (error: unknown) => {
909
- onRenderError(error);
910
- // What React's default onError does (it only logs).
911
- console.error(error);
912
- },
913
- }),
973
+ onError: (error: unknown) => {
974
+ onRenderError?.(error);
975
+ (reportedByFizz ??= new Set()).add(error);
976
+ // What React's default onError does (it only logs).
977
+ if (originatedHere(error)) console.error(error);
978
+ },
914
979
  }),
915
980
  );
916
981
 
@@ -938,12 +1003,49 @@ export function createSSRHandler<TEnv = unknown>(deps: SSRDependencies<TEnv>) {
938
1003
  // Inject RSC payload into HTML as <script nonce="...">__FLIGHT_DATA__</script>
939
1004
  return html.pipeThrough(injectRSCPayload(rscStream2, { nonce }));
940
1005
  } catch (error) {
941
- reportRenderError(onError, error);
942
- throw error;
1006
+ // Fizz printed what it reported. Printed here: what never reached Fizz
1007
+ // and started in this render, a bootstrap failure or a payload that did
1008
+ // not decode without a Flight error row (no `digest`: the stream broke).
1009
+ // A Flight copy printed with its original in the Flight render. The
1010
+ // handler's catch skips what the html stage marked printed.
1011
+ if (!isLoaderSignalError(error)) reportRenderError(onError, error);
1012
+ if (originatedHere(error) && !reportedByFizz?.has(error)) {
1013
+ console.error(error);
1014
+ }
1015
+ const originated = !payloadFailed && originatedHere(error);
1016
+ if (shellErrorReachesHost(error)) {
1017
+ onShellError?.({ error, originated });
1018
+ throw error;
1019
+ }
1020
+ const answer = await answerShellError(
1021
+ deps,
1022
+ error,
1023
+ recovery,
1024
+ flight,
1025
+ nonce,
1026
+ );
1027
+ onShellError?.({
1028
+ error,
1029
+ originated,
1030
+ status: answer.status,
1031
+ location: answer.location,
1032
+ });
1033
+ return answer.body;
943
1034
  }
944
1035
  };
945
1036
  }
946
1037
 
1038
+ /**
1039
+ * In dev a shell error reaches the host as the original error object,
1040
+ * so Vite's overlay shows its message and source-mapped stack. A loader's
1041
+ * notFound()/redirect() is control flow and is answered as in production.
1042
+ * This is the one gate: returning false here makes dev answer every shell
1043
+ * error as production does.
1044
+ */
1045
+ function shellErrorReachesHost(error: unknown): boolean {
1046
+ return process.env.NODE_ENV !== "production" && !isLoaderSignalError(error);
1047
+ }
1048
+
947
1049
  /**
948
1050
  * Create the PPR shell capture handler.
949
1051
  *
@@ -1269,6 +1371,7 @@ export function createShellResumeHandler<TEnv = unknown>(
1269
1371
  onError: (error) => {
1270
1372
  opts.onError?.(error);
1271
1373
  reportRenderError(onError, error);
1374
+ if (originatedHere(error)) console.error(error);
1272
1375
  },
1273
1376
  nonce,
1274
1377
  }),
@@ -0,0 +1,139 @@
1
+ /**
2
+ * What renderHTML answers when React's server render fails its shell: an
3
+ * error with no `<Suspense>` above it (a loader read, a component that throws
4
+ * only on the server). Fizz runs no error boundaries, so renderToReadableStream
5
+ * rejects; rethrown, the host would answer its own raw 500 (a stack with
6
+ * server paths in production).
7
+ *
8
+ * The answer, in order:
9
+ * - a loader `redirect()` with no state: a real 3xx (nothing has been sent);
10
+ * - the payload decoded and the tree built: a recovery document carrying the
11
+ * same Flight payload, which the browser entry renders with createRoot
12
+ * (`data-rango-ssr-error`), so the router's client boundaries run as on a
13
+ * client navigation to the URL (the Next.js `__next_error__` design);
14
+ * - otherwise: a static document with no scripts and nothing from the error.
15
+ */
16
+ import React from "react";
17
+ import { isDataNotFoundError } from "../errors.js";
18
+ import { loaderRedirectOf } from "../decode-loader-results.js";
19
+ import { ThemeScript } from "../theme/ThemeScript.js";
20
+ import type { ResolvedThemeConfig } from "../theme/types.js";
21
+ import type { SSRDependencies } from "./index.js";
22
+
23
+ /**
24
+ * Read by the browser entry (vite/plugins/virtual-entries.ts): render the
25
+ * document, do not hydrate it.
26
+ */
27
+ const SSR_ERROR_ATTRIBUTE = "data-rango-ssr-error";
28
+
29
+ /** What renderHTML answered a shell error with. */
30
+ export interface ShellErrorAnswer {
31
+ body: ReadableStream<Uint8Array>;
32
+ status: number;
33
+ location?: string;
34
+ }
35
+
36
+ /**
37
+ * What a recovery document needs. Absent when the payload did not decode or
38
+ * the tree build rejected: the browser builds the same tree from the same
39
+ * payload and would reject the same way.
40
+ */
41
+ export interface ShellErrorRecovery {
42
+ bootstrap: { bootstrapScriptContent?: string; bootstrapModules?: string[] };
43
+ themeConfig: ResolvedThemeConfig | null;
44
+ }
45
+
46
+ type ShellErrorRenderDeps = Pick<
47
+ SSRDependencies,
48
+ "renderToReadableStream" | "injectRSCPayload"
49
+ >;
50
+
51
+ const HEADINGS: Record<number, string> = {
52
+ 200: "Redirecting",
53
+ 404: "Not Found",
54
+ 500: "Internal Server Error",
55
+ };
56
+
57
+ function staticErrorDocument(status: number): ReadableStream<Uint8Array> {
58
+ return new Response(
59
+ `<!DOCTYPE html><html ${SSR_ERROR_ATTRIBUTE}=""><head><meta charset="utf-8"/>` +
60
+ `<title>${HEADINGS[status]}</title></head><body><h1>${HEADINGS[status]}</h1></body></html>`,
61
+ ).body!;
62
+ }
63
+
64
+ /**
65
+ * The recovery document: the bootstrap scripts, the untouched second branch
66
+ * of the Flight tee, and the heading only in <noscript>, so a JavaScript
67
+ * visitor sees the client render's boundary without a flash of it. The theme
68
+ * script runs here, as the document's own does on a hydrated page: the client
69
+ * render inserts the root layout's inline scripts without running them.
70
+ */
71
+ async function renderRecoveryDocument(
72
+ deps: ShellErrorRenderDeps,
73
+ recovery: ShellErrorRecovery,
74
+ flight: ReadableStream<Uint8Array>,
75
+ status: number,
76
+ nonce: string | undefined,
77
+ ): Promise<ReadableStream<Uint8Array>> {
78
+ const html = await deps.renderToReadableStream(
79
+ <html {...{ [SSR_ERROR_ATTRIBUTE]: "" }}>
80
+ <head>
81
+ <meta charSet="utf-8" />
82
+ {recovery.themeConfig && (
83
+ <ThemeScript config={recovery.themeConfig} nonce={nonce} />
84
+ )}
85
+ </head>
86
+ <body>
87
+ <noscript>
88
+ <h1>{HEADINGS[status]}</h1>
89
+ </noscript>
90
+ </body>
91
+ </html>,
92
+ { ...recovery.bootstrap, nonce },
93
+ );
94
+ return html.pipeThrough(deps.injectRSCPayload(flight, { nonce }));
95
+ }
96
+
97
+ /** Answer a shell error; see the module comment for the order. */
98
+ export async function answerShellError(
99
+ deps: ShellErrorRenderDeps,
100
+ error: unknown,
101
+ recovery: ShellErrorRecovery | undefined,
102
+ flight: ReadableStream<Uint8Array> | undefined,
103
+ nonce: string | undefined,
104
+ ): Promise<ShellErrorAnswer> {
105
+ // A redirect with state keeps the recovery document: the state rides the
106
+ // history entry the client replace writes, which a 3xx cannot. Without
107
+ // this branch every loader redirect takes the client replace.
108
+ const redirect = loaderRedirectOf(error);
109
+ if (redirect && redirect.state === undefined) {
110
+ flight?.cancel().catch(() => {});
111
+ return {
112
+ body: new Response("").body!,
113
+ status: redirect.status ?? 302,
114
+ location: redirect.to,
115
+ };
116
+ }
117
+ // A redirect with state answers 200 like the same redirect inside a
118
+ // <Suspense>: the document, then the client replace.
119
+ const status = redirect ? 200 : isDataNotFoundError(error) ? 404 : 500;
120
+ if (recovery && flight) {
121
+ try {
122
+ return {
123
+ body: await renderRecoveryDocument(
124
+ deps,
125
+ recovery,
126
+ flight,
127
+ status,
128
+ nonce,
129
+ ),
130
+ status,
131
+ };
132
+ } catch {
133
+ // The static document below.
134
+ }
135
+ }
136
+ flight?.cancel().catch(() => {});
137
+ const failed = redirect ? 500 : status;
138
+ return { body: staticErrorDocument(failed), status: failed };
139
+ }
@@ -221,6 +221,12 @@ export interface SsrRootOptions {
221
221
  * live fizz passes the request's origin; capture/resume pass theirs.
222
222
  */
223
223
  origin?: string;
224
+ /**
225
+ * Called when the segment tree build rejects. renderHTML answers that shell
226
+ * error with a static document: the browser builds the same tree from the
227
+ * same payload and would reject the same way (ssr/shell-error-document.tsx).
228
+ */
229
+ onTreeError?: (error: unknown) => void;
224
230
  }
225
231
 
226
232
  /**
@@ -248,6 +254,7 @@ export function createSsrRootComponent(opts: SsrRootOptions): React.FC {
248
254
  onPayloadSettled,
249
255
  search,
250
256
  origin,
257
+ onTreeError,
251
258
  } = opts;
252
259
 
253
260
  // onPayloadSettled fires AFTER fragment expansion (inside
@@ -308,11 +315,14 @@ export function createSsrRootComponent(opts: SsrRootOptions): React.FC {
308
315
  // Memoize like payload/handles above: renderSegments is async, so
309
316
  // React.use() on a fresh promise suspends and replays SsrRoot, which
310
317
  // would re-run the entire segment-tree build on every initial render.
311
- rootPromise ??= Promise.resolve(
312
- renderSegments(resolved.metadata?.segments ?? [], {
313
- rootLayout: resolved.metadata?.rootLayout,
314
- }),
315
- );
318
+ if (!rootPromise) {
319
+ rootPromise = Promise.resolve(
320
+ renderSegments(resolved.metadata?.segments ?? [], {
321
+ rootLayout: resolved.metadata?.rootLayout,
322
+ }),
323
+ );
324
+ if (onTreeError) rootPromise.catch(onTreeError);
325
+ }
316
326
  let content: React.ReactNode = React.use(rootPromise);
317
327
 
318
328
  // Wrap content with ThemeProvider if theme is enabled