@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.
- package/dist/bin/rango.js +11 -7
- package/dist/types/cache/cache-error.d.ts +2 -0
- package/dist/types/decode-loader-results.d.ts +12 -1
- package/dist/types/errors.d.ts +20 -0
- package/dist/types/router/loader-resolution.d.ts +10 -4
- package/dist/types/router/types.d.ts +3 -1
- package/dist/types/rsc/types.d.ts +14 -0
- package/dist/types/server/print-server-error.d.ts +27 -0
- package/dist/types/server/request-context.d.ts +6 -1
- package/dist/types/ssr/index.d.ts +14 -0
- package/dist/types/ssr/shell-error-document.d.ts +24 -0
- package/dist/types/ssr/ssr-root.d.ts +6 -0
- package/dist/types/testing/render-route.d.ts +14 -1
- package/dist/types/types/boundaries.d.ts +4 -1
- package/dist/vite/index.js +14 -9
- package/package.json +1 -1
- package/skills/loader/SKILL.md +53 -4
- package/skills/ppr/SKILL.md +19 -10
- package/skills/route/SKILL.md +4 -1
- package/skills/router-setup/SKILL.md +29 -1
- package/skills/server-actions/SKILL.md +3 -1
- package/skills/testing/client-components.md +30 -16
- package/src/cache/cache-error.ts +11 -2
- package/src/decode-loader-results.ts +29 -1
- package/src/errors.ts +39 -0
- package/src/router/loader-resolution.ts +41 -6
- package/src/router/segment-resolution/fresh.ts +2 -0
- package/src/router/segment-resolution/helpers.ts +12 -1
- package/src/router/types.ts +2 -0
- package/src/router.ts +16 -1
- package/src/rsc/handler.ts +31 -9
- package/src/rsc/loader-fetch.ts +32 -7
- package/src/rsc/progressive-enhancement.ts +13 -4
- package/src/rsc/render-pipeline.ts +9 -1
- package/src/rsc/rsc-rendering.ts +4 -0
- package/src/rsc/server-action.ts +16 -0
- package/src/rsc/ssr-setup.ts +23 -1
- package/src/rsc/types.ts +15 -0
- package/src/server/print-server-error.ts +87 -0
- package/src/server/request-context.ts +8 -0
- package/src/ssr/index.tsx +113 -10
- package/src/ssr/shell-error-document.tsx +139 -0
- package/src/ssr/ssr-root.tsx +15 -5
- package/src/testing/render-route.tsx +52 -11
- package/src/theme/ThemeProvider.tsx +31 -13
- package/src/types/boundaries.ts +8 -2
- 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
|
|
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,
|
package/src/rsc/rsc-rendering.ts
CHANGED
|
@@ -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
|
);
|
package/src/rsc/server-action.ts
CHANGED
|
@@ -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.
|
package/src/rsc/ssr-setup.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
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
|
-
|
|
942
|
-
|
|
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
|
+
}
|
package/src/ssr/ssr-root.tsx
CHANGED
|
@@ -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
|
|
312
|
-
|
|
313
|
-
|
|
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
|