@alxia/react-router 0.10.0 → 0.12.0
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 +370 -20
- 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/errors/api-error.d.ts +86 -0
- package/dist/errors/api-error.d.ts.map +1 -0
- package/dist/errors/api-errors.d.ts +96 -0
- package/dist/errors/api-errors.d.ts.map +1 -0
- package/dist/errors/call-failure.d.ts +45 -0
- package/dist/errors/call-failure.d.ts.map +1 -0
- package/dist/errors/catalogue-check.d.ts +24 -0
- package/dist/errors/catalogue-check.d.ts.map +1 -0
- package/dist/errors/catalogue-read.d.ts +33 -0
- package/dist/errors/catalogue-read.d.ts.map +1 -0
- package/dist/errors/catalogue.d.ts +59 -0
- package/dist/errors/catalogue.d.ts.map +1 -0
- package/dist/errors/code-resolver.d.ts +44 -0
- package/dist/errors/code-resolver.d.ts.map +1 -0
- package/dist/errors/describe-refusal.d.ts +6 -0
- package/dist/errors/describe-refusal.d.ts.map +1 -0
- package/dist/errors/describe.d.ts +3 -0
- package/dist/errors/describe.d.ts.map +1 -0
- package/dist/errors/fetcher-result.d.ts +21 -0
- package/dist/errors/fetcher-result.d.ts.map +1 -0
- package/dist/errors/form-errors.d.ts +8 -0
- package/dist/errors/form-errors.d.ts.map +1 -0
- package/dist/errors/index.d.ts +15 -0
- package/dist/errors/index.d.ts.map +1 -0
- package/dist/errors/index.js +602 -0
- package/dist/errors/index.js.map +29 -0
- package/dist/errors/json-actions.d.ts +38 -0
- package/dist/errors/json-actions.d.ts.map +1 -0
- package/dist/errors/log.d.ts +8 -0
- package/dist/errors/log.d.ts.map +1 -0
- package/dist/errors/message-values.d.ts +11 -0
- package/dist/errors/message-values.d.ts.map +1 -0
- package/dist/errors/messages.d.ts +63 -0
- package/dist/errors/messages.d.ts.map +1 -0
- package/dist/errors/page-path.d.ts +9 -0
- package/dist/errors/page-path.d.ts.map +1 -0
- package/dist/errors/provider.d.ts +20 -0
- package/dist/errors/provider.d.ts.map +1 -0
- package/dist/errors/request-id.d.ts +5 -0
- package/dist/errors/request-id.d.ts.map +1 -0
- package/dist/errors/route-error.d.ts +31 -0
- package/dist/errors/route-error.d.ts.map +1 -0
- package/dist/errors/standard-schema.d.ts +34 -0
- package/dist/errors/standard-schema.d.ts.map +1 -0
- package/dist/errors/submit-form.d.ts +16 -0
- package/dist/errors/submit-form.d.ts.map +1 -0
- package/dist/errors/use-json-fetcher.d.ts +18 -0
- package/dist/errors/use-json-fetcher.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 +3 -1
- package/docs/guide/csp-nonce.md +35 -3
- package/docs/guide/document.md +310 -0
- package/docs/guide/errors.md +838 -0
- package/docs/roadmap.md +36 -1
- package/docs/troubleshooting.md +712 -1
- package/package.json +33 -3
|
@@ -0,0 +1,310 @@
|
|
|
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`, the root passes nothing. `<Links>`,
|
|
65
|
+
`<Scripts>` and `<ScrollRestoration>` fall back to the nonce `<ServerRouter
|
|
66
|
+
nonce>` put in React Router's framework context, so their `nonce` props are
|
|
67
|
+
optional:
|
|
68
|
+
|
|
69
|
+
```tsx
|
|
70
|
+
// app/root.tsx
|
|
71
|
+
export function Layout({ children }: { children: React.ReactNode }) {
|
|
72
|
+
return (
|
|
73
|
+
<html lang="en">
|
|
74
|
+
<head>
|
|
75
|
+
<meta charSet="utf-8" />
|
|
76
|
+
<Links />
|
|
77
|
+
</head>
|
|
78
|
+
<body>
|
|
79
|
+
{children}
|
|
80
|
+
<ScrollRestoration />
|
|
81
|
+
<Scripts />
|
|
82
|
+
</body>
|
|
83
|
+
</html>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Keep `useNonce()` for what React Router does not render: an `InlineScript`,
|
|
89
|
+
a `CspNonceMeta`, and any script or style the app inserts itself.
|
|
90
|
+
|
|
91
|
+
A page that carries a nonce is made for one response. A route's `headers`
|
|
92
|
+
export must never make it `public`: a shared cache would hand the same nonce
|
|
93
|
+
to every visitor.
|
|
94
|
+
|
|
95
|
+
## The theme-boot script
|
|
96
|
+
|
|
97
|
+
A theme library stores the choice (light, dark) and applies it with a class
|
|
98
|
+
or an attribute on `<html>`. Applied after hydration, the page paints in the
|
|
99
|
+
wrong theme first. The cure is an inline, synchronous script in `<head>`,
|
|
100
|
+
which runs before the first paint. alxia ships the mechanism, not the
|
|
101
|
+
script: the code is the theme library's own boot script (for example a UI
|
|
102
|
+
kit's `themeBootScript()`), kept as a constant.
|
|
103
|
+
|
|
104
|
+
```tsx
|
|
105
|
+
// app/root.tsx
|
|
106
|
+
import { CspNonceMeta, InlineScript } from '@alxia/react-router/document';
|
|
107
|
+
import { themeBootScript } from '<your theme library>';
|
|
108
|
+
|
|
109
|
+
const THEME_BOOT = themeBootScript();
|
|
110
|
+
|
|
111
|
+
export function Layout({ children }: { children: React.ReactNode }) {
|
|
112
|
+
return (
|
|
113
|
+
<html lang="en" suppressHydrationWarning>
|
|
114
|
+
<head>
|
|
115
|
+
<InlineScript code={THEME_BOOT} />
|
|
116
|
+
<meta charSet="utf-8" />
|
|
117
|
+
<CspNonceMeta />
|
|
118
|
+
<Links />
|
|
119
|
+
</head>
|
|
120
|
+
<body>{children}</body>
|
|
121
|
+
</html>
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
- **Place it first in `<head>`**, before `<Links>`: an inline script is
|
|
127
|
+
synchronous and runs before the first paint, without waiting for a
|
|
128
|
+
stylesheet. Only a `<meta charSet>` may come before it.
|
|
129
|
+
- **Keep `suppressHydrationWarning` on `<html>`**: the script changes its
|
|
130
|
+
`class` and `style` before React hydrates, and React would warn that they
|
|
131
|
+
differ from the server's HTML. It silences that element's attributes only.
|
|
132
|
+
- **The nonce is the response's**: `script-src 'nonce-…' 'strict-dynamic'`
|
|
133
|
+
allows the script because it carries it. `InlineScript` uses `useNonce()`
|
|
134
|
+
on the server and the document's nonce in the browser, so the attribute
|
|
135
|
+
matches during hydration. `nonce` overrides it for one script.
|
|
136
|
+
- **With no nonce**, the script renders with no `nonce` attribute. The policy
|
|
137
|
+
must then allow it by `'unsafe-inline'` or a hash of the code.
|
|
138
|
+
|
|
139
|
+
> **Warning:** the code is written unescaped. It is a constant of the app,
|
|
140
|
+
> never user input. `InlineScript` throws a `TypeError` when it holds
|
|
141
|
+
> `</script` or `<!--`, which end or confuse the element
|
|
142
|
+
> ([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)):
|
|
143
|
+
> escape it in a string: the code must contain `<\/script`, written
|
|
144
|
+
> `"<\\/script"` in source.
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
`CspNonceMeta` renders `<meta property="csp-nonce" nonce>`, which UI
|
|
148
|
+
libraries read to give the scripts and styles they add at runtime the same
|
|
149
|
+
nonce.
|
|
150
|
+
|
|
151
|
+
## The entry
|
|
152
|
+
|
|
153
|
+
`@alxia/react-router/entry` is `entry.server.tsx` for an app on Bun. It is
|
|
154
|
+
server only, and it renders with the web stream renderer
|
|
155
|
+
(`renderToReadableStream`), so it needs neither `@react-router/node` nor
|
|
156
|
+
`node:stream`. It imports `react`, `react-dom/server`, `react-router` and
|
|
157
|
+
`isbot` only when no `isBot` option is given (it is imported on the first bot
|
|
158
|
+
check); `react-dom` and `isbot` are optional peers, which React Router's
|
|
159
|
+
templates install.
|
|
160
|
+
|
|
161
|
+
```tsx
|
|
162
|
+
// app/entry.server.tsx
|
|
163
|
+
export {
|
|
164
|
+
handleRequest as default,
|
|
165
|
+
streamTimeout,
|
|
166
|
+
} from '@alxia/react-router/entry';
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
It does what React Router's revealed entry does:
|
|
170
|
+
|
|
171
|
+
- **Streams** the shell and the rest as it resolves.
|
|
172
|
+
- **Waits for the whole page** for a bot (`isbot`) and in SPA mode.
|
|
173
|
+
- **Answers HEAD** with the status and the headers, and no body. Under
|
|
174
|
+
alxia a HEAD reaches the entry as a GET (the core drops the body), so this
|
|
175
|
+
is for a server that is not alxia.
|
|
176
|
+
- **Aborts** the render `streamTimeout + 1 000` ms in, or as soon as the
|
|
177
|
+
client leaves (before the shell, or while a bot waits for the whole
|
|
178
|
+
page), and clears the timer when the body ends or the client goes away.
|
|
179
|
+
- **Rejects when the shell fails**, so React Router logs it once and answers
|
|
180
|
+
with its error boundary. Once the shell is out, an error goes to `onError`
|
|
181
|
+
and the status stays as it was sent.
|
|
182
|
+
- Sends `Content-Type: text/html`.
|
|
183
|
+
|
|
184
|
+
And it gives the router the response's nonce: `<ServerRouter nonce>`,
|
|
185
|
+
React's `nonce` option, and `<DocumentNonce>` around the router, so the
|
|
186
|
+
root reads it with `useNonce()`.
|
|
187
|
+
|
|
188
|
+
To change something, build the entry:
|
|
189
|
+
|
|
190
|
+
```tsx
|
|
191
|
+
// app/entry.server.tsx
|
|
192
|
+
import { createEntry } from '@alxia/react-router/entry';
|
|
193
|
+
|
|
194
|
+
const entry = createEntry({
|
|
195
|
+
streamTimeout: 8_000,
|
|
196
|
+
// Default: nonceOf(loadContext)
|
|
197
|
+
nonce: (loadContext, request) => nonceFor(request),
|
|
198
|
+
// Default: isbot(userAgent)
|
|
199
|
+
isBot: (userAgent) => /bot|crawl|spider/i.test(userAgent),
|
|
200
|
+
// Default: console.error
|
|
201
|
+
onError: (error) => logger.error(error),
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
export const streamTimeout = entry.streamTimeout;
|
|
205
|
+
export default entry.handleRequest;
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
`createEntry` returns an `Entry`, `{ handleRequest, streamTimeout }`.
|
|
209
|
+
|
|
210
|
+
| option | default | |
|
|
211
|
+
| --- | --- | --- |
|
|
212
|
+
| `streamTimeout` | `5_000` | ms React Router waits for a pending promise; export it as `streamTimeout` |
|
|
213
|
+
| `nonce` | `nonceOf(loadContext)` | this response's CSP nonce |
|
|
214
|
+
| `isBot` | `isbot`, imported when no `isBot` is given | whether a user agent gets the whole page |
|
|
215
|
+
| `onError` | `console.error` | an error after the shell is out |
|
|
216
|
+
|
|
217
|
+
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`.
|
|
218
|
+
|
|
219
|
+
The nonce is passed to `ServerRouter` only when there is one, which
|
|
220
|
+
satisfies `exactOptionalPropertyTypes`
|
|
221
|
+
([the message](../troubleshooting.md#type---nonce-string--undefined--is-not-assignable-to-type-serverrouterprops-with-exactoptionalpropertytypes-true)).
|
|
222
|
+
Under `react-router dev`, keep `alxia()` in `vite.config.ts`: it keeps one
|
|
223
|
+
copy of `react-router` for the entry and the root
|
|
224
|
+
([the message](../troubleshooting.md#error-you-must-render-this-element-inside-a-hydratedrouter-element)).
|
|
225
|
+
|
|
226
|
+
## An entry of your own
|
|
227
|
+
|
|
228
|
+
An entry that is not the package's wraps `<ServerRouter>` in
|
|
229
|
+
`<DocumentNonce nonce={nonceOf(loadContext)}>` as shown
|
|
230
|
+
[above](#the-nonce-on-the-server). Without it `useNonce()` is `undefined`
|
|
231
|
+
on the server, and the page's inline scripts render with no nonce
|
|
232
|
+
([the message](../troubleshooting.md#refused-to-execute-inline-script-because-it-violates-the-following-content-security-policy-directive-script-src-self-nonce-)).
|
|
233
|
+
|
|
234
|
+
## The page's language
|
|
235
|
+
|
|
236
|
+
`createPageLanguage` gives the root the page's language for `<html lang>` and
|
|
237
|
+
a route's `meta`, and `textDirection` the `dir` that goes with it. The server
|
|
238
|
+
side, negotiating the request's language from a cookie and `Accept-Language`,
|
|
239
|
+
is [`@alxia/language`](https://github.com/softistx/alxia/blob/develop/packages/language/README.md):
|
|
240
|
+
|
|
241
|
+
```ts
|
|
242
|
+
// app/server.ts
|
|
243
|
+
import { language } from '@alxia/language';
|
|
244
|
+
import { createServer } from '@alxia/react-router';
|
|
245
|
+
|
|
246
|
+
const server = createServer({
|
|
247
|
+
configure: (app) =>
|
|
248
|
+
app.use(language({ supported: ['en', 'fr', 'ar'], fallback: 'en', order: ['cookie', 'header'] })),
|
|
249
|
+
});
|
|
250
|
+
export default server;
|
|
251
|
+
declare module '@alxia/react-router' {
|
|
252
|
+
interface Register { server: typeof server }
|
|
253
|
+
}
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
```ts
|
|
257
|
+
// app/language.ts: one instance, imported by the root and the routes
|
|
258
|
+
import { createPageLanguage } from '@alxia/react-router/document';
|
|
259
|
+
|
|
260
|
+
export const { pageLanguage, metaLanguage, chooseLanguage, useChosenLanguage } =
|
|
261
|
+
createPageLanguage({ languages: ['en', 'fr', 'ar'], fallback: 'en' });
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
```tsx
|
|
265
|
+
// app/root.tsx
|
|
266
|
+
import { type AlxiaArgs, withAlxia } from '@alxia/react-router';
|
|
267
|
+
import { textDirection } from '@alxia/react-router/document';
|
|
268
|
+
import { useRouteLoaderData } from 'react-router';
|
|
269
|
+
import { metaLanguage, pageLanguage, useChosenLanguage } from './language';
|
|
270
|
+
import type { Route } from './+types/root';
|
|
271
|
+
|
|
272
|
+
const titles = { en: 'Home', fr: 'Accueil', ar: 'الرئيسية' };
|
|
273
|
+
|
|
274
|
+
// the root loader returns alxia.language, set by the middleware
|
|
275
|
+
export const loader = withAlxia(({ alxia }: Route.LoaderArgs & AlxiaArgs) => ({ language: alxia.language }));
|
|
276
|
+
|
|
277
|
+
export function Layout({ children }: { children: React.ReactNode }) {
|
|
278
|
+
const data = useRouteLoaderData<typeof loader>('root');
|
|
279
|
+
const language = pageLanguage(data?.language);
|
|
280
|
+
useChosenLanguage(); // re-renders the shell when the menu picks a language
|
|
281
|
+
return (
|
|
282
|
+
<html lang={language} dir={textDirection(language)} suppressHydrationWarning>
|
|
283
|
+
{/* … */}
|
|
284
|
+
</html>
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export const meta: Route.MetaFunction = ({ matches }) => [
|
|
289
|
+
{ title: titles[metaLanguage(matches)] },
|
|
290
|
+
];
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
- `pageLanguage(language)` is the supported language, else the fallback. In
|
|
294
|
+
the browser the one picked with `chooseLanguage(language)` wins, and the
|
|
295
|
+
last one seen stands in when the root's data is gone. On the server it
|
|
296
|
+
remembers nothing: one module serves every request. A server that
|
|
297
|
+
registers a DOM shim globally (happy-dom or jsdom) is read as a browser:
|
|
298
|
+
pass `browser: false` there.
|
|
299
|
+
- `metaLanguage(matches)` reads `matches[0].loaderData.language`.
|
|
300
|
+
- `useChosenLanguage()` is the picked language, or `undefined`; it
|
|
301
|
+
re-renders on a pick, and is `undefined` in the server render so
|
|
302
|
+
hydration matches.
|
|
303
|
+
- `textDirection(tag)` is `'rtl'` for `ar`, `fa`, `he`, `ur`, `ps`, `yi`,
|
|
304
|
+
`dv`, `ckb`, `sd` and `ug` (and the legacy `iw` and `ji`), else `'ltr'`, reading the base tag whatever its
|
|
305
|
+
case (`ar-EG`, `AR`). A script subtag is not read: `az-Arab` is `'ltr'`.
|
|
306
|
+
- A fallback outside `languages` throws
|
|
307
|
+
([the message](../troubleshooting.md#typeerror-createpagelanguage-the-fallback--is-not-one-of-the-languages-got-)).
|
|
308
|
+
An error page keeps the root's data, so it keeps the language, except
|
|
309
|
+
for a URL that matches no route or a root loader that threw: there it
|
|
310
|
+
shows the last language seen in the browser, or the fallback ([why](../troubleshooting.md#the-error-page-shows-in-the-fallback-language)).
|