@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
@@ -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
- serverHtml = withoutBrowserGlobals(() => renderToString(serverUi));
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
- hydrate: true,
1056
- onRecoverableError: (error: unknown) => {
1057
- recoverableErrors.push(
1058
- error instanceof Error ? error.message : String(error),
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, { router, serverHtml, recoverableErrors });
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
- el.classList.add(value);
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
- // First VALID candidate wins — an empty/garbage cookie value (e.g. a
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 as Theme);
156
- applyThemeToDocument(explicit as Theme, config);
173
+ setThemeState(explicit);
174
+ applyThemeToDocument(explicit, config);
157
175
  }
158
176
  // eslint-disable-next-line react-hooks/exhaustive-deps
159
177
  }, []);
@@ -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?: { to: string; state?: Record<string, unknown> };
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
- hydrateRoot(
51
- document,
52
- strictMode === false ? app : createElement(StrictMode, null, app),
53
- formState ? { formState } : undefined
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);