@rangojs/router 0.26.2 → 0.26.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin/rango.js +11 -7
- package/dist/types/cache/cache-error.d.ts +2 -0
- package/dist/types/decode-loader-results.d.ts +12 -1
- package/dist/types/errors.d.ts +20 -0
- package/dist/types/router/loader-resolution.d.ts +7 -4
- package/dist/types/rsc/types.d.ts +14 -0
- package/dist/types/server/print-server-error.d.ts +27 -0
- package/dist/types/server/request-context.d.ts +6 -1
- package/dist/types/ssr/index.d.ts +14 -0
- package/dist/types/ssr/shell-error-document.d.ts +24 -0
- package/dist/types/ssr/ssr-root.d.ts +6 -0
- package/dist/types/testing/render-route.d.ts +14 -1
- package/dist/types/types/boundaries.d.ts +4 -1
- package/dist/vite/index.js +14 -9
- package/package.json +1 -1
- package/skills/loader/SKILL.md +49 -9
- package/skills/ppr/SKILL.md +15 -7
- package/skills/route/SKILL.md +4 -1
- package/skills/router-setup/SKILL.md +28 -0
- package/skills/server-actions/SKILL.md +3 -1
- package/skills/testing/client-components.md +30 -16
- package/src/cache/cache-error.ts +11 -2
- package/src/decode-loader-results.ts +29 -1
- package/src/errors.ts +39 -0
- package/src/router/loader-resolution.ts +32 -7
- package/src/router/segment-resolution/helpers.ts +12 -1
- package/src/router.ts +14 -1
- package/src/rsc/handler.ts +31 -9
- package/src/rsc/loader-fetch.ts +32 -7
- package/src/rsc/progressive-enhancement.ts +13 -4
- package/src/rsc/render-pipeline.ts +9 -1
- package/src/rsc/rsc-rendering.ts +4 -0
- package/src/rsc/server-action.ts +16 -0
- package/src/rsc/ssr-setup.ts +23 -1
- package/src/rsc/types.ts +15 -0
- package/src/server/print-server-error.ts +87 -0
- package/src/server/request-context.ts +8 -0
- package/src/ssr/index.tsx +113 -10
- package/src/ssr/shell-error-document.tsx +139 -0
- package/src/ssr/ssr-root.tsx +15 -5
- package/src/testing/render-route.tsx +52 -11
- package/src/theme/ThemeProvider.tsx +31 -13
- package/src/types/boundaries.ts +8 -2
- package/src/vite/plugins/virtual-entries.ts +10 -6
|
@@ -384,7 +384,12 @@ export interface RenderRouteOptions {
|
|
|
384
384
|
* - Content that suspends in the server pass (`lazy`, a pending or untracked
|
|
385
385
|
* `use()` promise) is emitted as its fallback and client-rendered, not
|
|
386
386
|
* hydrated; React reports the boundary in `recoverableErrors`. Outside any
|
|
387
|
-
* `<Suspense>` it rejects
|
|
387
|
+
* `<Suspense>` it rejects: production's server waits for it, this
|
|
388
|
+
* synchronous pass cannot.
|
|
389
|
+
* - An error the server pass throws outside any `<Suspense>` fails the
|
|
390
|
+
* shell. Production answers a recovery document and the browser renders
|
|
391
|
+
* the tree with `createRoot` instead of hydrating it; so does this mode,
|
|
392
|
+
* and the result's `shellError` holds the error.
|
|
388
393
|
* - Needs @testing-library/react >= 16.2.0; throws on older versions.
|
|
389
394
|
*/
|
|
390
395
|
hydrate?: boolean;
|
|
@@ -479,6 +484,14 @@ export type RenderRouteHydrateResult = RenderRouteResult & {
|
|
|
479
484
|
* with `console.error` in development.
|
|
480
485
|
*/
|
|
481
486
|
recoverableErrors: string[];
|
|
487
|
+
/**
|
|
488
|
+
* The error the server pass threw outside any `<Suspense>` (a shell error),
|
|
489
|
+
* or undefined. When set, the tree was rendered with `createRoot`, as the
|
|
490
|
+
* browser renders production's recovery document, so the router's error
|
|
491
|
+
* boundaries ran on the client: assert what they show. `serverHtml` is then
|
|
492
|
+
* empty.
|
|
493
|
+
*/
|
|
494
|
+
shellError: unknown;
|
|
482
495
|
};
|
|
483
496
|
|
|
484
497
|
interface ResolvedMatch {
|
|
@@ -1015,6 +1028,8 @@ export async function renderRoute(
|
|
|
1015
1028
|
);
|
|
1016
1029
|
|
|
1017
1030
|
let serverHtml: string | undefined;
|
|
1031
|
+
let shellError: unknown;
|
|
1032
|
+
let shellFailed = false;
|
|
1018
1033
|
const recoverableErrors: string[] = [];
|
|
1019
1034
|
if (options.hydrate) {
|
|
1020
1035
|
// RTL forwards onRecoverableError to hydrateRoot from 16.2.0. 16.0-16.1
|
|
@@ -1035,7 +1050,14 @@ export async function renderRoute(
|
|
|
1035
1050
|
) : (
|
|
1036
1051
|
ui
|
|
1037
1052
|
);
|
|
1038
|
-
|
|
1053
|
+
try {
|
|
1054
|
+
serverHtml = withoutBrowserGlobals(() => renderToString(serverUi));
|
|
1055
|
+
} catch (error) {
|
|
1056
|
+
if (isSynchronousSuspension(error)) throw error;
|
|
1057
|
+
shellFailed = true;
|
|
1058
|
+
shellError = error;
|
|
1059
|
+
serverHtml = "";
|
|
1060
|
+
}
|
|
1039
1061
|
}
|
|
1040
1062
|
|
|
1041
1063
|
// Wrap render in an awaited async act so a tree that suspends (async loaders,
|
|
@@ -1051,14 +1073,15 @@ export async function renderRoute(
|
|
|
1051
1073
|
result = render(ui, {
|
|
1052
1074
|
baseElement: document.body,
|
|
1053
1075
|
container,
|
|
1054
|
-
...(serverHtml !== undefined &&
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1076
|
+
...(serverHtml !== undefined &&
|
|
1077
|
+
!shellFailed && {
|
|
1078
|
+
hydrate: true,
|
|
1079
|
+
onRecoverableError: (error: unknown) => {
|
|
1080
|
+
recoverableErrors.push(
|
|
1081
|
+
error instanceof Error ? error.message : String(error),
|
|
1082
|
+
);
|
|
1083
|
+
},
|
|
1084
|
+
}),
|
|
1062
1085
|
});
|
|
1063
1086
|
});
|
|
1064
1087
|
|
|
@@ -1081,7 +1104,25 @@ export async function renderRoute(
|
|
|
1081
1104
|
};
|
|
1082
1105
|
|
|
1083
1106
|
if (serverHtml === undefined) return Object.assign(result, { router });
|
|
1084
|
-
return Object.assign(result, {
|
|
1107
|
+
return Object.assign(result, {
|
|
1108
|
+
router,
|
|
1109
|
+
serverHtml,
|
|
1110
|
+
recoverableErrors,
|
|
1111
|
+
shellError,
|
|
1112
|
+
});
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
/**
|
|
1116
|
+
* renderToString's error for a root that suspended: Fizz waits for that
|
|
1117
|
+
* promise in production, so it is no shell error.
|
|
1118
|
+
*/
|
|
1119
|
+
function isSynchronousSuspension(error: unknown): boolean {
|
|
1120
|
+
return (
|
|
1121
|
+
error instanceof Error &&
|
|
1122
|
+
error.message.startsWith(
|
|
1123
|
+
"A component suspended while responding to synchronous input",
|
|
1124
|
+
)
|
|
1125
|
+
);
|
|
1085
1126
|
}
|
|
1086
1127
|
|
|
1087
1128
|
/**
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
import React, {
|
|
15
15
|
useCallback,
|
|
16
16
|
useEffect,
|
|
17
|
+
useLayoutEffect,
|
|
17
18
|
useMemo,
|
|
18
19
|
useState,
|
|
19
20
|
useRef,
|
|
@@ -65,6 +66,19 @@ function readThemeFromStorage(storageKey: string): string | null {
|
|
|
65
66
|
}
|
|
66
67
|
}
|
|
67
68
|
|
|
69
|
+
/**
|
|
70
|
+
* The visitor's explicit stored theme: the first VALID of the cookie and
|
|
71
|
+
* localStorage. An empty/garbage cookie value (e.g. a deleted cookie leaving
|
|
72
|
+
* "theme=") must not shadow a valid localStorage value behind a bare
|
|
73
|
+
* null-coalesce.
|
|
74
|
+
*/
|
|
75
|
+
function readStoredTheme(config: ResolvedThemeConfig): Theme | null {
|
|
76
|
+
return ([
|
|
77
|
+
readThemeFromCookie(config.storageKey),
|
|
78
|
+
readThemeFromStorage(config.storageKey),
|
|
79
|
+
].find((v) => v !== null && isValidTheme(v, config)) ?? null) as Theme | null;
|
|
80
|
+
}
|
|
81
|
+
|
|
68
82
|
function writeThemeToCookie(storageKey: string, theme: Theme): void {
|
|
69
83
|
if (typeof document === "undefined") return;
|
|
70
84
|
|
|
@@ -97,15 +111,16 @@ function applyThemeToDocument(theme: Theme, config: ResolvedThemeConfig): void {
|
|
|
97
111
|
if (config.attribute === "class") {
|
|
98
112
|
for (const t of config.themes) {
|
|
99
113
|
const v = config.value[t] || t;
|
|
100
|
-
el.classList.remove(v);
|
|
114
|
+
if (v !== value && el.classList.contains(v)) el.classList.remove(v);
|
|
101
115
|
}
|
|
102
|
-
|
|
116
|
+
// Applying the active theme again mutates nothing (a hydrated page).
|
|
117
|
+
if (!el.classList.contains(value)) el.classList.add(value);
|
|
103
118
|
} else {
|
|
104
119
|
el.setAttribute(config.attribute, value);
|
|
105
120
|
}
|
|
106
121
|
|
|
107
122
|
if (config.enableColorScheme) {
|
|
108
|
-
el.style.colorScheme = resolved;
|
|
123
|
+
if (el.style.colorScheme !== resolved) el.style.colorScheme = resolved;
|
|
109
124
|
}
|
|
110
125
|
}
|
|
111
126
|
|
|
@@ -129,6 +144,16 @@ export function ThemeProvider({
|
|
|
129
144
|
|
|
130
145
|
const [systemTheme, setSystemTheme] = useState<ResolvedTheme>("light");
|
|
131
146
|
|
|
147
|
+
// A document rendered with createRoot instead of hydrated (the recovery
|
|
148
|
+
// document a server-render shell error answers, ssr/shell-error-document.tsx)
|
|
149
|
+
// mounts <html> fresh: React drops every attribute the head's theme script
|
|
150
|
+
// set. Apply the theme again before the first paint. On a hydrated document
|
|
151
|
+
// this writes what the script already applied.
|
|
152
|
+
useLayoutEffect(() => {
|
|
153
|
+
applyThemeToDocument(readStoredTheme(config) ?? theme, config);
|
|
154
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
155
|
+
}, []);
|
|
156
|
+
|
|
132
157
|
useEffect(() => {
|
|
133
158
|
setSystemTheme(getSystemTheme());
|
|
134
159
|
// Re-sync state from an EXPLICITLY stored theme after mount. initialTheme
|
|
@@ -143,17 +168,10 @@ export function ThemeProvider({
|
|
|
143
168
|
// Only an explicit cookie/localStorage value re-syncs — a defaultTheme
|
|
144
169
|
// fallback must not override a server-provided initialTheme when the
|
|
145
170
|
// visitor never chose a theme.
|
|
146
|
-
|
|
147
|
-
// deleted cookie leaving "theme=") must not shadow a valid localStorage
|
|
148
|
-
// value behind a bare null-coalesce.
|
|
149
|
-
const explicit =
|
|
150
|
-
[
|
|
151
|
-
readThemeFromCookie(config.storageKey),
|
|
152
|
-
readThemeFromStorage(config.storageKey),
|
|
153
|
-
].find((v) => v !== null && isValidTheme(v, config)) ?? null;
|
|
171
|
+
const explicit = readStoredTheme(config);
|
|
154
172
|
if (explicit !== null && explicit !== theme) {
|
|
155
|
-
setThemeState(explicit
|
|
156
|
-
applyThemeToDocument(explicit
|
|
173
|
+
setThemeState(explicit);
|
|
174
|
+
applyThemeToDocument(explicit, config);
|
|
157
175
|
}
|
|
158
176
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
159
177
|
}, []);
|
package/src/types/boundaries.ts
CHANGED
|
@@ -90,9 +90,15 @@ export type LoaderDataResult<T = unknown> =
|
|
|
90
90
|
* resolved `redirect(url, { state })` record (`__rsc_ls_*` keys) — it
|
|
91
91
|
* travels on the marker because a streaming loader settles after
|
|
92
92
|
* payload metadata flushed; the redirect navigation merges it at the
|
|
93
|
-
* target entry.
|
|
93
|
+
* target entry. `status` is the redirect's own: a document whose shell
|
|
94
|
+
* the read fails answers it as a real redirect when there is no state
|
|
95
|
+
* (ssr/shell-error-document.tsx).
|
|
94
96
|
*/
|
|
95
|
-
redirect?: {
|
|
97
|
+
redirect?: {
|
|
98
|
+
to: string;
|
|
99
|
+
status?: number;
|
|
100
|
+
state?: Record<string, unknown>;
|
|
101
|
+
};
|
|
96
102
|
};
|
|
97
103
|
|
|
98
104
|
export function isLoaderDataResult(value: unknown): value is LoaderDataResult {
|
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
createTemporaryReferenceSet,
|
|
12
12
|
} from "@rangojs/router/internal/deps/browser";
|
|
13
13
|
import { createElement, StrictMode } from "react";
|
|
14
|
-
import { hydrateRoot } from "react-dom/client";
|
|
14
|
+
import { createRoot, hydrateRoot } from "react-dom/client";
|
|
15
15
|
import { rscStream } from "@rangojs/router/internal/deps/html-stream-client";
|
|
16
16
|
import { initBrowserApp, Rango } from "@rangojs/router/browser";
|
|
17
17
|
|
|
@@ -47,11 +47,15 @@ async function initializeApp() {
|
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
const app = createElement(Rango);
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
)
|
|
50
|
+
const root = strictMode === false ? app : createElement(StrictMode, null, app);
|
|
51
|
+
// The server render failed its shell and answered a recovery document
|
|
52
|
+
// (ssr/shell-error-document.tsx): nothing to hydrate, so render it, and the
|
|
53
|
+
// router's error boundaries run as on a client navigation.
|
|
54
|
+
if (document.documentElement.hasAttribute("data-rango-ssr-error")) {
|
|
55
|
+
createRoot(document).render(root);
|
|
56
|
+
} else {
|
|
57
|
+
hydrateRoot(document, root, formState ? { formState } : undefined);
|
|
58
|
+
}
|
|
55
59
|
}
|
|
56
60
|
|
|
57
61
|
initializeApp().catch(console.error);
|