@rangojs/router 0.26.2 → 0.26.4

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 (46) 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/dev-reload-handoff.d.ts +12 -0
  6. package/dist/types/router/loader-resolution.d.ts +7 -4
  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 +49 -9
  18. package/skills/ppr/SKILL.md +15 -7
  19. package/skills/route/SKILL.md +4 -1
  20. package/skills/router-setup/SKILL.md +28 -0
  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/dev-reload-handoff.ts +37 -0
  27. package/src/router/loader-resolution.ts +32 -7
  28. package/src/router/segment-resolution/helpers.ts +12 -1
  29. package/src/router.ts +37 -12
  30. package/src/rsc/handler.ts +31 -9
  31. package/src/rsc/loader-fetch.ts +32 -7
  32. package/src/rsc/progressive-enhancement.ts +13 -4
  33. package/src/rsc/render-pipeline.ts +9 -1
  34. package/src/rsc/rsc-rendering.ts +4 -0
  35. package/src/rsc/server-action.ts +16 -0
  36. package/src/rsc/ssr-setup.ts +23 -1
  37. package/src/rsc/types.ts +15 -0
  38. package/src/server/print-server-error.ts +87 -0
  39. package/src/server/request-context.ts +8 -0
  40. package/src/ssr/index.tsx +113 -10
  41. package/src/ssr/shell-error-document.tsx +139 -0
  42. package/src/ssr/ssr-root.tsx +15 -5
  43. package/src/testing/render-route.tsx +52 -11
  44. package/src/theme/ThemeProvider.tsx +31 -13
  45. package/src/types/boundaries.ts +8 -2
  46. package/src/vite/plugins/virtual-entries.ts +10 -6
