@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,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 `recoverableErrors`. See [Hydration](#hydration). |
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 `renderRoute` rejects. |
303
- | 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. |
304
- | A text or element mismatch | React re-renders that tree on the client and reports it: `recoverableErrors` gets `"Hydration failed because ..."` with the diff. |
305
- | 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")`. |
306
- | `<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. |
307
- | 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. A settled promise (Caveats, below) stays in the server HTML. |
308
- | 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. |
309
- | `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. |
310
- | `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. |
311
- | `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. |
312
- | `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. |
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.
@@ -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
- console.error(`${label}:`, error);
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) throws these.
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
@@ -55,6 +55,7 @@ import {
55
55
  } from "../server/handle-store.js";
56
56
  import { getFetchableLoader } from "../server/fetchable-loader-store.js";
57
57
  import { _getRequestContext } from "../server/request-context.js";
58
+ import { DefaultErrorFallback } from "../default-error-boundary.js";
58
59
  import { defineRewrittenFrom } from "../server/rewrite-trail.js";
59
60
  import {
60
61
  firstUnitCandidate,
@@ -75,6 +76,7 @@ import {
75
76
  type ScopableReverse,
76
77
  } from "../server/context.js";
77
78
  import { debugLog } from "./logging.js";
79
+ import { printServerError } from "../server/print-server-error.js";
78
80
 
79
81
  /**
80
82
  * Internal callback signature for loader error notifications.
@@ -146,14 +148,20 @@ function pushHandleValue(
146
148
  * presentation there.
147
149
  * - redirect() (thrown 3xx Response): the target is resolved through the
148
150
  * soft-redirect same-origin rules NOW so unsafe targets never leave the
149
- * server; the client navigates when the entry decodes. No document-lane
150
- * 302 — pre-stream redirect authority belongs to middleware (doctrine);
151
- * a loader redirect is always a client-side navigate.
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.
152
156
  *
153
157
  * @param onError - Optional callback invoked when loader errors occur.
154
158
  * This has a simplified signature for internal use - the caller (typically
155
159
  * wrapLoaderPromise in router.ts) is responsible for bridging to the full
156
160
  * OnErrorCallback with complete request context (request, url, env, etc.).
161
+ * @param useDefaultErrorBoundary - An `ssr: false` loader on a document or
162
+ * capture render: an error with no declared boundary gets
163
+ * `DefaultErrorFallback` (it would otherwise reject the tree), and every
164
+ * error answers 500.
157
165
  */
158
166
  export function wrapLoaderWithErrorHandling<T>(
159
167
  promise: Promise<T>,
@@ -177,13 +185,15 @@ export function wrapLoaderWithErrorHandling<T>(
177
185
  | ReactNode
178
186
  | ((props: { pathname: string }) => ReactNode);
179
187
  },
188
+ useDefaultErrorBoundary?: boolean,
180
189
  ): Promise<LoaderDataResult<T>> {
181
190
  // Extract the trailing token from segmentId (format: "<shortCode>D<i>.<loaderId>").
182
191
  // The token is the loader's $$id (hash#export in prod, pathfrag#export in dev),
183
192
  // not a clean display name.
184
193
  const loaderName = segmentId.split(".").pop() || "unknown";
185
194
  // Captured now: the rejection can settle outside the request's ALS frame.
186
- const renderErrors = _getRequestContext()?._renderErrors;
195
+ const requestContext = _getRequestContext();
196
+ const renderErrors = requestContext?._renderErrors;
187
197
 
188
198
  return Promise.resolve(promise)
189
199
  .then(
@@ -270,7 +280,13 @@ export function wrapLoaderWithErrorHandling<T>(
270
280
  return {
271
281
  __loaderResult: true,
272
282
  ok: false,
273
- redirect: { to, ...(redirectState && { state: redirectState }) },
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
+ },
274
290
  error: createErrorInfo(
275
291
  new Error(`Loader redirected to ${to}`),
276
292
  segmentId,
@@ -284,9 +300,22 @@ export function wrapLoaderWithErrorHandling<T>(
284
300
  // Flight nor Fizz reports: record it like their errors, so a writer
285
301
  // that reads _renderErrors (the document cache) does not store it.
286
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
+ );
287
314
 
288
315
  // Find nearest error boundary
289
- const fallback = findNearestErrorBoundary(entry);
316
+ const declared = findNearestErrorBoundary(entry);
317
+ const fallback =
318
+ declared ?? (useDefaultErrorBoundary ? DefaultErrorFallback : null);
290
319
 
291
320
  // Create error info
292
321
  const errorInfo = createErrorInfo(error, segmentId, "loader");
@@ -341,6 +370,12 @@ export function wrapLoaderWithErrorHandling<T>(
341
370
  segmentId,
342
371
  message: errorInfo.message,
343
372
  });
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
+ }
344
379
 
345
380
  return {
346
381
  __loaderResult: true,
@@ -184,6 +184,7 @@ export async function resolveLoaders<TEnv>(
184
184
  segmentId,
185
185
  ctx.pathname,
186
186
  errorContext,
187
+ loaderEntry.awaitBeforeFlush,
187
188
  ),
188
189
  belongsToRoute,
189
190
  };
@@ -235,6 +236,7 @@ export async function resolveLoaders<TEnv>(
235
236
  segmentId,
236
237
  ctx.pathname,
237
238
  errorContext,
239
+ loaderEntry.awaitBeforeFlush,
238
240
  );
239
241
  return { wrapped, segmentId };
240
242
  });
@@ -17,7 +17,11 @@ import {
17
17
  createNotFoundSegment,
18
18
  resolveNotFoundFallback,
19
19
  } from "../error-handling.js";
20
- import { getRequestContext } from "../../server/request-context.js";
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);
@@ -49,6 +49,8 @@ export interface SegmentResolutionDeps<TEnv = any> {
49
49
  isPartial?: boolean;
50
50
  requestStartTime?: number;
51
51
  },
52
+ /** An `ssr: false` loader on a document or capture render. */
53
+ useDefaultErrorBoundary?: boolean,
52
54
  ) => Promise<LoaderDataResult<T>>;
53
55
  trackHandler: <T>(
54
56
  promise: Promise<T>,
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
- if (reqCtx && onError) {
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,
@@ -531,6 +544,7 @@ export function createRouter<TEnv = any>(
531
544
  isPartial?: boolean;
532
545
  requestStartTime?: number;
533
546
  },
547
+ useDefaultErrorBoundary?: boolean,
534
548
  ): Promise<LoaderDataResult<T>> {
535
549
  const loaderStart = telemetrySink ? performance.now() : 0;
536
550
  const loaderRequestId = telemetrySink
@@ -600,6 +614,7 @@ export function createRouter<TEnv = any>(
600
614
  findNearestNotFoundBoundary,
601
615
  notFoundComponent: notFound,
602
616
  },
617
+ useDefaultErrorBoundary,
603
618
  );
604
619
 
605
620
  // Emit loader.end after the promise settles (fire-and-forget)
@@ -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
- console.error(`[RSC] Action error:`, error);
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
- callOnError(error, "routing", {
1553
- request,
1554
- url,
1555
- env,
1556
- handledByBoundary: false,
1557
- });
1558
- console.error(`[RSC] Error:`, error);
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
  };
@@ -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 { getRequestContext } from "../server/request-context.js";
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 console.error label,
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
- logLabel: string,
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
- console.error(logLabel, error);
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
- `[RSC] Loader module load failed for "${loaderId}":`,
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
- "[RSC] Loader error:",
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");