@alxia/react-router 0.11.0 → 0.12.1

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 (49) hide show
  1. package/README.md +201 -19
  2. package/dist/chunks/index-5rt6hnhn.js +42 -0
  3. package/dist/chunks/index-5rt6hnhn.js.map +11 -0
  4. package/dist/chunks/{index-mppz0806.js → index-6jf7zpxz.js} +5 -5
  5. package/dist/chunks/{index-mppz0806.js.map → index-6jf7zpxz.js.map} +1 -1
  6. package/dist/chunks/index-y6vpnjr3.js +123 -0
  7. package/dist/chunks/index-y6vpnjr3.js.map +14 -0
  8. package/dist/context.d.ts +0 -7
  9. package/dist/context.d.ts.map +1 -1
  10. package/dist/document/index.d.ts +11 -0
  11. package/dist/document/index.d.ts.map +1 -0
  12. package/dist/document/index.js +19 -0
  13. package/dist/document/index.js.map +9 -0
  14. package/dist/document/inline-script.d.ts +27 -0
  15. package/dist/document/inline-script.d.ts.map +1 -0
  16. package/dist/document/language-store.d.ts +8 -0
  17. package/dist/document/language-store.d.ts.map +1 -0
  18. package/dist/document/nonce.d.ts +34 -0
  19. package/dist/document/nonce.d.ts.map +1 -0
  20. package/dist/document/page-language.d.ts +39 -0
  21. package/dist/document/page-language.d.ts.map +1 -0
  22. package/dist/document/text-direction.d.ts +10 -0
  23. package/dist/document/text-direction.d.ts.map +1 -0
  24. package/dist/entry/abort.d.ts +21 -0
  25. package/dist/entry/abort.d.ts.map +1 -0
  26. package/dist/entry/create-entry.d.ts +32 -0
  27. package/dist/entry/create-entry.d.ts.map +1 -0
  28. package/dist/entry/index.d.ts +8 -0
  29. package/dist/entry/index.d.ts.map +1 -0
  30. package/dist/entry/index.js +128 -0
  31. package/dist/entry/index.js.map +12 -0
  32. package/dist/entry/whole-page.d.ts +10 -0
  33. package/dist/entry/whole-page.d.ts.map +1 -0
  34. package/dist/index.js +13 -34
  35. package/dist/index.js.map +3 -5
  36. package/dist/pwa/build/index.js +1 -1
  37. package/dist/pwa/index.js +6 -6
  38. package/dist/pwa/index.js.map +1 -1
  39. package/dist/vite/index.d.ts.map +1 -1
  40. package/dist/vite/index.js +29 -2
  41. package/dist/vite/index.js.map +5 -4
  42. package/dist/vite/inline.d.ts +18 -0
  43. package/dist/vite/inline.d.ts.map +1 -0
  44. package/docs/README.md +1 -0
  45. package/docs/guide/csp-nonce.md +37 -3
  46. package/docs/guide/document.md +321 -0
  47. package/docs/roadmap.md +17 -1
  48. package/docs/troubleshooting.md +266 -1
  49. package/package.json +22 -2
