@rangojs/router 0.26.2 → 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 +7 -4
- 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 +49 -9
- package/skills/ppr/SKILL.md +15 -7
- package/skills/route/SKILL.md +4 -1
- package/skills/router-setup/SKILL.md +28 -0
- 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 +32 -7
- package/src/router/segment-resolution/helpers.ts +12 -1
- package/src/router.ts +14 -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,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
**Layer:** unit (DOM) · **Import:** `@rangojs/router/testing/dom` · **DSL it tests:** a client component reading router context (see `/hooks`)
|
|
4
4
|
|
|
5
|
-
`renderRoute(routes, options?)` is an RTL-style stub (peer of React Router's `createRoutesStub` / Expo's `renderRouter`), and it is async — `await` it. It mounts the router's REAL `NavigationProvider` plus a synthetic segment tree built from the `routes` you pass, so client hooks resolve against production context — no server, no Vite build, no Flight round-trip. Loader data, location state, and handle output are SEEDED into client context; nothing is executed. With `hydrate: true` it renders that same tree to HTML first and hydrates it ([Hydration](#hydration)).
|
|
5
|
+
`renderRoute(routes, options?)` is an RTL-style stub (peer of React Router's `createRoutesStub` / Expo's `renderRouter`), and it is async — `await` it. It mounts the router's REAL `NavigationProvider` plus a synthetic segment tree built from the `routes` you pass, so client hooks resolve against production context — no server, no Vite build, no Flight round-trip. Loader data, location state, and handle output are SEEDED into client context; nothing is executed. With `hydrate: true` it renders that same tree to HTML first and hydrates it, or renders it as production's recovery document when that pass throws ([Hydration](#hydration)).
|
|
6
6
|
|
|
7
7
|
## API
|
|
8
8
|
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
| `theme` | `ThemeConfig \| true` | Theme config (`createRouter({ theme })` shape) to wrap the tree in a `ThemeProvider`. Defaults to no provider. A component calling `useTheme()` REQUIRES one. |
|
|
26
26
|
| `nonce` | `string` | CSP nonce to seed via `NonceContext`, so a component calling `useNonce()` (e.g. an analytics/GTM head script) sees it — mirroring SSR. Defaults to `undefined` (the browser default). |
|
|
27
27
|
| `defaultPrefetch` | `PrefetchStrategy` | Router default for `<Link>` and eligible plain anchors. `data-prefetch="false"`/`"none"` opts out one anchor; ancestor `data-prefetch-scope="false"`/`"none"` hard-disables the subtree; `"true"` permits routed resource suffixes elsewhere. |
|
|
28
|
-
| `hydrate` | `boolean` | Render the same tree to HTML first (`renderToString`, no `window`/`document`), then hydrate that HTML instead of mounting fresh. The result gains `serverHtml` and `
|
|
28
|
+
| `hydrate` | `boolean` | Render the same tree to HTML first (`renderToString`, no `window`/`document`), then hydrate that HTML instead of mounting fresh. The result gains `serverHtml`, `recoverableErrors` and `shellError`. See [Hydration](#hydration). |
|
|
29
29
|
| `rewrite` | `string` | The path middleware rewrote `request` to (`throw rewrite()`): the leaf is matched against it for params, while the location (`usePathname`, `useSearchParams`) stays `request`, as a rewritten document renders and hydrates. |
|
|
30
30
|
|
|
31
31
|
`RenderRouteSpec = { path, Component, layout?, loaderIds?, name?, transition? }` — one node of the route definition. The array is the layout chain root-to-leaf; the LAST entry is the leaf route (its pattern is matched against `request` to extract params; layout patterns are informational). `loaderIds` attaches seeded loaders to THIS node's segment; `layout` on the leaf wraps it; `name` is informational. `transition` is the `transition()` config this node declares (`{}` for a bare `transition()`), attached to its segment exactly as the DSL does — see [Held navigation](#held-navigation).
|
|
@@ -296,20 +296,21 @@ it("hydrates as the server rendered it, then shows the stored count", async () =
|
|
|
296
296
|
});
|
|
297
297
|
```
|
|
298
298
|
|
|
299
|
-
| In your component | Under `hydrate: true`
|
|
300
|
-
| ------------------------------------------------------------------------------------- |
|
|
301
|
-
| `typeof window` / `typeof document` in render | `"undefined"` in the server pass: both globals are removed for it and restored before hydration. A branch renders its server side; if the client side differs, React reports the mismatch.
|
|
302
|
-
| An unguarded `window.x` / `document.x` in render | Throws `ReferenceError` in the server pass, as in SSR. Inside a `<Suspense>` the boundary is client-rendered and reported; outside one
|
|
303
|
-
|
|
|
304
|
-
|
|
|
305
|
-
|
|
|
306
|
-
|
|
|
307
|
-
|
|
|
308
|
-
|
|
|
309
|
-
|
|
|
310
|
-
| `
|
|
311
|
-
| `
|
|
312
|
-
| `
|
|
299
|
+
| In your component | Under `hydrate: true` |
|
|
300
|
+
| ------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
301
|
+
| `typeof window` / `typeof document` in render | `"undefined"` in the server pass: both globals are removed for it and restored before hydration. A branch renders its server side; if the client side differs, React reports the mismatch. |
|
|
302
|
+
| An unguarded `window.x` / `document.x` in render | Throws `ReferenceError` in the server pass, as in SSR. Inside a `<Suspense>` the boundary is client-rendered and reported; outside one it is a shell error (next row). |
|
|
303
|
+
| An error thrown in the server pass outside any `<Suspense>` (a shell error) | As production's recovery document: `shellError` holds the error, `serverHtml` is `""`, and the tree is rendered with `createRoot`, so your error boundaries (or the component itself, if it throws only on the server) show what the document ends on. |
|
|
304
|
+
| Hydration adopts the server HTML | `hydrateRoot` adopts the server-rendered DOM nodes instead of replacing them, and `recoverableErrors` stays empty; a client render of the same tree would replace the nodes. |
|
|
305
|
+
| A text or element mismatch | React re-renders that tree on the client and reports it: `recoverableErrors` gets `"Hydration failed because ..."` with the diff. |
|
|
306
|
+
| An attribute-only mismatch (`className`, `href`) | NOT in `recoverableErrors`. React keeps the server attribute and logs `console.error` in development; assert it with `vi.spyOn(console, "error")`. |
|
|
307
|
+
| `<Suspense>` whose content renders synchronously | In `serverHtml`. It hydrates in a later pass than the tree above it, after that tree's effects, before `renderRoute` resolves. |
|
|
308
|
+
| Content that suspends in the server pass (`lazy`, a pending or plain `use()` promise) | NOT hydrated: `serverHtml` has the fallback, the client renders the content, and `recoverableErrors` reports the boundary. With no `<Suspense>` above it `renderRoute` rejects: the production server waits for it, this synchronous pass cannot. A settled promise (Caveats, below) stays in the server HTML. |
|
|
309
|
+
| Render counts and module state | Every component renders once in the server pass before its hydration render. Both passes share one module realm, so module state written while rendering is still there at hydration. |
|
|
310
|
+
| `history`, `localStorage`, `navigator` read outside a server snapshot | FIDELITY LIMIT: still defined in the server pass (only `window` and `document` are removed), so the read renders the same in both passes here and can still mismatch in real SSR. |
|
|
311
|
+
| `data-hydrated` on `<html>` | Set after hydration, as the production root sets it from its effect, and removed on unmount. A `<Suspense>` boundary therefore hydrates with it already set. |
|
|
312
|
+
| `useHandle` with `lateHandles` | The `handles` values in the server pass and in every hydration render, a `<Suspense>` boundary's included; the `lateHandles` values on the render after. |
|
|
313
|
+
| `useLocationState` of a seeded definition | `undefined` in the server pass and the hydration render, the seeded value on the render after. A `clearOnReload` definition stays `undefined`: its seeded slot is removed from `history.state` before hydration, as on a real document load. |
|
|
313
314
|
|
|
314
315
|
A handle value can reach the page after hydration: a loader push made after an `await`, or any loader push on a PPR shell HIT (`/ppr`, "Handles on a shell HIT"). `lateHandles` models it. The server HTML is rendered from `handles`; the update is applied once the root has hydrated, so a reader inside a `<Suspense>` hydrates after it arrived, as a streamed boundary does in production:
|
|
315
316
|
|
|
@@ -337,6 +338,19 @@ it("hydrates with the document's notes, then shows the late one", async () => {
|
|
|
337
338
|
});
|
|
338
339
|
```
|
|
339
340
|
|
|
341
|
+
A server-only throw fails the document's shell in production; the router answers a recovery document and the browser renders the page (`/loader`, "A read outside any `<Suspense>`"). `shellError` lets a test pin where that document ends:
|
|
342
|
+
|
|
343
|
+
```tsx
|
|
344
|
+
it("a widget that needs the browser still renders on a document load", async () => {
|
|
345
|
+
const { shellError, getByTestId } = await renderRoute(
|
|
346
|
+
[{ path: "/products", Component: ProductsPage }], // ProductsPage reads window.matchMedia
|
|
347
|
+
{ hydrate: true },
|
|
348
|
+
);
|
|
349
|
+
expect(shellError).toBeInstanceOf(ReferenceError);
|
|
350
|
+
expect(getByTestId("products")).toBeTruthy(); // rendered in the browser
|
|
351
|
+
});
|
|
352
|
+
```
|
|
353
|
+
|
|
340
354
|
- Needs `@testing-library/react` 16.2.0 or newer. 16.0 and 16.1 never pass `onRecoverableError` to `hydrateRoot`, so a mismatch would be invisible; `renderRoute` throws there instead.
|
|
341
355
|
- `recoverableErrors` is live: React appends to it for as long as the root is mounted.
|
|
342
356
|
- The server pass is the HTML render of the CLIENT tree. It runs no handler, loader, middleware, or Flight, and it is not the streamed document: streaming order, client-reference identity, and the browser's HTML parser stay at e2e.
|
package/src/cache/cache-error.ts
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import { _getRequestContext } from "../server/request-context.js";
|
|
19
|
+
import { wasServerErrorPrinted } from "../server/print-server-error.js";
|
|
19
20
|
|
|
20
21
|
/**
|
|
21
22
|
* Minimal shape of a request context for error reporting. Passed explicitly by
|
|
@@ -29,6 +30,8 @@ export interface CacheErrorReporter {
|
|
|
29
30
|
error: unknown,
|
|
30
31
|
category: CacheErrorCategory,
|
|
31
32
|
) => void;
|
|
33
|
+
/** The errors the request printed (router/error-handling.ts printServerError). */
|
|
34
|
+
_printedErrors?: WeakSet<object>;
|
|
32
35
|
}
|
|
33
36
|
|
|
34
37
|
export type CacheErrorCategory =
|
|
@@ -70,9 +73,15 @@ export function reportCacheError(
|
|
|
70
73
|
label: string,
|
|
71
74
|
ctx?: CacheErrorReporter,
|
|
72
75
|
): void {
|
|
73
|
-
|
|
76
|
+
let target: CacheErrorReporter | undefined;
|
|
77
|
+
try {
|
|
78
|
+
target = ctx ?? _getRequestContext();
|
|
79
|
+
} catch {
|
|
80
|
+
// Reporting must never itself break the cache path.
|
|
81
|
+
}
|
|
82
|
+
// A render error a writer refuses to store printed where it originated.
|
|
83
|
+
if (!wasServerErrorPrinted(error, target)) console.error(`${label}:`, error);
|
|
74
84
|
try {
|
|
75
|
-
const target = ctx ?? _getRequestContext();
|
|
76
85
|
target?._reportBackgroundError?.(error, category);
|
|
77
86
|
} catch {
|
|
78
87
|
// Reporting must never itself break the cache path.
|
|
@@ -19,7 +19,10 @@ import { LoaderRedirect } from "./loader-redirect.js";
|
|
|
19
19
|
*
|
|
20
20
|
* Only decodeLoaderEntry (the read-site path — client components, so the
|
|
21
21
|
* throw lands in the router-owned boundary; during document SSR, Fizz emits
|
|
22
|
-
* the Suspense fallback and replays the throw at hydration
|
|
22
|
+
* the Suspense fallback and replays the throw at hydration, and with no
|
|
23
|
+
* Suspense above the read the throw fails the shell, which renderHTML answers
|
|
24
|
+
* with a recovery document or a 3xx, ssr/shell-error-document.tsx) throws
|
|
25
|
+
* these.
|
|
23
26
|
* decodeLoaderResults runs during the tree build on settled results, where a
|
|
24
27
|
* throw would collapse the whole payload — it routes signals through the
|
|
25
28
|
* errorFallback slot instead (same visual: the slot replaces the children
|
|
@@ -33,6 +36,16 @@ export const LOADER_NOT_FOUND_FALLBACK: unique symbol = Symbol(
|
|
|
33
36
|
);
|
|
34
37
|
export const LOADER_REDIRECT: unique symbol = Symbol("rango.loaderRedirect");
|
|
35
38
|
|
|
39
|
+
/** The redirect a `LoaderRedirect` thrown by throwSignal carries, if any. */
|
|
40
|
+
export function loaderRedirectOf(
|
|
41
|
+
error: unknown,
|
|
42
|
+
): { to: string; status?: number; state?: unknown } | undefined {
|
|
43
|
+
if (error === null || typeof error !== "object") return undefined;
|
|
44
|
+
return (error as Record<PropertyKey, unknown>)[LOADER_REDIRECT] as
|
|
45
|
+
| { to: string; status?: number; state?: unknown }
|
|
46
|
+
| undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
36
49
|
function throwSignal(result: any): void {
|
|
37
50
|
if (result.notFound === true) {
|
|
38
51
|
const err = new Error(result.error?.message ?? "Not found");
|
|
@@ -70,6 +83,13 @@ function signalFallback(result: any): ReactNode | undefined {
|
|
|
70
83
|
return undefined;
|
|
71
84
|
}
|
|
72
85
|
|
|
86
|
+
/**
|
|
87
|
+
* Marks an error loaderResultError rebuilt from a loader's ErrorInfo (masked
|
|
88
|
+
* in production). The server printed the original where the loader ran, so
|
|
89
|
+
* the SSR render never prints the rebuild.
|
|
90
|
+
*/
|
|
91
|
+
const REBUILT_LOADER_ERROR: unique symbol = Symbol();
|
|
92
|
+
|
|
73
93
|
/**
|
|
74
94
|
* A failed loader entry's error, rebuilt with the ErrorInfo identity
|
|
75
95
|
* (name/stack/code/cause) instead of a stripped generic Error.
|
|
@@ -83,9 +103,17 @@ export function loaderResultError(result: { error: ErrorInfo }): Error {
|
|
|
83
103
|
if (info.name) err.name = info.name;
|
|
84
104
|
if (info.stack) err.stack = info.stack;
|
|
85
105
|
if (info.code !== undefined) (err as { code?: string }).code = info.code;
|
|
106
|
+
(err as { [REBUILT_LOADER_ERROR]?: true })[REBUILT_LOADER_ERROR] = true;
|
|
86
107
|
return err;
|
|
87
108
|
}
|
|
88
109
|
|
|
110
|
+
/** Whether `error` is a rebuild loaderResultError made (see the marker). */
|
|
111
|
+
export function isRebuiltLoaderError(error: unknown): boolean {
|
|
112
|
+
return (
|
|
113
|
+
typeof error === "object" && error !== null && REBUILT_LOADER_ERROR in error
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
89
117
|
/** A loader that threw: not ok, and not a notFound()/redirect() signal. */
|
|
90
118
|
export function isLoaderError(
|
|
91
119
|
result: unknown,
|
package/src/errors.ts
CHANGED
|
@@ -76,6 +76,45 @@ export function isDataNotFoundError(
|
|
|
76
76
|
);
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
/**
|
|
80
|
+
* A loader's notFound() or redirect() rethrown at its read site
|
|
81
|
+
* (decode-loader-results.ts throwSignal): control flow answered with its own
|
|
82
|
+
* status, not an error to print or report. Name-based, so the RSC environment,
|
|
83
|
+
* whose module instances differ from the SSR one's, classifies it too.
|
|
84
|
+
*/
|
|
85
|
+
export function isLoaderSignalError(error: unknown): boolean {
|
|
86
|
+
return (
|
|
87
|
+
isDataNotFoundError(error) ||
|
|
88
|
+
(error instanceof Error && error.name === "LoaderRedirect")
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* An error React's Flight client decoded from an error row: it always sets
|
|
94
|
+
* `digest`. The Flight render printed and reported the original.
|
|
95
|
+
*/
|
|
96
|
+
export function isFlightDecodedError(error: unknown): boolean {
|
|
97
|
+
return typeof error === "object" && error !== null && "digest" in error;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* A render's own cancellation, not a failure: the error React's Flight and
|
|
102
|
+
* Fizz report for each pending task when a stream is aborted with no reason
|
|
103
|
+
* (a client disconnect), or an AbortError while the request itself is
|
|
104
|
+
* aborted. A loader's own AbortController or fetch timeout on a live request
|
|
105
|
+
* is a failure.
|
|
106
|
+
*/
|
|
107
|
+
export function isAbortedRender(
|
|
108
|
+
error: unknown,
|
|
109
|
+
requestSignal?: AbortSignal,
|
|
110
|
+
): boolean {
|
|
111
|
+
if (!(error instanceof Error)) return false;
|
|
112
|
+
if (error.message.startsWith("The render was aborted by the server")) {
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
return error.name === "AbortError" && requestSignal?.aborted === true;
|
|
116
|
+
}
|
|
117
|
+
|
|
79
118
|
/**
|
|
80
119
|
* Convenience function to throw a DataNotFoundError
|
|
81
120
|
* Shorter syntax for common not-found scenarios
|
|
@@ -76,6 +76,7 @@ import {
|
|
|
76
76
|
type ScopableReverse,
|
|
77
77
|
} from "../server/context.js";
|
|
78
78
|
import { debugLog } from "./logging.js";
|
|
79
|
+
import { printServerError } from "../server/print-server-error.js";
|
|
79
80
|
|
|
80
81
|
/**
|
|
81
82
|
* Internal callback signature for loader error notifications.
|
|
@@ -147,9 +148,11 @@ function pushHandleValue(
|
|
|
147
148
|
* presentation there.
|
|
148
149
|
* - redirect() (thrown 3xx Response): the target is resolved through the
|
|
149
150
|
* soft-redirect same-origin rules NOW so unsafe targets never leave the
|
|
150
|
-
* server; the client navigates when the entry decodes.
|
|
151
|
-
*
|
|
152
|
-
* a
|
|
151
|
+
* server; the client navigates when the entry decodes. A read outside
|
|
152
|
+
* Suspense fails the document's server-render shell before anything was
|
|
153
|
+
* sent: that document answers a real 3xx when the redirect carries no
|
|
154
|
+
* state (ssr/shell-error-document.tsx); otherwise a loader redirect is a
|
|
155
|
+
* client-side navigate.
|
|
153
156
|
*
|
|
154
157
|
* @param onError - Optional callback invoked when loader errors occur.
|
|
155
158
|
* This has a simplified signature for internal use - the caller (typically
|
|
@@ -157,7 +160,8 @@ function pushHandleValue(
|
|
|
157
160
|
* OnErrorCallback with complete request context (request, url, env, etc.).
|
|
158
161
|
* @param useDefaultErrorBoundary - An `ssr: false` loader on a document or
|
|
159
162
|
* capture render: an error with no declared boundary gets
|
|
160
|
-
* `DefaultErrorFallback`
|
|
163
|
+
* `DefaultErrorFallback` (it would otherwise reject the tree), and every
|
|
164
|
+
* error answers 500.
|
|
161
165
|
*/
|
|
162
166
|
export function wrapLoaderWithErrorHandling<T>(
|
|
163
167
|
promise: Promise<T>,
|
|
@@ -276,7 +280,13 @@ export function wrapLoaderWithErrorHandling<T>(
|
|
|
276
280
|
return {
|
|
277
281
|
__loaderResult: true,
|
|
278
282
|
ok: false,
|
|
279
|
-
|
|
283
|
+
// status: a document whose shell this read fails answers it as a
|
|
284
|
+
// real redirect (ssr/shell-error-document.tsx).
|
|
285
|
+
redirect: {
|
|
286
|
+
to,
|
|
287
|
+
status: error.status,
|
|
288
|
+
...(redirectState && { state: redirectState }),
|
|
289
|
+
},
|
|
280
290
|
error: createErrorInfo(
|
|
281
291
|
new Error(`Loader redirected to ${to}`),
|
|
282
292
|
segmentId,
|
|
@@ -290,6 +300,17 @@ export function wrapLoaderWithErrorHandling<T>(
|
|
|
290
300
|
// Flight nor Fizz reports: record it like their errors, so a writer
|
|
291
301
|
// that reads _renderErrors (the document cache) does not store it.
|
|
292
302
|
renderErrors?.push(error);
|
|
303
|
+
// The original, once, here: the payload carries a masked rebuild that
|
|
304
|
+
// the SSR render must not print (decode-loader-results.ts).
|
|
305
|
+
printServerError(
|
|
306
|
+
error,
|
|
307
|
+
{
|
|
308
|
+
label: "Loader error",
|
|
309
|
+
name: loaderName.slice(loaderName.lastIndexOf("#") + 1),
|
|
310
|
+
pathname,
|
|
311
|
+
},
|
|
312
|
+
requestContext,
|
|
313
|
+
);
|
|
293
314
|
|
|
294
315
|
// Find nearest error boundary
|
|
295
316
|
const declared = findNearestErrorBoundary(entry);
|
|
@@ -349,8 +370,12 @@ export function wrapLoaderWithErrorHandling<T>(
|
|
|
349
370
|
segmentId,
|
|
350
371
|
message: errorInfo.message,
|
|
351
372
|
});
|
|
352
|
-
|
|
353
|
-
|
|
373
|
+
if (useDefaultErrorBoundary) {
|
|
374
|
+
// Settled before the first flush, so the document answers
|
|
375
|
+
// 500 whichever boundary renders it, as a handler error does. On a
|
|
376
|
+
// shell HIT the status went out with the shell and the write is inert.
|
|
377
|
+
requestContext?._setStatus(500);
|
|
378
|
+
}
|
|
354
379
|
|
|
355
380
|
return {
|
|
356
381
|
__loaderResult: true,
|
|
@@ -17,7 +17,11 @@ import {
|
|
|
17
17
|
createNotFoundSegment,
|
|
18
18
|
resolveNotFoundFallback,
|
|
19
19
|
} from "../error-handling.js";
|
|
20
|
-
import {
|
|
20
|
+
import { printServerError } from "../../server/print-server-error.js";
|
|
21
|
+
import {
|
|
22
|
+
_getRequestContext,
|
|
23
|
+
getRequestContext,
|
|
24
|
+
} from "../../server/request-context.js";
|
|
21
25
|
import { DefaultErrorFallback } from "../../default-error-boundary.js";
|
|
22
26
|
import type { EntryData } from "../../server/context";
|
|
23
27
|
import type {
|
|
@@ -295,6 +299,13 @@ export function catchSegmentError<TEnv>(
|
|
|
295
299
|
);
|
|
296
300
|
}
|
|
297
301
|
|
|
302
|
+
// The client gets the masked ErrorInfo; the original prints here.
|
|
303
|
+
printServerError(
|
|
304
|
+
error,
|
|
305
|
+
{ label: "Handler error", name: entry.shortCode, pathname },
|
|
306
|
+
_getRequestContext(),
|
|
307
|
+
);
|
|
308
|
+
|
|
298
309
|
const fallback = deps.findNearestErrorBoundary(entry);
|
|
299
310
|
const segmentType: ErrorInfo["segmentType"] = entry.type;
|
|
300
311
|
const errorInfo = createErrorInfo(error, entry.shortCode, segmentType);
|
package/src/router.ts
CHANGED
|
@@ -61,6 +61,7 @@ import {
|
|
|
61
61
|
findNearestNotFoundBoundary as findNotFoundBoundary,
|
|
62
62
|
invokeOnError,
|
|
63
63
|
} from "./router/error-handling.js";
|
|
64
|
+
import { printServerError } from "./server/print-server-error.js";
|
|
64
65
|
|
|
65
66
|
// Extracted module factories
|
|
66
67
|
import { createSegmentWrappers } from "./router/segment-wrappers.js";
|
|
@@ -497,8 +498,20 @@ export function createRouter<TEnv = any>(
|
|
|
497
498
|
// Report streaming handler errors to onError as a side-effect.
|
|
498
499
|
// The rejection still propagates to the RSC stream for client error boundaries.
|
|
499
500
|
// Captures request context eagerly (closure) so the catch handler has full context.
|
|
500
|
-
|
|
501
|
+
// Printed here, where it originated, whether a boundary recovers it or
|
|
502
|
+
// Flight reports it again (printed once per request).
|
|
503
|
+
if (reqCtx) {
|
|
501
504
|
tracked.catch((error) => {
|
|
505
|
+
printServerError(
|
|
506
|
+
error,
|
|
507
|
+
{
|
|
508
|
+
label: "Handler error",
|
|
509
|
+
name: errorContext?.segmentId,
|
|
510
|
+
pathname: reqCtx.url.pathname,
|
|
511
|
+
},
|
|
512
|
+
reqCtx,
|
|
513
|
+
);
|
|
514
|
+
if (!onError) return;
|
|
502
515
|
callOnError(error, "handler", {
|
|
503
516
|
request: reqCtx.request,
|
|
504
517
|
url: reqCtx.url,
|
package/src/rsc/handler.ts
CHANGED
|
@@ -63,6 +63,10 @@ import {
|
|
|
63
63
|
invokeOnError,
|
|
64
64
|
resolveDefaultNotFound,
|
|
65
65
|
} from "../router/error-handling.js";
|
|
66
|
+
import {
|
|
67
|
+
printServerError,
|
|
68
|
+
wasServerErrorPrinted,
|
|
69
|
+
} from "../server/print-server-error.js";
|
|
66
70
|
import { createReverseFunction } from "../router/handler-context.js";
|
|
67
71
|
import { contextSet } from "../context-var.js";
|
|
68
72
|
import {
|
|
@@ -1304,7 +1308,15 @@ export function createRSCHandler<
|
|
|
1304
1308
|
actionId: plan.actionId,
|
|
1305
1309
|
handledByBoundary: false,
|
|
1306
1310
|
});
|
|
1307
|
-
|
|
1311
|
+
printServerError(
|
|
1312
|
+
error,
|
|
1313
|
+
{
|
|
1314
|
+
label: "Action error",
|
|
1315
|
+
name: plan.actionId,
|
|
1316
|
+
pathname: url.pathname,
|
|
1317
|
+
},
|
|
1318
|
+
reqCtx,
|
|
1319
|
+
);
|
|
1308
1320
|
throw error;
|
|
1309
1321
|
}
|
|
1310
1322
|
}
|
|
@@ -1548,14 +1560,24 @@ export function createRSCHandler<
|
|
|
1548
1560
|
);
|
|
1549
1561
|
}
|
|
1550
1562
|
|
|
1551
|
-
// Report unhandled errors
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1563
|
+
// Report unhandled errors. Skip one the request already printed or
|
|
1564
|
+
// recorded as done: a shell error renderHTML rethrew to the host (dev),
|
|
1565
|
+
// which the html stage (rsc/ssr-setup.ts) reported when it started in
|
|
1566
|
+
// the SSR render; a copy's original was reported where it started.
|
|
1567
|
+
const printedContext = _getRequestContext();
|
|
1568
|
+
if (!wasServerErrorPrinted(error, printedContext)) {
|
|
1569
|
+
callOnError(error, "routing", {
|
|
1570
|
+
request,
|
|
1571
|
+
url,
|
|
1572
|
+
env,
|
|
1573
|
+
handledByBoundary: false,
|
|
1574
|
+
});
|
|
1575
|
+
printServerError(
|
|
1576
|
+
error,
|
|
1577
|
+
{ label: "Error", pathname: url.pathname },
|
|
1578
|
+
printedContext,
|
|
1579
|
+
);
|
|
1580
|
+
}
|
|
1559
1581
|
throw error;
|
|
1560
1582
|
}
|
|
1561
1583
|
};
|
package/src/rsc/loader-fetch.ts
CHANGED
|
@@ -15,8 +15,12 @@ import { requestHeaders } from "../server/request-headers.js";
|
|
|
15
15
|
import { FORBIDDEN_KEYS } from "../context-var.js";
|
|
16
16
|
import { getLoaderLazy } from "../server/loader-registry.js";
|
|
17
17
|
import { isDataNotFoundError } from "../errors.js";
|
|
18
|
+
import { printServerError } from "../server/print-server-error.js";
|
|
18
19
|
import { executeLoaderMiddleware } from "../router/middleware.js";
|
|
19
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
_getRequestContext,
|
|
22
|
+
getRequestContext,
|
|
23
|
+
} from "../server/request-context.js";
|
|
20
24
|
import { observePhase, PHASES } from "../router/instrument.js";
|
|
21
25
|
import {
|
|
22
26
|
createReverseFunction,
|
|
@@ -54,10 +58,17 @@ export function renderLoaderError<TEnv>(
|
|
|
54
58
|
meta: LoaderErrorMeta<TEnv>,
|
|
55
59
|
): Response {
|
|
56
60
|
const { request, url, env, loaderId } = meta;
|
|
61
|
+
// Read once here: onError can fire outside the ALS frame.
|
|
62
|
+
const requestContext = _getRequestContext();
|
|
57
63
|
const rscStream = ctx.renderToReadableStream(
|
|
58
64
|
{ loaderResult: null, loaderError },
|
|
59
65
|
{
|
|
60
66
|
onError: (renderError: unknown) => {
|
|
67
|
+
printServerError(
|
|
68
|
+
renderError,
|
|
69
|
+
{ label: "Rendering error", name: loaderId, pathname: url.pathname },
|
|
70
|
+
requestContext,
|
|
71
|
+
);
|
|
61
72
|
ctx.callOnError(renderError, "rendering", {
|
|
62
73
|
request,
|
|
63
74
|
url,
|
|
@@ -78,19 +89,22 @@ export function renderLoaderError<TEnv>(
|
|
|
78
89
|
* Build the 500 RSC error Response for a failed fetchable loader. Shared by the
|
|
79
90
|
* module-load-error catch (the import itself threw) and the loader-execution
|
|
80
91
|
* error catch — both call onError("loader") and serialize the same dev-gated
|
|
81
|
-
* error payload. The only per-site difference is the
|
|
82
|
-
* passed in.
|
|
92
|
+
* error payload. The only per-site difference is the printed label, passed in.
|
|
83
93
|
*/
|
|
84
94
|
function buildLoaderErrorResponse<TEnv>(
|
|
85
95
|
ctx: HandlerContext<TEnv>,
|
|
86
96
|
error: unknown,
|
|
87
97
|
meta: LoaderErrorMeta<TEnv>,
|
|
88
|
-
|
|
98
|
+
label: string,
|
|
89
99
|
): Response {
|
|
90
100
|
const { request, url, env, loaderId } = meta;
|
|
91
101
|
const isDev = process.env.NODE_ENV !== "production";
|
|
92
102
|
|
|
93
|
-
|
|
103
|
+
printServerError(
|
|
104
|
+
error,
|
|
105
|
+
{ label, name: loaderId, pathname: url.pathname },
|
|
106
|
+
_getRequestContext(),
|
|
107
|
+
);
|
|
94
108
|
|
|
95
109
|
ctx.callOnError(error, "loader", {
|
|
96
110
|
request,
|
|
@@ -145,7 +159,7 @@ export async function handleLoaderFetch<TEnv>(
|
|
|
145
159
|
ctx,
|
|
146
160
|
error,
|
|
147
161
|
{ request, url, env, loaderId },
|
|
148
|
-
|
|
162
|
+
"Loader module load error",
|
|
149
163
|
);
|
|
150
164
|
}
|
|
151
165
|
if (!registeredLoader) {
|
|
@@ -288,10 +302,21 @@ export async function handleLoaderFetch<TEnv>(
|
|
|
288
302
|
loaderResult: unknown;
|
|
289
303
|
}
|
|
290
304
|
const loaderPayload: LoaderPayload = { loaderResult: result };
|
|
305
|
+
// Read once here: onError can fire outside the ALS frame.
|
|
306
|
+
const requestContext = _getRequestContext();
|
|
291
307
|
const rscStream = ctx.renderToReadableStream<LoaderPayload>(
|
|
292
308
|
loaderPayload,
|
|
293
309
|
{
|
|
294
310
|
onError: (error: unknown) => {
|
|
311
|
+
printServerError(
|
|
312
|
+
error,
|
|
313
|
+
{
|
|
314
|
+
label: "Rendering error",
|
|
315
|
+
name: loaderId,
|
|
316
|
+
pathname: url.pathname,
|
|
317
|
+
},
|
|
318
|
+
requestContext,
|
|
319
|
+
);
|
|
295
320
|
ctx.callOnError(error, "rendering", {
|
|
296
321
|
request,
|
|
297
322
|
url,
|
|
@@ -342,7 +367,7 @@ export async function handleLoaderFetch<TEnv>(
|
|
|
342
367
|
ctx,
|
|
343
368
|
error,
|
|
344
369
|
{ request, url, env, loaderId },
|
|
345
|
-
"
|
|
370
|
+
"Loader error",
|
|
346
371
|
);
|
|
347
372
|
}
|
|
348
373
|
}
|
|
@@ -8,9 +8,11 @@
|
|
|
8
8
|
|
|
9
9
|
import { bodylessHeaders, requestHeaders } from "../server/request-headers.js";
|
|
10
10
|
import {
|
|
11
|
+
_getRequestContext,
|
|
11
12
|
getRequestContext,
|
|
12
13
|
setRequestContextParams,
|
|
13
14
|
} from "../server/request-context.js";
|
|
15
|
+
import { printServerError } from "../server/print-server-error.js";
|
|
14
16
|
import { createSsrHtmlStage } from "./ssr-setup.js";
|
|
15
17
|
import type { MiddlewareFn } from "../router/middleware.js";
|
|
16
18
|
import { executeMiddleware } from "../router/middleware.js";
|
|
@@ -146,7 +148,6 @@ async function runFormAction<TEnv>(
|
|
|
146
148
|
if (failure.boundary) {
|
|
147
149
|
return { reactFormState: null, directActionId: null, failure };
|
|
148
150
|
}
|
|
149
|
-
console.error("[RSC] Progressive enhancement form parse error:", error);
|
|
150
151
|
return createResponseWithMergedHeaders(null, { status: 400 });
|
|
151
152
|
}
|
|
152
153
|
|
|
@@ -226,9 +227,6 @@ async function runFormAction<TEnv>(
|
|
|
226
227
|
if (failure?.boundary) {
|
|
227
228
|
return { reactFormState: null, directActionId, failure };
|
|
228
229
|
}
|
|
229
|
-
if (failure) {
|
|
230
|
-
console.error("[RSC] Progressive enhancement action error:", failure.error);
|
|
231
|
-
}
|
|
232
230
|
|
|
233
231
|
// Handle Response returned from action during PE.
|
|
234
232
|
// In the JS path, executeServerAction intercepts redirect Responses and
|
|
@@ -512,6 +510,17 @@ async function matchPeFailure<TEnv>(
|
|
|
512
510
|
getRequestContext()._inActionRevalidation = true;
|
|
513
511
|
}
|
|
514
512
|
|
|
513
|
+
// Printed here, once, whether or not a boundary renders it.
|
|
514
|
+
printServerError(
|
|
515
|
+
error,
|
|
516
|
+
{
|
|
517
|
+
label: actionRan ? "Action error" : "Action form parse error",
|
|
518
|
+
name: actionId ?? undefined,
|
|
519
|
+
pathname: url.pathname,
|
|
520
|
+
},
|
|
521
|
+
_getRequestContext(),
|
|
522
|
+
);
|
|
523
|
+
|
|
515
524
|
let boundary: MatchResult | null;
|
|
516
525
|
try {
|
|
517
526
|
boundary = await ctx.router.matchError(request, { env }, error, "route");
|
|
@@ -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
|
);
|