@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.
- package/README.md +201 -19
- package/dist/chunks/index-5rt6hnhn.js +42 -0
- package/dist/chunks/index-5rt6hnhn.js.map +11 -0
- package/dist/chunks/{index-mppz0806.js → index-6jf7zpxz.js} +5 -5
- package/dist/chunks/{index-mppz0806.js.map → index-6jf7zpxz.js.map} +1 -1
- package/dist/chunks/index-y6vpnjr3.js +123 -0
- package/dist/chunks/index-y6vpnjr3.js.map +14 -0
- package/dist/context.d.ts +0 -7
- package/dist/context.d.ts.map +1 -1
- package/dist/document/index.d.ts +11 -0
- package/dist/document/index.d.ts.map +1 -0
- package/dist/document/index.js +19 -0
- package/dist/document/index.js.map +9 -0
- package/dist/document/inline-script.d.ts +27 -0
- package/dist/document/inline-script.d.ts.map +1 -0
- package/dist/document/language-store.d.ts +8 -0
- package/dist/document/language-store.d.ts.map +1 -0
- package/dist/document/nonce.d.ts +34 -0
- package/dist/document/nonce.d.ts.map +1 -0
- package/dist/document/page-language.d.ts +39 -0
- package/dist/document/page-language.d.ts.map +1 -0
- package/dist/document/text-direction.d.ts +10 -0
- package/dist/document/text-direction.d.ts.map +1 -0
- package/dist/entry/abort.d.ts +21 -0
- package/dist/entry/abort.d.ts.map +1 -0
- package/dist/entry/create-entry.d.ts +32 -0
- package/dist/entry/create-entry.d.ts.map +1 -0
- package/dist/entry/index.d.ts +8 -0
- package/dist/entry/index.d.ts.map +1 -0
- package/dist/entry/index.js +128 -0
- package/dist/entry/index.js.map +12 -0
- package/dist/entry/whole-page.d.ts +10 -0
- package/dist/entry/whole-page.d.ts.map +1 -0
- package/dist/index.js +13 -34
- package/dist/index.js.map +3 -5
- package/dist/pwa/build/index.js +1 -1
- package/dist/pwa/index.js +6 -6
- package/dist/pwa/index.js.map +1 -1
- package/dist/vite/index.d.ts.map +1 -1
- package/dist/vite/index.js +29 -2
- package/dist/vite/index.js.map +5 -4
- package/dist/vite/inline.d.ts +18 -0
- package/dist/vite/inline.d.ts.map +1 -0
- package/docs/README.md +1 -0
- package/docs/guide/csp-nonce.md +37 -3
- package/docs/guide/document.md +321 -0
- package/docs/roadmap.md +17 -1
- package/docs/troubleshooting.md +266 -1
- 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
|
|
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.**
|
package/docs/troubleshooting.md
CHANGED
|
@@ -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:**
|
|
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);
|