@@ -0,0 +1,321 @@
1
+ # The document
2
+
3
+ This page covers what the root route renders for the page's CSP nonce, from
4
+ `@alxia/react-router/document`: the nonce on the server and in the browser,
5
+ an inline script that carries it, the theme-boot recipe, and the page's
6
+ language and text direction.
7
+
8
+ `/document` is safe to import in the browser and imports only `react`.
9
+
10
+ | export | |
11
+ | --- | --- |
12
+ | `DocumentNonce({ nonce, children })` | provides this response's nonce to the server render |
13
+ | `useNonce()` | the page's nonce, on the server and in the browser |
14
+ | `InlineScript({ code, nonce? })` | an inline `<script>` with the nonce; refuses `</script` and `<!--` |
15
+ | `CspNonceMeta()` | `<meta property="csp-nonce" nonce>` |
16
+ | `createPageLanguage({ languages, fallback })` | `pageLanguage`, `metaLanguage`, `chooseLanguage`, `useChosenLanguage` |
17
+ | `textDirection(language)` | `'ltr'` or `'rtl'` for `<html dir>` |
18
+
19
+ ## The nonce on the server
20
+
21
+ With `secureHeaders({ nonce: true })` the context carries a fresh nonce per
22
+ request ([A CSP nonce](csp-nonce.md)). The server render needs it in the
23
+ React tree too, so that the root, and every boundary that replaces it, can
24
+ read it with `useNonce()`. Wrap the router in `entry.server.tsx`:
25
+
26
+ ```tsx
27
+ // app/entry.server.tsx
28
+ import { nonceOf } from '@alxia/react-router';
29
+ import { DocumentNonce } from '@alxia/react-router/document';
30
+
31
+ const nonce = nonceOf(loadContext);
32
+
33
+ <DocumentNonce nonce={nonce}>
34
+ <ServerRouter context={routerContext} url={request.url} />
35
+ </DocumentNonce>;
36
+ ```
37
+
38
+ Do not put the nonce in a loader's data: the browser hides a nonce from the
39
+ page, and loader data would print it as text in the hydration script and in
40
+ every `.data` answer. An error page after an action's error runs no loader,
41
+ and still reads the nonce, since the provider is above the router.
42
+
43
+ ## The nonce in the browser
44
+
45
+ React Router's client knows no nonce, and React would find `nonce` on the
46
+ server's elements and none in its own: a hydration mismatch. So in the
47
+ browser `useNonce()` reads the `nonce` property of the first script or
48
+ `csp-nonce` meta in the document's `<head>` (the browser hides the
49
+ attribute, not the property), else of the first `script[nonce]` anywhere,
50
+ once, and caches it. `DocumentNonce` is not needed there. With none of them
51
+ it returns `undefined`.
52
+
53
+ "The browser" means a global `document`. A server that installs a DOM shim
54
+ on `globalThis` takes the browser path and finds no nonce, so it must not
55
+ render `useNonce()` that way (and passes `browser: () => false` to
56
+ `createPageLanguage`).
57
+
58
+ The first read is cached per module copy. A test with a DOM keeps it across
59
+ tests: reset modules between them, or render the server path with
60
+ `DocumentNonce`.
61
+
62
+ ## What React Router renders
63
+
64
+ With `@alxia/react-router/entry`, `<Scripts>` and `<ScrollRestoration>` need
65
+ no `nonce`: they fall back to the nonce `<ServerRouter nonce>` put in React
66
+ Router's framework context, and they suppress the hydration warning for the
67
+ attribute the browser hides. `<Links>` falls back to it on the server too, but
68
+ does not suppress the warning, so pass it `useNonce()`:
69
+
70
+ ```tsx
71
+ // app/root.tsx
72
+ import { useNonce } from '@alxia/react-router/document';
73
+
74
+ export function Layout({ children }: { children: React.ReactNode }) {
75
+ const nonce = useNonce();
76
+ return (
77
+ <html lang="en">
78
+ <head>
79
+ <meta charSet="utf-8" />
80
+ <Links nonce={nonce} />
81
+ </head>
82
+ <body>
83
+ {children}
84
+ <ScrollRestoration />
85
+ <Scripts />
86
+ </body>
87
+ </html>
88
+ );
89
+ }
90
+ ```
91
+
92
+ Why `<Links>` needs it: in development, React Router renders the critical CSS
93
+ as a `<style nonce>`. The server writes the nonce on it, the browser hides the
94
+ attribute and React Router's client knows none, so without the prop the client
95
+ renders the `<style>` with no nonce and React reports a mismatch
96
+ ([Troubleshooting](../troubleshooting.md#a-tree-hydrated-but-some-attributes-of-the-server-rendered-html-didnt-match-the-client-properties-on-a-style-with-a-nonce)).
97
+ `useNonce()` returns the same value on both sides.
98
+
99
+ Keep `useNonce()` for what React Router does not render: an `InlineScript`,
100
+ a `CspNonceMeta`, and any script or style the app inserts itself.
101
+
102
+ A page that carries a nonce is made for one response. A route's `headers`
103
+ export must never make it `public`: a shared cache would hand the same nonce
104
+ to every visitor.
105
+
106
+ ## The theme-boot script
107
+
108
+ A theme library stores the choice (light, dark) and applies it with a class
109
+ or an attribute on `<html>`. Applied after hydration, the page paints in the
110
+ wrong theme first. The cure is an inline, synchronous script in `<head>`,
111
+ which runs before the first paint. alxia ships the mechanism, not the
112
+ script: the code is the theme library's own boot script (for example a UI
113
+ kit's `themeBootScript()`), kept as a constant.
114
+
115
+ ```tsx
116
+ // app/root.tsx
117
+ import { CspNonceMeta, InlineScript } from '@alxia/react-router/document';
118
+ import { themeBootScript } from '<your theme library>';
119
+
120
+ const THEME_BOOT = themeBootScript();
121
+
122
+ export function Layout({ children }: { children: React.ReactNode }) {
123
+ return (
124
+ <html lang="en" suppressHydrationWarning>
125
+ <head>
126
+ <InlineScript code={THEME_BOOT} />
127
+ <meta charSet="utf-8" />
128
+ <CspNonceMeta />
129
+ <Links />
130
+ </head>
131
+ <body>{children}</body>
132
+ </html>
133
+ );
134
+ }
135
+ ```
136
+
137
+ - **Place it first in `<head>`**, before `<Links>`: an inline script is
138
+ synchronous and runs before the first paint, without waiting for a
139
+ stylesheet. Only a `<meta charSet>` may come before it.
140
+ - **Keep `suppressHydrationWarning` on `<html>`**: the script changes its
141
+ `class` and `style` before React hydrates, and React would warn that they
142
+ differ from the server's HTML. It silences that element's attributes only.
143
+ - **The nonce is the response's**: `script-src 'nonce-…' 'strict-dynamic'`
144
+ allows the script because it carries it. `InlineScript` uses `useNonce()`
145
+ on the server and the document's nonce in the browser, so the attribute
146
+ matches during hydration. `nonce` overrides it for one script.
147
+ - **With no nonce**, the script renders with no `nonce` attribute. The policy
148
+ must then allow it by `'unsafe-inline'` or a hash of the code.
149
+
150
+ > **Warning:** the code is written unescaped. It is a constant of the app,
151
+ > never user input. `InlineScript` throws a `TypeError` when it holds
152
+ > `</script` or `<!--`, which end or confuse the element
153
+ > ([the message](../troubleshooting.md#typeerror-inlinescript-the-code-contains-script-or----which-ends-or-confuses-the-script-element-escape-it-such-as-script-inside-a-string)):
154
+ > escape it in a string: the code must contain `<\/script`, written
155
+ > `"<\\/script"` in source.
156
+
157
+
158
+ `CspNonceMeta` renders `<meta property="csp-nonce" nonce>`, which UI
159
+ libraries read to give the scripts and styles they add at runtime the same
160
+ nonce.
161
+
162
+ ## The entry
163
+
164
+ `@alxia/react-router/entry` is `entry.server.tsx` for an app on Bun. It is
165
+ server only, and it renders with the web stream renderer
166
+ (`renderToReadableStream`), so it needs neither `@react-router/node` nor
167
+ `node:stream`. It imports `react`, `react-dom/server`, `react-router` and
168
+ `isbot` only when no `isBot` option is given (it is imported on the first bot
169
+ check); `react-dom` and `isbot` are optional peers, which React Router's
170
+ templates install.
171
+
172
+ ```tsx
173
+ // app/entry.server.tsx
174
+ export {
175
+ handleRequest as default,
176
+ streamTimeout,
177
+ } from '@alxia/react-router/entry';
178
+ ```
179
+
180
+ It does what React Router's revealed entry does:
181
+
182
+ - **Streams** the shell and the rest as it resolves.
183
+ - **Waits for the whole page** for a bot (`isbot`) and in SPA mode.
184
+ - **Answers HEAD** with the status and the headers, and no body. Under
185
+ alxia a HEAD reaches the entry as a GET (the core drops the body), so this
186
+ is for a server that is not alxia.
187
+ - **Aborts** the render `streamTimeout + 1 000` ms in, or as soon as the
188
+ client leaves (before the shell, or while a bot waits for the whole
189
+ page), and clears the timer when the body ends or the client goes away.
190
+ - **Rejects when the shell fails**, so React Router logs it once and answers
191
+ with its error boundary. Once the shell is out, an error goes to `onError`
192
+ and the status stays as it was sent.
193
+ - Sends `Content-Type: text/html`.
194
+
195
+ And it gives the router the response's nonce: `<ServerRouter nonce>`,
196
+ React's `nonce` option, and `<DocumentNonce>` around the router, so the
197
+ root reads it with `useNonce()`.
198
+
199
+ To change something, build the entry:
200
+
201
+ ```tsx
202
+ // app/entry.server.tsx
203
+ import { createEntry } from '@alxia/react-router/entry';
204
+
205
+ const entry = createEntry({
206
+ streamTimeout: 8_000,
207
+ // Default: nonceOf(loadContext)
208
+ nonce: (loadContext, request) => nonceFor(request),
209
+ // Default: isbot(userAgent)
210
+ isBot: (userAgent) => /bot|crawl|spider/i.test(userAgent),
211
+ // Default: console.error
212
+ onError: (error) => logger.error(error),
213
+ });
214
+
215
+ export const streamTimeout = entry.streamTimeout;
216
+ export default entry.handleRequest;
217
+ ```
218
+
219
+ `createEntry` returns an `Entry`, `{ handleRequest, streamTimeout }`.
220
+
221
+ | option | default | |
222
+ | --- | --- | --- |
223
+ | `streamTimeout` | `5_000` | ms React Router waits for a pending promise; export it as `streamTimeout` |
224
+ | `nonce` | `nonceOf(loadContext)` | this response's CSP nonce |
225
+ | `isBot` | `isbot`, imported when no `isBot` is given | whether a user agent gets the whole page |
226
+ | `onError` | `console.error` | an error after the shell is out |
227
+
228
+ An error inside a `<Suspense>` that fails during the first render pass, before the shell is out, turns the status into 500 and is not reported to `onError`, as in React Router's own entry; log it in the route's loader or `ErrorBoundary`.
229
+
230
+ The nonce is passed to `ServerRouter` only when there is one, which
231
+ satisfies `exactOptionalPropertyTypes`
232
+ ([the message](../troubleshooting.md#type---nonce-string--undefined--is-not-assignable-to-type-serverrouterprops-with-exactoptionalpropertytypes-true)).
233
+ Under `react-router dev`, keep `alxia()` in `vite.config.ts`: it keeps one
234
+ copy of `react-router` for the entry and the root
235
+ ([the message](../troubleshooting.md#error-you-must-render-this-element-inside-a-hydratedrouter-element)).
236
+
237
+ ## An entry of your own
238
+
239
+ An entry that is not the package's wraps `<ServerRouter>` in
240
+ `<DocumentNonce nonce={nonceOf(loadContext)}>` as shown
241
+ [above](#the-nonce-on-the-server). Without it `useNonce()` is `undefined`
242
+ on the server, and the page's inline scripts render with no nonce
243
+ ([the message](../troubleshooting.md#refused-to-execute-inline-script-because-it-violates-the-following-content-security-policy-directive-script-src-self-nonce-)).
244
+
245
+ ## The page's language
246
+
247
+ `createPageLanguage` gives the root the page's language for `<html lang>` and
248
+ a route's `meta`, and `textDirection` the `dir` that goes with it. The server
249
+ side, negotiating the request's language from a cookie and `Accept-Language`,
250
+ is [`@alxia/language`](https://github.com/softistx/alxia/blob/develop/packages/language/README.md):
251
+
252
+ ```ts
253
+ // app/server.ts
254
+ import { language } from '@alxia/language';
255
+ import { createServer } from '@alxia/react-router';
256
+
257
+ const server = createServer({
258
+ configure: (app) =>
259
+ app.use(language({ supported: ['en', 'fr', 'ar'], fallback: 'en', order: ['cookie', 'header'] })),
260
+ });
261
+ export default server;
262
+ declare module '@alxia/react-router' {
263
+ interface Register { server: typeof server }
264
+ }
265
+ ```
266
+
267
+ ```ts
268
+ // app/language.ts: one instance, imported by the root and the routes
269
+ import { createPageLanguage } from '@alxia/react-router/document';
270
+
271
+ export const { pageLanguage, metaLanguage, chooseLanguage, useChosenLanguage } =
272
+ createPageLanguage({ languages: ['en', 'fr', 'ar'], fallback: 'en' });
273
+ ```
274
+
275
+ ```tsx
276
+ // app/root.tsx
277
+ import { type AlxiaArgs, withAlxia } from '@alxia/react-router';
278
+ import { textDirection } from '@alxia/react-router/document';
279
+ import { useRouteLoaderData } from 'react-router';
280
+ import { metaLanguage, pageLanguage, useChosenLanguage } from './language';
281
+ import type { Route } from './+types/root';
282
+
283
+ const titles = { en: 'Home', fr: 'Accueil', ar: 'الرئيسية' };
284
+
285
+ // the root loader returns alxia.language, set by the middleware
286
+ export const loader = withAlxia(({ alxia }: Route.LoaderArgs & AlxiaArgs) => ({ language: alxia.language }));
287
+
288
+ export function Layout({ children }: { children: React.ReactNode }) {
289
+ const data = useRouteLoaderData<typeof loader>('root');
290
+ const language = pageLanguage(data?.language);
291
+ useChosenLanguage(); // re-renders the shell when the menu picks a language
292
+ return (
293
+ <html lang={language} dir={textDirection(language)} suppressHydrationWarning>
294
+ {/* … */}
295
+ </html>
296
+ );
297
+ }
298
+
299
+ export const meta: Route.MetaFunction = ({ matches }) => [
300
+ { title: titles[metaLanguage(matches)] },
301
+ ];
302
+ ```
303
+
304
+ - `pageLanguage(language)` is the supported language, else the fallback. In
305
+ the browser the one picked with `chooseLanguage(language)` wins, and the
306
+ last one seen stands in when the root's data is gone. On the server it
307
+ remembers nothing: one module serves every request. A server that
308
+ registers a DOM shim globally (happy-dom or jsdom) is read as a browser:
309
+ pass `browser: false` there.
310
+ - `metaLanguage(matches)` reads `matches[0].loaderData.language`.
311
+ - `useChosenLanguage()` is the picked language, or `undefined`; it
312
+ re-renders on a pick, and is `undefined` in the server render so
313
+ hydration matches.
314
+ - `textDirection(tag)` is `'rtl'` for `ar`, `fa`, `he`, `ur`, `ps`, `yi`,
315
+ `dv`, `ckb`, `sd` and `ug` (and the legacy `iw` and `ji`), else `'ltr'`, reading the base tag whatever its
316
+ case (`ar-EG`, `AR`). A script subtag is not read: `az-Arab` is `'ltr'`.
317
+ - A fallback outside `languages` throws
318
+ ([the message](../troubleshooting.md#typeerror-createpagelanguage-the-fallback--is-not-one-of-the-languages-got-)).
319
+ An error page keeps the root's data, so it keeps the language, except
320
+ for a URL that matches no route or a root loader that threw: there it
321
+ shows the last language seen in the browser, or the fallback ([why](../troubleshooting.md#the-error-page-shows-in-the-fallback-language)).
package/docs/roadmap.md CHANGED
@@ -7,7 +7,7 @@ number on it. Every release, with each change it made, is in
7
7
 
8
8
  ## Now
9
9
 
10
- Nothing in progress.
10
+ Nothing planned.
11
11
 
12
12
  ## Next
13
13
 
@@ -15,6 +15,8 @@ Nothing planned.
15
15
 
16
16
  ## Later
17
17
 
18
+ - **Hreflang alternates** for an app with one URL per language. Today the
19
+ language is negotiated on a single URL, so there is no alternate to link.
18
20
  - **`service-worker.js` served revalidated with no setup.** Today the app
19
21
  adds `noCache` for `SERVICE_WORKER_URL` itself.
20
22
  - **A check of an offline page's policy.** The offline page declares its
@@ -33,6 +35,8 @@ Nothing planned.
33
35
 
34
36
  ## Not planned
35
37
 
38
+ - **The theme's own boot script.** `InlineScript` renders it with the nonce;
39
+ its text, its storage keys and class names, are the UI library's.
36
40
  - **React Router 7.** Its loaders' context is a `RouterContextProvider`
37
41
  only behind `future.v8_middleware`, and the line is in maintenance.
38
42
  - **Node or Cloudflare.** alxia is a Bun framework; React Router's own
@@ -53,6 +57,18 @@ Nothing planned.
53
57
 
54
58
  ## Shipped
55
59
 
60
+ ### 0.12.0
61
+
62
+ - **`@alxia/react-router/document` and `/entry`: the boot of a page.**
63
+ `DocumentNonce`, `useNonce`, `InlineScript` and `CspNonceMeta` give the
64
+ root the response's CSP nonce, an inline boot script that carries it and
65
+ the `csp-nonce` meta; `createPageLanguage` and `textDirection` give the
66
+ page's language and text direction. `entry.server.tsx` becomes
67
+ `export { handleRequest as default, streamTimeout } from '@alxia/react-router/entry'`:
68
+ a streamed shell on Bun's web renderer, the whole page for a bot and in SPA
69
+ mode, no body for HEAD, and the nonce handed to the router, with no
70
+ `@react-router/node` and no `node:stream`.
71
+
56
72
  ### 0.11.0
57
73
 
58
74
  - **`@alxia/react-router/errors`: an API's refusals, worded for the page.**
@@ -26,6 +26,14 @@ a loader, a message React Router or the browser prints, or an error from
26
26
  - [`TypeError: reactRouter(): client is …, which is not a directory. …`](#typeerror-reactrouter-client-is--which-is-not-a-directory-)
27
27
  - [`TypeError: reactRouter(): … cannot be served at a path of its own name; rename it. …`](#typeerror-reactrouter--cannot-be-served-at-a-path-of-its-own-name-rename-it-)
28
28
  - [`Refused to execute inline script because it violates the following Content Security Policy directive: "default-src 'none'"`](#refused-to-execute-inline-script-because-it-violates-the-following-content-security-policy-directive-default-src-none)
29
+ - [`Refused to execute inline script because it violates the following Content Security Policy directive: "script-src 'self' 'nonce-…'"`](#refused-to-execute-inline-script-because-it-violates-the-following-content-security-policy-directive-script-src-self-nonce-)
30
+ - [`A tree hydrated but some attributes of the server rendered HTML didn't match the client properties`](#a-tree-hydrated-but-some-attributes-of-the-server-rendered-html-didnt-match-the-client-properties)
31
+ - [`A tree hydrated but some attributes of the server rendered HTML didn't match the client properties` on a `<style>` with a `nonce`](#a-tree-hydrated-but-some-attributes-of-the-server-rendered-html-didnt-match-the-client-properties-on-a-style-with-a-nonce)
32
+ - [`useNonce()` is undefined on the server](#usenonce-is-undefined-on-the-server)
33
+ - [`TypeError: InlineScript(): the code contains "</script" or "<!--", which ends or confuses the script element; escape it, such as "<\/script" inside a string`](#typeerror-inlinescript-the-code-contains-script-or----which-ends-or-confuses-the-script-element-escape-it-such-as-script-inside-a-string)
34
+ - [`TypeError: createPageLanguage(): the fallback … is not one of the languages; got ….`](#typeerror-createpagelanguage-the-fallback--is-not-one-of-the-languages-got-)
35
+ - [The error page shows in the fallback language](#the-error-page-shows-in-the-fallback-language)
36
+ - [`Error: You must render this element inside a <HydratedRouter> element`](#error-you-must-render-this-element-inside-a-hydratedrouter-element)
29
37
  - [`alxia-react-router: … already exists, and reveal leaves it as it is. Run alxia-react-router reveal --force to overwrite it.`](#alxia-react-router--already-exists-and-reveal-leaves-it-as-it-is-run-alxia-react-router-reveal---force-to-overwrite-it)
30
38
  - [`alxia-react-router: no vite.config.ts in …. Run reveal from the app's root, beside vite.config.ts.`](#alxia-react-router-no-viteconfigts-in--run-reveal-from-the-apps-root-beside-viteconfigts)
31
39
  - [`alxia-react-router: … gives alxia() an entry reveal cannot read. …`](#alxia-react-router--gives-alxia-an-entry-reveal-cannot-read-)
@@ -44,6 +52,7 @@ a loader, a message React Router or the browser prints, or an error from
44
52
  - [`TypeError: defineWorkerContract(): cacheVersion must be a non-empty name without '-', …`](#typeerror-defineworkercontract-cacheversion-must-be-a-non-empty-name-without---)
45
53
  - [`parseBrowserManifest(): not React Router's browser manifest …`](#parsebrowsermanifest-not-react-routers-browser-manifest-)
46
54
  - [`error: Cannot find package 'react' from '…/@alxia/react-router/dist/pwa/react/index.js'`](#error-cannot-find-package-react-from-alxiareact-routerdistpwareactindexjs)
55
+ - [`error: Cannot find package 'isbot' from '…/@alxia/react-router/dist/entry/index.js'`](#error-cannot-find-package-isbot-from-alxiareact-routerdistentryindexjs)
47
56
  - [`startWorker(): no precache list. …`](#startworker-no-precache-list-)
48
57
  - [`TypeError: startWorker(): contract is required: …`](#typeerror-startworker-contract-is-required-)
49
58
  - [`error: Could not resolve: "workbox-core". Maybe you need to "bun install"?`](#error-could-not-resolve-workbox-core-maybe-you-need-to-bun-install)
@@ -538,6 +547,236 @@ or `contentSecurityPolicy: false`. Better than `'unsafe-inline'`: a nonce
538
547
  per request, with `nonce: true` and `nonceOf(loadContext)` in
539
548
  `entry.server.tsx` ([guide](guide/csp-nonce.md#a-csp-nonce)).
540
549
 
550
+ ### `Refused to execute inline script because it violates the following Content Security Policy directive: "script-src 'self' 'nonce-…'"`
551
+
552
+ **When:** the root renders an `<InlineScript>` (a theme's boot script, say)
553
+ and the policy names a nonce.
554
+
555
+ **Why:** the script rendered with no `nonce` attribute, because
556
+ `useNonce()` found no nonce: the server render has no `<DocumentNonce>`
557
+ around the router (an entry of your own that does not provide it), so
558
+ `InlineScript` renders a bare `<script>`. The browser then refuses it, and
559
+ the page flashes the wrong theme.
560
+
561
+ **Fix:** wrap the router in the nonce `secureHeaders({ nonce: true })`
562
+ made, in `entry.server.tsx`, or pass one to the script:
563
+
564
+ ```tsx
565
+ // app/entry.server.tsx
566
+ import { nonceOf } from '@alxia/react-router';
567
+ import { DocumentNonce } from '@alxia/react-router/document';
568
+
569
+ return (
570
+ <DocumentNonce nonce={nonceOf(loadContext)}>
571
+ <ServerRouter context={routerContext} url={request.url} />
572
+ </DocumentNonce>
573
+ );
574
+ ```
575
+
576
+ or, for one script, `<InlineScript code={THEME_BOOT} nonce={nonce} />`.
577
+
578
+ Check the response: `curl -s -A 'Mozilla/5.0 Chrome/130' http://localhost:5173/ | grep -o '<script[^>]*>'`
579
+ should show `nonce="…"` on the boot script too
580
+ ([guide](guide/document.md#the-nonce-on-the-server)).
581
+
582
+ ### `A tree hydrated but some attributes of the server rendered HTML didn't match the client properties`
583
+
584
+ **When:** the page hydrates, and the warning names `<html>` with a `class`
585
+ or `style` the server did not render.
586
+
587
+ **Why:** a boot script (a theme's) sets `class` and `style` on `<html>`
588
+ before React hydrates, so the DOM no longer equals the server's HTML.
589
+
590
+ **Fix:** keep `suppressHydrationWarning` on `<html>`. It silences that
591
+ element's own attributes only, not its children:
592
+
593
+ ```tsx
594
+ // app/root.tsx
595
+ <html lang="en" suppressHydrationWarning>
596
+ <head>
597
+ <InlineScript code={THEME_BOOT} />
598
+ </head>
599
+ <body>{children}</body>
600
+ </html>
601
+ ```
602
+
603
+ A warning on another element is a real mismatch: a `nonce` the browser hid
604
+ is read by `useNonce()` from the document, so never pass it from loader
605
+ data ([guide](guide/document.md#the-theme-boot-script)).
606
+
607
+ ### `A tree hydrated but some attributes of the server rendered HTML didn't match the client properties` on a `<style>` with a `nonce`
608
+
609
+ **When:** in development (`react-router dev`) with a CSP nonce, the console
610
+ warns that a tree hydrated but some attributes did not match, and the diff
611
+ shows `nonce="…"` on the `<style data-react-router-critical-css>` in `<head>`.
612
+
613
+ **Why:** React Router renders the critical CSS as a `<style nonce>`. The
614
+ server writes the nonce from `<ServerRouter nonce>`, the browser hides the
615
+ attribute, and `<Links />` has no nonce to render on the client. Unlike
616
+ `<Scripts>` and `<ScrollRestoration>`, `<Links>` does not suppress that
617
+ warning.
618
+
619
+ **Fix:** pass it the nonce `useNonce()` reads, on both sides:
620
+
621
+ ```tsx
622
+ // app/root.tsx
623
+ import { useNonce } from '@alxia/react-router/document';
624
+
625
+ export function Layout({ children }: { children: React.ReactNode }) {
626
+ const nonce = useNonce();
627
+ return (
628
+ <html lang="en">
629
+ <head>
630
+ <Links nonce={nonce} />
631
+ </head>
632
+ <body>{children}</body>
633
+ </html>
634
+ );
635
+ }
636
+ ```
637
+
638
+ `<Scripts>` and `<ScrollRestoration>` need nothing
639
+ ([guide](guide/document.md#what-react-router-renders)).
640
+
641
+ ### `useNonce()` is undefined on the server
642
+
643
+ **When:** the page's inline scripts or `<CspNonceMeta />` render with no
644
+ `nonce` attribute although `secureHeaders({ nonce: true })` set one, and
645
+ `useNonce()` returns `undefined` in the root.
646
+
647
+ **Why:** the server render has no `<DocumentNonce>` around the router. The
648
+ entry of `@alxia/react-router/entry` adds it; an entry of your own, such
649
+ as React Router's revealed one, does not, and `ServerRouter`'s own `nonce`
650
+ prop only reaches the scripts React Router writes.
651
+
652
+ **Fix:** re-export the package's entry, or wrap the router in your own:
653
+
654
+ ```tsx
655
+ // app/entry.server.tsx
656
+ export {
657
+ handleRequest as default,
658
+ streamTimeout,
659
+ } from '@alxia/react-router/entry';
660
+ ```
661
+
662
+ ```tsx
663
+ // An entry of your own
664
+ import { nonceOf } from '@alxia/react-router';
665
+ import { DocumentNonce } from '@alxia/react-router/document';
666
+
667
+ <DocumentNonce nonce={nonceOf(loadContext)}>
668
+ <ServerRouter context={routerContext} url={request.url} />
669
+ </DocumentNonce>;
670
+ ```
671
+
672
+ ([guide](guide/document.md#the-entry))
673
+
674
+ ### `TypeError: InlineScript(): the code contains "</script" or "<!--", which ends or confuses the script element; escape it, such as "<\/script" inside a string`
675
+
676
+ **When:** a page renders an `<InlineScript code={…} />` whose code holds
677
+ `</script` (in any case) or `<!--`.
678
+
679
+ **Why:** the code is written into the page unescaped. A `</script` ends the
680
+ element early, so what follows is HTML, not script, and can be markup an
681
+ attacker chose; a `<!--` makes the HTML parser treat the following
682
+ `<script` as part of the script's text. A nonce does not help: it only says
683
+ the script may run. The component refuses the code instead of rendering it.
684
+
685
+ **Fix:** keep the code a constant of the app and escape the sequence inside
686
+ a string. The code that reaches `InlineScript` must contain `<\/`, which the
687
+ browser's script reads as `</`, and which is written `"<\\/script"` in a
688
+ source string literal:
689
+
690
+ ```tsx
691
+ <InlineScript code={'document.title = "<\\/script>"'} />
692
+ ```
693
+
694
+ Never build the code from user input; pass such a value through a `data-`
695
+ attribute and read it from the script.
696
+ ([guide](guide/document.md#the-theme-boot-script))
697
+
698
+ ### `TypeError: createPageLanguage(): the fallback … is not one of the languages; got ….`
699
+
700
+ ```text
701
+ TypeError: createPageLanguage(): the fallback "de" is not one of the languages; got ["en","fr"].
702
+ ```
703
+
704
+ **When:** the module that calls `createPageLanguage` loads.
705
+
706
+ **Why:** `fallback` is the language of a page whose root data gives none, so
707
+ it must be one of `languages`. The usual cause is a fallback in another
708
+ spelling (`en-US` for `en`), or a `languages` list built from a different
709
+ source than the fallback.
710
+
711
+ **Fix:** name a language of the list:
712
+
713
+ ```ts
714
+ createPageLanguage({ languages: ['en', 'fr'], fallback: 'en' });
715
+ ```
716
+
717
+ ### The error page shows in the fallback language
718
+
719
+ **When:** on an error document, the root's `<html lang>` or text shows the
720
+ fallback language although the visitor uses another.
721
+
722
+ **Why:** a child route's error keeps the root's loader data. React Router
723
+ keeps `loaderData` for every loader that succeeded, so
724
+ `useRouteLoaderData('root')` still has it in `Layout` and `ErrorBoundary`.
725
+ The data is missing in only two cases:
726
+
727
+ - the URL matches no route, which is a 404 that runs no loader at all, the
728
+ root's included;
729
+ - the root's own loader threw.
730
+
731
+ With no data, `pageLanguage(undefined)` has no language to read. In the
732
+ browser it keeps the last language it saw and the one picked with
733
+ `chooseLanguage`; a document the server renders as an error has seen
734
+ neither, so the fallback shows.
735
+
736
+ **Fix, 404:** add a splat route whose loader throws a 404, so the URL
737
+ matches and the root loader runs:
738
+
739
+ ```ts
740
+ // app/routes.ts: add it last in your route config
741
+ import { route } from '@react-router/dev/routes';
742
+
743
+ route('*', 'routes/not-found.tsx');
744
+ ```
745
+
746
+ ```ts
747
+ // app/routes/not-found.tsx
748
+ import { data } from 'react-router';
749
+
750
+ export const loader = () => {
751
+ throw data(null, { status: 404 });
752
+ };
753
+ ```
754
+
755
+ **Fix, root loader:** keep the root loader to `{ language: alxia.language }`,
756
+ with nothing fallible in it. Load fallible data in child routes, or catch it
757
+ and return it as a field.
758
+
759
+ **Fix, client:** none is needed: a failed client navigation keeps the last
760
+ language through `pageLanguage`.
761
+
762
+ ### `Error: You must render this element inside a <HydratedRouter> element`
763
+
764
+ **When:** under `react-router dev` or after `react-router build`, every page
765
+ answers "Unexpected Server Error" with this message, once
766
+ `entry.server.tsx` re-exports `@alxia/react-router/entry`.
767
+
768
+ **Why:** the server holds two copies of `react-router`: the one that
769
+ renders `ServerRouter` in the entry, and the one `<Links>` and `<Scripts>`
770
+ in the root read. They share no context. `alxia()` from
771
+ `@alxia/react-router/vite` keeps them to one (it inlines `/entry` and
772
+ `/document`, by an alias in dev and in the build, so they import the app's
773
+ `react-router`). The message means `@alxia/react-router` is listed in
774
+ `ssr.external` and `alxia()` is not in use.
775
+
776
+ **Fix:** add `alxia()` to `vite.config.ts` beside `reactRouter()`, or list
777
+ `@alxia/react-router` in `ssr.noExternal` and remove it from
778
+ `ssr.external`.
779
+
541
780
  ### `alxia-react-router: … already exists, and reveal leaves it as it is. Run alxia-react-router reveal --force to overwrite it.`
542
781
 
543
782
  ```text
@@ -910,6 +1149,30 @@ this message too.
910
1149
  `/pwa/react` from the page alone. The worker and a build script import
911
1150
  `@alxia/react-router/pwa`, which imports nothing.
912
1151
 
1152
+ ### `error: Cannot find package 'isbot' from '…/@alxia/react-router/dist/entry/index.js'`
1153
+
1154
+ **When:** a request with a user agent reaches `entry.server.tsx` re-exporting
1155
+ `@alxia/react-router/entry` (or a `createEntry()` with no `isBot` option).
1156
+
1157
+ ```text
1158
+ error: Cannot find package 'isbot' from '/app/node_modules/@alxia/react-router/dist/entry/index.js'
1159
+ ```
1160
+
1161
+ **Why:** with no `isBot` option, `/entry` asks `isbot` whether a request
1162
+ comes from a crawler, which then gets the whole page, and imports it on the
1163
+ first request that needs it. `isbot` and `react-dom` are optional peers of
1164
+ the package, so nothing installs them for you. React Router's templates
1165
+ install both; an app made another way may not.
1166
+
1167
+ **Fix:** add them where the server is built and run:
1168
+
1169
+ ```sh
1170
+ bun add isbot react-dom
1171
+ ```
1172
+
1173
+ Or give the `isBot` option, which replaces the check: `isbot` is then never
1174
+ imported, and only `react-dom` is needed.
1175
+
913
1176
  ### `startWorker(): no precache list. …`
914
1177
 
915
1178
  **Why:** the full message ends "Build this worker with `alxia()` from
@@ -1490,7 +1753,9 @@ error TS2375: Type '{ context: EntryContext; url: string; nonce: string | undefi
1490
1753
  `ServerRouterProps.nonce` is optional without `undefined`. React's
1491
1754
  `nonce` option takes `undefined`, so only the prop complains.
1492
1755
 
1493
- **Fix:** spread the prop in only when there is a nonce:
1756
+ **Fix:** use `@alxia/react-router/entry`, whose `handleRequest` does it
1757
+ ([guide](guide/document.md#the-entry)), or spread the prop in only when
1758
+ there is a nonce, in an entry of your own:
1494
1759
 
1495
1760
  ```tsx
1496
1761
  const nonce = nonceOf(loadContext);