@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.
Files changed (44) 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 +7 -4
  6. package/dist/types/rsc/types.d.ts +14 -0
  7. package/dist/types/server/print-server-error.d.ts +27 -0
  8. package/dist/types/server/request-context.d.ts +6 -1
  9. package/dist/types/ssr/index.d.ts +14 -0
  10. package/dist/types/ssr/shell-error-document.d.ts +24 -0
  11. package/dist/types/ssr/ssr-root.d.ts +6 -0
  12. package/dist/types/testing/render-route.d.ts +14 -1
  13. package/dist/types/types/boundaries.d.ts +4 -1
  14. package/dist/vite/index.js +14 -9
  15. package/package.json +1 -1
  16. package/skills/loader/SKILL.md +49 -9
  17. package/skills/ppr/SKILL.md +15 -7
  18. package/skills/route/SKILL.md +4 -1
  19. package/skills/router-setup/SKILL.md +28 -0
  20. package/skills/server-actions/SKILL.md +3 -1
  21. package/skills/testing/client-components.md +30 -16
  22. package/src/cache/cache-error.ts +11 -2
  23. package/src/decode-loader-results.ts +29 -1
  24. package/src/errors.ts +39 -0
  25. package/src/router/loader-resolution.ts +32 -7
  26. package/src/router/segment-resolution/helpers.ts +12 -1
  27. package/src/router.ts +14 -1
  28. package/src/rsc/handler.ts +31 -9
  29. package/src/rsc/loader-fetch.ts +32 -7
  30. package/src/rsc/progressive-enhancement.ts +13 -4
  31. package/src/rsc/render-pipeline.ts +9 -1
  32. package/src/rsc/rsc-rendering.ts +4 -0
  33. package/src/rsc/server-action.ts +16 -0
  34. package/src/rsc/ssr-setup.ts +23 -1
  35. package/src/rsc/types.ts +15 -0
  36. package/src/server/print-server-error.ts +87 -0
  37. package/src/server/request-context.ts +8 -0
  38. package/src/ssr/index.tsx +113 -10
  39. package/src/ssr/shell-error-document.tsx +139 -0
  40. package/src/ssr/ssr-root.tsx +15 -5
  41. package/src/testing/render-route.tsx +52 -11
  42. package/src/theme/ThemeProvider.tsx +31 -13
  43. package/src/types/boundaries.ts +8 -2
  44. 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
@@ -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. No document-lane
151
- * 302 — pre-stream redirect authority belongs to middleware (doctrine);
152
- * 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.
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` and a 500 (it would otherwise reject the tree).
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
- 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
+ },
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
- // The default boundary answers 500, as in catchSegmentError.
353
- if (!declared) requestContext?._setStatus(500);
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 { 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);
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,
@@ -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");
@@ -2,6 +2,7 @@ import { appendMetric } from "../router/metrics.js";
2
2
  import type { MetricsStore } from "../server/context.js";
3
3
  import { observePhase, PHASES } from "../router/instrument.js";
4
4
  import { _getRequestContext } from "../server/request-context.js";
5
+ import { printServerError } from "../server/print-server-error.js";
5
6
  import type { TraceSpan } from "../router/tracing.js";
6
7
  import type { RenderMode, RenderPhase } from "../router/timeout.js";
7
8
  import type { HandlerContext } from "./handler-context.js";
@@ -156,11 +157,18 @@ function createFlightStage<TEnv>(
156
157
  input.payload.metadata.devDiscoveryEpoch = input.ctx.devDiscoveryEpoch;
157
158
  }
158
159
  // Read once here: onError can fire outside the ALS frame.
159
- const renderErrors = _getRequestContext()?._renderErrors;
160
+ const requestContext = _getRequestContext();
161
+ const renderErrors = requestContext?._renderErrors;
160
162
  const stream = input.ctx.renderToReadableStream<RscPayload>(input.payload, {
161
163
  temporaryReferences: input.temporaryReferences,
162
164
  onError: (error: unknown) => {
163
165
  renderErrors?.push(error);
166
+ // The client gets a masked copy (an error row); the original prints here.
167
+ printServerError(
168
+ error,
169
+ { label: "Rendering error", pathname: input.url.pathname },
170
+ requestContext,
171
+ );
164
172
  input.ctx.callOnError(error, "rendering", {
165
173
  request: input.request,
166
174
  url: input.url,
@@ -28,6 +28,7 @@ import { getSSRSetup, createSsrHtmlStage, isRscRequest } from "./ssr-setup.js";
28
28
  import type { RscPayload } from "./types.js";
29
29
  import type { SSRModule } from "./types.js";
30
30
  import type { RequestContext } from "../server/request-context.js";
31
+ import { markServerErrorPrinted } from "../server/print-server-error.js";
31
32
  import type { PprReplayBypassReason } from "./shell-capture-constants.js";
32
33
  import {
33
34
  createResponseWithMergedHeaders,
@@ -1988,8 +1989,11 @@ function serveShellHit(
1988
1989
  // The HIT request's origin — same host as the capture's (key-scoped).
1989
1990
  origin: url.origin,
1990
1991
  // The document cache reads the list before storing this composite.
1992
+ // resumeShellHTML printed what started in the resume, or it is a copy
1993
+ // whose original printed upstream: a refusing writer prints nothing.
1991
1994
  onError: (error) => {
1992
1995
  reqCtx._renderErrors?.push(error);
1996
+ markServerErrorPrinted(error, reqCtx);
1993
1997
  },
1994
1998
  }),
1995
1999
  );