package/dist/bin/rango.js CHANGED
@@ -1644,7 +1644,7 @@ var init_package = __esm({
1644
1644
  "package.json"() {
1645
1645
  package_default = {
1646
1646
  name: "@rangojs/router",
1647
- version: "0.26.2",
1647
+ version: "0.26.4",
1648
1648
  description: "Django-inspired RSC router with composable URL patterns",
1649
1649
  keywords: [
1650
1650
  "react",
@@ -1966,7 +1966,7 @@ import {
1966
1966
  createTemporaryReferenceSet,
1967
1967
  } from "@rangojs/router/internal/deps/browser";
1968
1968
  import { createElement, StrictMode } from "react";
1969
- import { hydrateRoot } from "react-dom/client";
1969
+ import { createRoot, hydrateRoot } from "react-dom/client";
1970
1970
  import { rscStream } from "@rangojs/router/internal/deps/html-stream-client";
1971
1971
  import { initBrowserApp, Rango } from "@rangojs/router/browser";
1972
1972
 
@@ -2002,11 +2002,15 @@ async function initializeApp() {
2002
2002
  }
2003
2003
 
2004
2004
  const app = createElement(Rango);
2005
- hydrateRoot(
2006
- document,
2007
- strictMode === false ? app : createElement(StrictMode, null, app),
2008
- formState ? { formState } : undefined
2009
- );
2005
+ const root = strictMode === false ? app : createElement(StrictMode, null, app);
2006
+ // The server render failed its shell and answered a recovery document
2007
+ // (ssr/shell-error-document.tsx): nothing to hydrate, so render it, and the
2008
+ // router's error boundaries run as on a client navigation.
2009
+ if (document.documentElement.hasAttribute("data-rango-ssr-error")) {
2010
+ createRoot(document).render(root);
2011
+ } else {
2012
+ hydrateRoot(document, root, formState ? { formState } : undefined);
2013
+ }
2010
2014
  }
2011
2015
 
2012
2016
  initializeApp().catch(console.error);
@@ -23,6 +23,8 @@
23
23
  */
24
24
  export interface CacheErrorReporter {
25
25
  _reportBackgroundError?: (error: unknown, category: CacheErrorCategory) => void;
26
+ /** The errors the request printed (router/error-handling.ts printServerError). */
27
+ _printedErrors?: WeakSet<object>;
26
28
  }
27
29
  export type CacheErrorCategory =
28
30
  /** A read failed (transient infra: KV/Cache API error). Degrade to a miss. */
@@ -11,7 +11,10 @@ import { type ErrorInfo, type ResolvedSegment } from "./types.js";
11
11
  *
12
12
  * Only decodeLoaderEntry (the read-site path — client components, so the
13
13
  * throw lands in the router-owned boundary; during document SSR, Fizz emits
14
- * the Suspense fallback and replays the throw at hydration) throws these.
14
+ * the Suspense fallback and replays the throw at hydration, and with no
15
+ * Suspense above the read the throw fails the shell, which renderHTML answers
16
+ * with a recovery document or a 3xx, ssr/shell-error-document.tsx) throws
17
+ * these.
15
18
  * decodeLoaderResults runs during the tree build on settled results, where a
16
19
  * throw would collapse the whole payload — it routes signals through the
17
20
  * errorFallback slot instead (same visual: the slot replaces the children
@@ -22,6 +25,12 @@ import { type ErrorInfo, type ResolvedSegment } from "./types.js";
22
25
  */
23
26
  export declare const LOADER_NOT_FOUND_FALLBACK: unique symbol;
24
27
  export declare const LOADER_REDIRECT: unique symbol;
28
+ /** The redirect a `LoaderRedirect` thrown by throwSignal carries, if any. */
29
+ export declare function loaderRedirectOf(error: unknown): {
30
+ to: string;
31
+ status?: number;
32
+ state?: unknown;
33
+ } | undefined;
25
34
  /**
26
35
  * A failed loader entry's error, rebuilt with the ErrorInfo identity
27
36
  * (name/stack/code/cause) instead of a stripped generic Error.
@@ -29,6 +38,8 @@ export declare const LOADER_REDIRECT: unique symbol;
29
38
  export declare function loaderResultError(result: {
30
39
  error: ErrorInfo;
31
40
  }): Error;
41
+ /** Whether `error` is a rebuild loaderResultError made (see the marker). */
42
+ export declare function isRebuiltLoaderError(error: unknown): boolean;
32
43
  /** A loader that threw: not ok, and not a notFound()/redirect() signal. */
33
44
  export declare function isLoaderError(result: unknown): result is {
34
45
  error: ErrorInfo;
@@ -39,6 +39,26 @@ export declare class DataNotFoundError extends Error {
39
39
  constructor(message?: string, options?: ErrorOptions);
40
40
  }
41
41
  export declare function isDataNotFoundError(error: unknown): error is DataNotFoundError;
42
+ /**
43
+ * A loader's notFound() or redirect() rethrown at its read site
44
+ * (decode-loader-results.ts throwSignal): control flow answered with its own
45
+ * status, not an error to print or report. Name-based, so the RSC environment,
46
+ * whose module instances differ from the SSR one's, classifies it too.
47
+ */
48
+ export declare function isLoaderSignalError(error: unknown): boolean;
49
+ /**
50
+ * An error React's Flight client decoded from an error row: it always sets
51
+ * `digest`. The Flight render printed and reported the original.
52
+ */
53
+ export declare function isFlightDecodedError(error: unknown): boolean;
54
+ /**
55
+ * A render's own cancellation, not a failure: the error React's Flight and
56
+ * Fizz report for each pending task when a stream is aborted with no reason
57
+ * (a client disconnect), or an AbortError while the request itself is
58
+ * aborted. A loader's own AbortController or fetch timeout on a live request
59
+ * is a failure.
60
+ */
61
+ export declare function isAbortedRender(error: unknown, requestSignal?: AbortSignal): boolean;
42
62
  /**
43
63
  * Convenience function to throw a DataNotFoundError
44
64
  * Shorter syntax for common not-found scenarios
@@ -13,6 +13,18 @@ export declare function watchRunnerReloads(events: RunnerReloadEvents | undefine
13
13
  * @internal
14
14
  */
15
15
  export declare function droppedByRunnerReload(): boolean;
16
+ type Serve = () => Promise<Response>;
17
+ /**
18
+ * Serves a request on this router copy, or on the router a runner full reload
19
+ * re-created when the reload dropped this copy before the request or while it
20
+ * ran. What a request imports after the clear is the fresh copy, so one the
21
+ * reload lands inside fails or answers the wrong route: its outcome is
22
+ * discarded. Only GET and HEAD re-run, since a re-run repeats side effects and
23
+ * cannot re-read a body. A reload after the response is returned still splits
24
+ * its stream.
25
+ * @internal
26
+ */
27
+ export declare function serveAcrossRunnerReload(request: Request, serve: Serve, recreated: () => Promise<Serve | undefined>): Promise<Response>;
16
28
  /**
17
29
  * The router the reload re-created for `routerId`, or undefined when none
18
30
  * registered within DEV_RECREATED_ROUTER_WAIT_MS. Importing `sourceFile`
@@ -34,9 +34,11 @@ export type LoaderErrorCallback = (error: unknown, context: {
34
34
  * presentation there.
35
35
  * - redirect() (thrown 3xx Response): the target is resolved through the
36
36
  * soft-redirect same-origin rules NOW so unsafe targets never leave the
37
- * server; the client navigates when the entry decodes. No document-lane
38
- * 302 — pre-stream redirect authority belongs to middleware (doctrine);
39
- * a loader redirect is always a client-side navigate.
37
+ * server; the client navigates when the entry decodes. A read outside
38
+ * Suspense fails the document's server-render shell before anything was
39
+ * sent: that document answers a real 3xx when the redirect carries no
40
+ * state (ssr/shell-error-document.tsx); otherwise a loader redirect is a
41
+ * client-side navigate.
40
42
  *
41
43
  * @param onError - Optional callback invoked when loader errors occur.
42
44
  * This has a simplified signature for internal use - the caller (typically
@@ -44,7 +46,8 @@ export type LoaderErrorCallback = (error: unknown, context: {
44
46
  * OnErrorCallback with complete request context (request, url, env, etc.).
45
47
  * @param useDefaultErrorBoundary - An `ssr: false` loader on a document or
46
48
  * capture render: an error with no declared boundary gets
47
- * `DefaultErrorFallback` and a 500 (it would otherwise reject the tree).
49
+ * `DefaultErrorFallback` (it would otherwise reject the tree), and every
50
+ * error answers 500.
48
51
  */
49
52
  export declare function wrapLoaderWithErrorHandling<T>(promise: Promise<T>, entry: EntryData, segmentId: string, pathname: string, findNearestErrorBoundary: (entry: EntryData | null) => ReactNode | ErrorBoundaryHandler | null, createErrorInfo: (error: unknown, segmentId: string, segmentType: ErrorInfo["segmentType"]) => ErrorInfo, onError?: LoaderErrorCallback, notFoundDeps?: {
50
53
  findNearestNotFoundBoundary: (entry: EntryData | null) => ReactNode | NotFoundBoundaryHandler | null;
@@ -202,6 +202,20 @@ export interface SSRRenderOptions {
202
202
  * otherwise completed document. React's default console.error is kept.
203
203
  */
204
204
  onError?: (error: unknown) => void;
205
+ /**
206
+ * Called once when the render failed before any HTML was produced (a shell
207
+ * error), before renderHTML answers it with a recovery document or rethrows
208
+ * it to the host (dev). `status` and `location` are the answer's, absent on
209
+ * a rethrow. `originated`: the error started in this render; a copy (a
210
+ * loader's masked rebuild, an error Flight decoded, a payload that did not
211
+ * decode) was reported where it started. See ssr/shell-error-document.tsx.
212
+ */
213
+ onShellError?: (shellError: {
214
+ error: unknown;
215
+ originated: boolean;
216
+ status?: number;
217
+ location?: string;
218
+ }) => void;
205
219
  }
206
220
  /**
207
221
  * SSR module interface for HTML rendering
@@ -0,0 +1,27 @@
1
+ /** The request context fields the record reads. */
2
+ interface PrintContext {
3
+ /** The errors this request printed (request-context.ts). */
4
+ _printedErrors?: WeakSet<object>;
5
+ request?: Request;
6
+ }
7
+ /**
8
+ * Print a server-side failure where it originates:
9
+ * `[RSC] <label> (<name>) <pathname>:` and the error object. Once per request:
10
+ * another place that sees the same error object in `requestContext` prints
11
+ * nothing more. A caller that can run outside the request's ALS frame (a
12
+ * Flight onError) passes the context it captured.
13
+ */
14
+ export declare function printServerError(error: unknown, origin: {
15
+ label: string;
16
+ name?: string;
17
+ pathname?: string;
18
+ }, requestContext: PrintContext | undefined): void;
19
+ /**
20
+ * Record an error this module did not print as done: the SSR render printed it
21
+ * in its own module graph, or it is a copy (a loader's masked rebuild, an
22
+ * error Flight decoded) whose original printed where it started.
23
+ */
24
+ export declare function markServerErrorPrinted(error: unknown, requestContext: PrintContext | undefined): void;
25
+ /** Whether this request printed `error`, or recorded it as done. */
26
+ export declare function wasServerErrorPrinted(error: unknown, requestContext: PrintContext | undefined): boolean;
27
+ export {};
@@ -755,6 +755,11 @@ export interface RequestContext<TEnv = DefaultEnv, TParams = Record<string, stri
755
755
  _renderBarrierGuardClosed?: boolean;
756
756
  /** @internal Per-request error dedup set for onError reporting */
757
757
  _reportedErrors: WeakSet<object>;
758
+ /**
759
+ * @internal The errors this request printed to the server console, so a
760
+ * failure prints once where it originated (server/print-server-error.ts). A derived context shares it through the prototype.
761
+ */
762
+ _printedErrors?: WeakSet<object>;
758
763
  /**
759
764
  * @internal Errors this response's Flight or Fizz render reported through
760
765
  * onError: a component that threw does not fail the stream, which completes
@@ -861,7 +866,7 @@ export interface RouteRecordWindow {
861
866
  * This is the type exported to library consumers. Internal code should
862
867
  * use the full RequestContext interface directly.
863
868
  */
864
- export type PublicRequestContext<TEnv = DefaultEnv, TParams = Record<string, string>> = Omit<RequestContext<TEnv, TParams>, "cookie" | "cookies" | "_readCookie" | "_readCookies" | "setCookie" | "deleteCookie" | "_handleStore" | "_transitionWhenRefs" | "_pprReplayPostMatchReason" | "_cacheStore" | "_prerender" | "_onDemandProducer" | "_searchParamsFilter" | "_shellCaptureRun" | "_shellCaptureForVisitor" | "_shellImplicitCache" | "_shellLoaderSeed" | "_shellCaptureLoaderRecords" | "_shellCaptureUnrecordedLoaderPush" | "_shellCaptureLoaderLanes" | "_resolvedCacheKeys" | "_shellKey" | "_shellForcedMiss" | "_prerenderWarm" | "_runAtRequestEntry" | "_routeRecordWindow" | "_shellFragmentPayload" | "_shellCaptureGuardTripped" | "_shellCaptureGuardTrippedLoaderId" | "_explicitTaggedStores" | "_requestTags" | "_recordTagOwners" | "_recordLoaderTags" | "_cacheProfiles" | "_onResponseCallbacks" | "_pendingBackgroundTasks" | "_themeConfig" | "_readTheme" | "_documentCacheRender" | "_payloadVisitorTheme" | "_payloadDeferred" | "_requestKind" | "_payloadVisitorLocation" | "_locationState" | "_routeName" | "_prevRouteKey" | "_visitorMoved" | "_inActionRevalidation" | "_actionRan" | "_reportedErrors" | "_renderErrors" | "_recoveredHandlerErrors" | "_renderBarrier" | "_resolveRenderBarrier" | "_renderBarrierSegmentOrder" | "_treeHasStreaming" | "_renderBarrierWaiters" | "_handlerLoaderDeps" | "_awaitBeforeFlushLoaderIds" | "_renderBarrierHandleSnapshot" | "_renderBarrierGuardClosed" | "_reportBackgroundError" | "_debugPerformance" | "_metricsStore" | "_renderForeground" | "_activeRoutine" | "_renderDiagnosticsEnabled" | "_handlerStart" | "_tracing" | "_basename" | "_routerId" | "_versions" | "_setStatus" | "_rotateStateCookie" | "_setKeepCacheDirective" | "_freshReads" | "_setFreshReadsCookie" | "_requestRoot" | "_requestStart" | "_responseSent" | "_variables" | "_classifiedRoute" | "_requestMode" | "_cacheSignal" | "_dynamic" | "res">;
869
+ export type PublicRequestContext<TEnv = DefaultEnv, TParams = Record<string, string>> = Omit<RequestContext<TEnv, TParams>, "cookie" | "cookies" | "_readCookie" | "_readCookies" | "setCookie" | "deleteCookie" | "_handleStore" | "_transitionWhenRefs" | "_pprReplayPostMatchReason" | "_cacheStore" | "_prerender" | "_onDemandProducer" | "_searchParamsFilter" | "_shellCaptureRun" | "_shellCaptureForVisitor" | "_shellImplicitCache" | "_shellLoaderSeed" | "_shellCaptureLoaderRecords" | "_shellCaptureUnrecordedLoaderPush" | "_shellCaptureLoaderLanes" | "_resolvedCacheKeys" | "_shellKey" | "_shellForcedMiss" | "_prerenderWarm" | "_runAtRequestEntry" | "_routeRecordWindow" | "_shellFragmentPayload" | "_shellCaptureGuardTripped" | "_shellCaptureGuardTrippedLoaderId" | "_explicitTaggedStores" | "_requestTags" | "_recordTagOwners" | "_recordLoaderTags" | "_cacheProfiles" | "_onResponseCallbacks" | "_pendingBackgroundTasks" | "_themeConfig" | "_readTheme" | "_documentCacheRender" | "_payloadVisitorTheme" | "_payloadDeferred" | "_requestKind" | "_payloadVisitorLocation" | "_locationState" | "_routeName" | "_prevRouteKey" | "_visitorMoved" | "_inActionRevalidation" | "_actionRan" | "_reportedErrors" | "_printedErrors" | "_renderErrors" | "_recoveredHandlerErrors" | "_renderBarrier" | "_resolveRenderBarrier" | "_renderBarrierSegmentOrder" | "_treeHasStreaming" | "_renderBarrierWaiters" | "_handlerLoaderDeps" | "_awaitBeforeFlushLoaderIds" | "_renderBarrierHandleSnapshot" | "_renderBarrierGuardClosed" | "_reportBackgroundError" | "_debugPerformance" | "_metricsStore" | "_renderForeground" | "_activeRoutine" | "_renderDiagnosticsEnabled" | "_handlerStart" | "_tracing" | "_basename" | "_routerId" | "_versions" | "_setStatus" | "_rotateStateCookie" | "_setKeepCacheDirective" | "_freshReads" | "_setFreshReadsCookie" | "_requestRoot" | "_requestStart" | "_responseSent" | "_variables" | "_classifiedRoute" | "_requestMode" | "_cacheSignal" | "_dynamic" | "res">;
865
870
  /**
866
871
  * Marker for a waitUntil-scheduled fn whose task promise must NOT enter
867
872
  * _pendingBackgroundTasks. Used by the PPR shell capture for its own task:
@@ -106,6 +106,20 @@ export interface SSRRenderOptions {
106
106
  * otherwise completed document. React's default console.error is kept.
107
107
  */
108
108
  onError?: (error: unknown) => void;
109
+ /**
110
+ * Called once when the render failed before any HTML was produced (a shell
111
+ * error), before renderHTML answers it or rethrows it. `status` and
112
+ * `location` are the answer's; both are absent when the error is rethrown
113
+ * to the host (dev). `originated`: the error started in this render. When
114
+ * false it is a copy (a loader's masked rebuild, an error Flight decoded, a
115
+ * payload that did not decode) whose original was reported where it started.
116
+ */
117
+ onShellError?: (shellError: {
118
+ error: unknown;
119
+ originated: boolean;
120
+ status?: number;
121
+ location?: string;
122
+ }) => void;
109
123
  }
110
124
  /**
111
125
  * SSR dependencies from external packages
@@ -0,0 +1,24 @@
1
+ import type { ResolvedThemeConfig } from "../theme/types.js";
2
+ import type { SSRDependencies } from "./index.js";
3
+ /** What renderHTML answered a shell error with. */
4
+ export interface ShellErrorAnswer {
5
+ body: ReadableStream<Uint8Array>;
6
+ status: number;
7
+ location?: string;
8
+ }
9
+ /**
10
+ * What a recovery document needs. Absent when the payload did not decode or
11
+ * the tree build rejected: the browser builds the same tree from the same
12
+ * payload and would reject the same way.
13
+ */
14
+ export interface ShellErrorRecovery {
15
+ bootstrap: {
16
+ bootstrapScriptContent?: string;
17
+ bootstrapModules?: string[];
18
+ };
19
+ themeConfig: ResolvedThemeConfig | null;
20
+ }
21
+ type ShellErrorRenderDeps = Pick<SSRDependencies, "renderToReadableStream" | "injectRSCPayload">;
22
+ /** Answer a shell error; see the module comment for the order. */
23
+ export declare function answerShellError(deps: ShellErrorRenderDeps, error: unknown, recovery: ShellErrorRecovery | undefined, flight: ReadableStream<Uint8Array> | undefined, nonce: string | undefined): Promise<ShellErrorAnswer>;
24
+ export {};
@@ -80,6 +80,12 @@ export interface SsrRootOptions {
80
80
  * live fizz passes the request's origin; capture/resume pass theirs.
81
81
  */
82
82
  origin?: string;
83
+ /**
84
+ * Called when the segment tree build rejects. renderHTML answers that shell
85
+ * error with a static document: the browser builds the same tree from the
86
+ * same payload and would reject the same way (ssr/shell-error-document.tsx).
87
+ */
88
+ onTreeError?: (error: unknown) => void;
83
89
  }
84
90
  /**
85
91
  * Build the closure component that deserializes the Flight payload, consumes
@@ -299,7 +299,12 @@ export interface RenderRouteOptions {
299
299
  * - Content that suspends in the server pass (`lazy`, a pending or untracked
300
300
  * `use()` promise) is emitted as its fallback and client-rendered, not
301
301
  * hydrated; React reports the boundary in `recoverableErrors`. Outside any
302
- * `<Suspense>` it rejects.
302
+ * `<Suspense>` it rejects: production's server waits for it, this
303
+ * synchronous pass cannot.
304
+ * - An error the server pass throws outside any `<Suspense>` fails the
305
+ * shell. Production answers a recovery document and the browser renders
306
+ * the tree with `createRoot` instead of hydrating it; so does this mode,
307
+ * and the result's `shellError` holds the error.
303
308
  * - Needs @testing-library/react >= 16.2.0; throws on older versions.
304
309
  */
305
310
  hydrate?: boolean;
@@ -389,6 +394,14 @@ export type RenderRouteHydrateResult = RenderRouteResult & {
389
394
  * with `console.error` in development.
390
395
  */
391
396
  recoverableErrors: string[];
397
+ /**
398
+ * The error the server pass threw outside any `<Suspense>` (a shell error),
399
+ * or undefined. When set, the tree was rendered with `createRoot`, as the
400
+ * browser renders production's recovery document, so the router's error
401
+ * boundaries ran on the client: assert what they show. `serverHtml` is then
402
+ * empty.
403
+ */
404
+ shellError: unknown;
392
405
  };
393
406
  export declare function renderRoute(routes: RenderRouteSpec[], options: RenderRouteOptions & {
394
407
  hydrate: true;
@@ -79,10 +79,13 @@ export type LoaderDataResult<T = unknown> = {
79
79
  * resolved `redirect(url, { state })` record (`__rsc_ls_*` keys) — it
80
80
  * travels on the marker because a streaming loader settles after
81
81
  * payload metadata flushed; the redirect navigation merges it at the
82
- * target entry.
82
+ * target entry. `status` is the redirect's own: a document whose shell
83
+ * the read fails answers it as a real redirect when there is no state
84
+ * (ssr/shell-error-document.tsx).
83
85
  */
84
86
  redirect?: {
85
87
  to: string;
88
+ status?: number;
86
89
  state?: Record<string, unknown>;
87
90
  };
88
91
  };
@@ -5209,7 +5209,7 @@ import { useContext as useContext13, useEffect as useEffect11, useRef as useRef9
5209
5209
  import {
5210
5210
  useState as useState11,
5211
5211
  useEffect as useEffect13,
5212
- useLayoutEffect as useLayoutEffect3,
5212
+ useLayoutEffect as useLayoutEffect4,
5213
5213
  useCallback as useCallback4,
5214
5214
  useMemo as useMemo7,
5215
5215
  useRef as useRef11,
@@ -5225,6 +5225,7 @@ import { jsx as jsx5, jsxs as jsxs3 } from "react/jsx-runtime";
5225
5225
  import {
5226
5226
  useCallback as useCallback3,
5227
5227
  useEffect as useEffect12,
5228
+ useLayoutEffect as useLayoutEffect3,
5228
5229
  useMemo as useMemo6,
5229
5230
  useState as useState10
5230
5231
  } from "react";
@@ -5877,7 +5878,7 @@ import {
5877
5878
  Fragment as Fragment4,
5878
5879
  Suspense as Suspense3,
5879
5880
  useDeferredValue,
5880
- useLayoutEffect as useLayoutEffect4,
5881
+ useLayoutEffect as useLayoutEffect5,
5881
5882
  useMemo as useMemo10,
5882
5883
  useState as useState13
5883
5884
  } from "react";
@@ -7077,7 +7078,7 @@ function resolveVersionsFrom(table, routerId2) {
7077
7078
  // package.json
7078
7079
  var package_default = {
7079
7080
  name: "@rangojs/router",
7080
- version: "0.26.2",
7081
+ version: "0.26.4",
7081
7082
  description: "Django-inspired RSC router with composable URL patterns",
7082
7083
  keywords: [
7083
7084
  "react",
@@ -7387,7 +7388,7 @@ import {
7387
7388
  createTemporaryReferenceSet,
7388
7389
  } from "@rangojs/router/internal/deps/browser";
7389
7390
  import { createElement, StrictMode } from "react";
7390
- import { hydrateRoot } from "react-dom/client";
7391
+ import { createRoot, hydrateRoot } from "react-dom/client";
7391
7392
  import { rscStream } from "@rangojs/router/internal/deps/html-stream-client";
7392
7393
  import { initBrowserApp, Rango } from "@rangojs/router/browser";
7393
7394
 
@@ -7423,11 +7424,15 @@ async function initializeApp() {
7423
7424
  }
7424
7425
 
7425
7426
  const app = createElement(Rango);
7426
- hydrateRoot(
7427
- document,
7428
- strictMode === false ? app : createElement(StrictMode, null, app),
7429
- formState ? { formState } : undefined
7430
- );
7427
+ const root = strictMode === false ? app : createElement(StrictMode, null, app);
7428
+ // The server render failed its shell and answered a recovery document
7429
+ // (ssr/shell-error-document.tsx): nothing to hydrate, so render it, and the
7430
+ // router's error boundaries run as on a client navigation.
7431
+ if (document.documentElement.hasAttribute("data-rango-ssr-error")) {
7432
+ createRoot(document).render(root);
7433
+ } else {
7434
+ hydrateRoot(document, root, formState ? { formState } : undefined);
7435
+ }
7431
7436
  }
7432
7437
 
7433
7438
  initializeApp().catch(console.error);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rangojs/router",
3
- "version": "0.26.2",
3
+ "version": "0.26.4",
4
4
  "description": "Django-inspired RSC router with composable URL patterns",
5
5
  "keywords": [
6
6
  "react",
@@ -980,10 +980,10 @@ export const ProductLoader = createLoader(async (ctx) => {
980
980
 
981
981
  Semantics by lane:
982
982
 
983
- | Signal | Document load | Client navigation |
984
- | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------- |
985
- | `notFound()` | Not-found UI resolves server-side (nearest `notFoundBoundary` → router option → default) and rides the envelope; the 404 STATUS is **opportunistic** — real only if the rejection beats Response construction. `ssr: false` (below) makes it deterministic. | 404 UI swaps in, URL preserved, payload stays 200 |
986
- | `redirect()` | 200 document, then a client-side replace to the target — **no document-lane 302 from loaders**; pre-stream redirect authority belongs to middleware | Redirect envelope navigates to the target |
983
+ | Signal | Document load, read inside `<Suspense>` or under `loading()` | Document load, read outside any `<Suspense>` | Client navigation |
984
+ | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------- |
985
+ | `notFound()` | Not-found UI resolves server-side (nearest `notFoundBoundary` → router option → default) and rides the envelope; the 404 STATUS is **opportunistic** — real only if the rejection beats Response construction. `ssr: false` (below) makes it deterministic. | A real 404; the browser renders the not-found UI with the layouts kept (the recovery document, below) | 404 UI swaps in, URL preserved, payload stays 200 |
986
+ | `redirect()` | 200 document, then a client-side replace to the target | A real 3xx (the redirect's own status) with `Location`; with `state`, the recovery document (200), then the client replace carries it | Redirect envelope navigates to the target |
987
987
 
988
988
  With `loader(Def, { ssr: false })` the result is settled BEFORE the document
989
989
  flushes, so both signals are resolved server-side while the tree is built:
@@ -998,11 +998,51 @@ gets no shell, and its renders deliver the signal as above.
998
998
  A flagged loader that throws (anything but `notFound()` or `redirect()`) is
999
999
  resolved the same way: its route's `errorBoundary()` renders at the owning
1000
1000
  segment, else `createRouter({ defaultErrorBoundary })`, else the router's
1001
- default error boundary (the "Internal Server Error" page) with a 500, as a
1002
- handler that throws does; a declared `errorBoundary()` or
1003
- `defaultErrorBoundary` answers 200 here. Layouts above it stay. On a `ppr` shell HIT whose
1004
- run throws, the baked content shows first and the boundary replaces it at
1005
- hydration, after React reports a hydration mismatch.
1001
+ default error boundary (the "Internal Server Error" page), with a 500 in every
1002
+ case, as a handler that throws does. Layouts above it stay. On a `ppr` shell HIT whose run throws, the status
1003
+ went out with the shell (200), the baked content shows first, and the boundary
1004
+ replaces it at hydration, after React reports a hydration mismatch.
1005
+
1006
+ ### A read outside any `<Suspense>` on a document load
1007
+
1008
+ A read that throws with no `<Suspense>` above it (no `loading()`, no inline
1009
+ boundary) fails React's server render before any HTML exists: React's server
1010
+ renderer runs no error boundaries. In production the router then answers a
1011
+ small recovery document that carries the same page data, and the browser
1012
+ renders the page instead of hydrating it, so the document ends where a client
1013
+ navigation to the URL ends:
1014
+
1015
+ | What the read throws | Document status | What the browser shows |
1016
+ | -------------------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------ |
1017
+ | An error | 500 | The route's `errorBoundary()` (or `defaultErrorBoundary`) with the layouts kept; with neither, `RootErrorBoundary` |
1018
+ | `notFound()` | 404 | The not-found UI, layouts kept |
1019
+ | `redirect()` | the redirect's 3xx | The target (the browser follows `Location`) |
1020
+ | `redirect(url, { state })` | 200 | The target, after the client replace that carries the state |
1021
+
1022
+ The same holds for any client component that throws only during the server
1023
+ render: the recovery document is a 500, and the page renders in the browser.
1024
+ In dev an error keeps Vite's error overlay with the original stack;
1025
+ `notFound()` and `redirect()` are answered as in production. A `ppr` route
1026
+ never stores such a document as its shell, and the document cache never stores
1027
+ it. `onError` gets the loader's own report (phase `loader`), with the original
1028
+ error, and no second report for the document. `notFound()` and
1029
+ `redirect()` are control flow: neither is reported nor printed.
1030
+
1031
+ A read under `loading()` or a `<Suspense>` streams the page with that
1032
+ boundary's fallback and keeps the 200 it already sent.
1033
+
1034
+ ### What the server prints
1035
+
1036
+ A loader that throws prints its original error, message and stack, to the
1037
+ server console once per request, where it ran:
1038
+ `[RSC] Loader error (ProductLoader) /product/1:` and the error. That holds
1039
+ whatever renders it (a route `errorBoundary()`, `defaultErrorBoundary`, the
1040
+ default page, `RootErrorBoundary` in the browser), on every lane (a document,
1041
+ a client navigation, an action's revalidation, a prefetch, a `ppr` capture),
1042
+ in dev and production. Only the copy the browser receives is masked
1043
+ (`An error occurred` in production). A handler that reads the same loader
1044
+ with `ctx.use()` does not print it again. `notFound()` and `redirect()` never
1045
+ print.
1006
1046
 
1007
1047
  Session/auth gates belong in middleware (they are request-shaped, not
1008
1048
  data-shaped, and middleware CAN emit a real pre-stream 302). Data-dependent
@@ -733,8 +733,11 @@ boundary, and streamed fresh on every request. It needs a boundary —
733
733
  `loading()` or an inline `<Suspense>` above the reader — or the shell is
734
734
  refused: a masked read with no boundary above it root-postpones, the `<body>`
735
735
  sanity gate refuses the capture, and the route stays an eternal MISS (warned
736
- once per key). A route whose loaders are ALL `ssr: false` needs no
737
- `loading()`: nothing masks, and the shell captures complete.
736
+ once per key). When that read also throws on the MISS render, the document is
737
+ the 500 recovery document the `/loader` skill describes ("A read outside any
738
+ `<Suspense>`"), and nothing is stored. A route whose loaders are ALL
739
+ `ssr: false` needs no `loading()`: nothing masks, and the shell captures
740
+ complete.
738
741
 
739
742
  ```typescript
740
743
  export const ProductLoader = createLoader(async (ctx) => {
@@ -1120,7 +1123,8 @@ Four hard edges (each e2e/unit-pinned):
1120
1123
  - **A rejecting bake-lane loader refuses.** Error UI never bakes. So does a
1121
1124
  value that fails to encode: a function, a class instance, a promise inside a
1122
1125
  `Map` that rejects, or a server component in the value that throws. The
1123
- capture stores nothing, logs the error, reports `cache-write` and backs the
1126
+ capture stores nothing, prints the error once (a loader's as
1127
+ `[RSC] Loader error`), reports `cache-write` and backs the
1124
1128
  key off, the same as a shell component that throws (below). The capture
1125
1129
  encodes the value a second time to pin it for HITs, so a server component
1126
1130
  that throws only on that run refuses it too. A build-time capture skips the
@@ -1612,14 +1616,18 @@ evicted by tag at all — move always-fresh data into a live-lane loader (no
1612
1616
  - **HIT status is committed at the flush**: a failing hole cannot become a
1613
1617
  500/redirect after the first shell byte — error UI renders inline via
1614
1618
  Suspense/error boundaries (the same property any streamed SSR page has after
1615
- its shell flushes).
1619
+ its shell flushes). A MISS has sent nothing yet: an `ssr: false` loader that
1620
+ throws answers 500 there with its route's `errorBoundary()` (or the default
1621
+ one), and the capture stores nothing.
1616
1622
  - **A shell component that throws during capture refuses it**: an async
1617
1623
  server component (or a client component during SSR) inside a Suspense
1618
1624
  boundary that throws does not fail the capture render, so its errored
1619
1625
  boundary would bake into the shared prelude. The capture stores nothing
1620
- instead, logs `[ShellCache] capture` with the error (it goes to `onError`
1621
- as `cache-write` unless the router already reported that same error during
1622
- the render), and backs the key off like any refused capture; the next
1626
+ instead, logs `[ShellCache] capture` with the error unless the error was
1627
+ already printed where it happened (a server component's or a loader's
1628
+ error prints once, as `[RSC] Rendering error` or `[RSC] Loader error`), it
1629
+ goes to `onError` as `cache-write` unless the router already reported that
1630
+ same error during the render, and backs the key off like any refused capture; the next
1623
1631
  capture after the window stores normally. The request that triggered the
1624
1632
  capture is served as before. Only a throw during the capture counts: a
1625
1633
  component that failed in the live render and succeeds when captured is
@@ -564,7 +564,10 @@ path("/product/:slug", ProductPage, { name: "product" }, () => [
564
564
  `loading(fallback, { ssr: false })` on a document load) still sets 500 / 404.
565
565
  A loader's 404 is likewise only set when its rejection settles before the
566
566
  response is built.
567
- - `onError` fires once per failure.
567
+ - `onError` fires once per failure, and the server console prints the
568
+ original error once, where it happened
569
+ (`[RSC] Handler error (<segment>) <path>:`); only what the browser receives
570
+ is masked.
568
571
  - A failed render is never stored: `cache()` skips the write for the scope that
569
572
  holds the failed segment, a `ppr` shell and the document cache refuse the
570
573
  whole document, and a Prerender route still fails the build.
@@ -706,6 +706,34 @@ The resolver receives `{ request, env, url }` and may be async. It runs only
706
706
  for HTML document responses; RSC payloads (navigations, prefetches, `__rsc`
707
707
  requests) and response routes are unaffected.
708
708
 
709
+ ### When the server render fails before any HTML
710
+
711
+ An error thrown during the server render with no `<Suspense>` above it (a
712
+ `useLoader` read of a failed loader, a client component that throws only on
713
+ the server) leaves React no HTML to send: its server renderer runs no error
714
+ boundaries. In production the router answers a small recovery document
715
+ instead of the host's raw 500. It carries the page's data, and the generated
716
+ browser entry renders the page with `createRoot` instead of hydrating it, so
717
+ the route's `errorBoundary()`, `defaultErrorBoundary` or the root error page
718
+ shows with the layouts, as after a client navigation. The status is 500, 404
719
+ for a loader's `notFound()`, and a loader's `redirect()` without state answers
720
+ a real 3xx (with state, 200 and the client replace). When the page data itself failed, the answer is a static error
721
+ page with no scripts and nothing from the error. In dev the error reaches
722
+ Vite's error overlay unchanged. The `/loader` skill has the full table.
723
+
724
+ The browser entry is generated by `rango()`. A hand-written browser entry must
725
+ make the same choice, or React reports a hydration error (#418) on the
726
+ recovery document before it renders the same page:
727
+
728
+ ```typescript
729
+ const root = createElement(StrictMode, null, createElement(Rango));
730
+ if (document.documentElement.hasAttribute("data-rango-ssr-error")) {
731
+ createRoot(document).render(root);
732
+ } else {
733
+ hydrateRoot(document, root, formState ? { formState } : undefined);
734
+ }
735
+ ```
736
+
709
737
  ## Timeouts
710
738
 
711
739
  Off by default. A timed-out action or render start returns a `504` (with an
@@ -688,7 +688,9 @@ layout(CheckoutLayout, () => [
688
688
 
689
689
  In production `error.message` is replaced with `"An error occurred"` (and
690
690
  `stack` / `cause` are dropped), while `error.name` and `error.code` are kept —
691
- branch on `name` or `code`, not on the message.
691
+ branch on `name` or `code`, not on the message. The server console prints the
692
+ original once, message and stack, JS or no-JS, boundary or not:
693
+ `[RSC] Action error (<action id>) <path>:`.
692
694
 
693
695
  ### Not found from an action
694
696