@k8ordo/router 0.1.0 → 1.0.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 +18 -9
- package/dist/base.d.mts +16 -0
- package/dist/base.mjs +45 -0
- package/dist/boundary.d.mts +13 -10
- package/dist/boundary.mjs +19 -6
- package/dist/define-routes.d.mts +22 -18
- package/dist/define-routes.mjs +15 -2
- package/dist/index.d.mts +9 -7
- package/dist/index.mjs +6 -4
- package/dist/links.d.mts +42 -13
- package/dist/links.mjs +44 -10
- package/dist/location.d.mts +22 -6
- package/dist/location.mjs +29 -6
- package/dist/match.d.mts +13 -5
- package/dist/match.mjs +8 -3
- package/dist/navigation.d.mts +61 -19
- package/dist/navigation.mjs +107 -29
- package/dist/not-found.d.mts +11 -0
- package/dist/not-found.mjs +28 -0
- package/dist/paths.d.mts +35 -19
- package/dist/register.d.mts +84 -14
- package/dist/router.d.mts +14 -8
- package/dist/router.mjs +29 -14
- package/docs/GUIDE.md +300 -58
- package/docs/llms.txt +74 -18
- package/package.json +17 -14
package/dist/router.mjs
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
"use client";
|
|
2
|
+
import { withoutBase } from "./base.mjs";
|
|
2
3
|
import { NavigationGeneration, useInterceptedNavigation } from "./navigation.mjs";
|
|
3
4
|
import { setBoundaryOutlet } from "./define-routes.mjs";
|
|
4
|
-
import { PathnameProvider } from "./location.mjs";
|
|
5
|
-
import { createContext, use, useMemo, useState } from "react";
|
|
5
|
+
import { PathnameProvider, appPathname } from "./location.mjs";
|
|
6
|
+
import { createContext, use, useDeferredValue, useMemo, useState } from "react";
|
|
6
7
|
import { jsx } from "react/jsx-runtime";
|
|
7
8
|
//#region src/router.tsx
|
|
8
9
|
const RouterContext = createContext(null);
|
|
@@ -29,6 +30,11 @@ const Outlet = () => {
|
|
|
29
30
|
});
|
|
30
31
|
};
|
|
31
32
|
setBoundaryOutlet(() => /* @__PURE__ */ jsx(Outlet, {}));
|
|
33
|
+
/** The table's match for a URL's pathname; a URL outside the base has none. */
|
|
34
|
+
const matchUrl = (routes, pathname) => {
|
|
35
|
+
const own = withoutBase(pathname);
|
|
36
|
+
return own === null ? null : routes.match(own);
|
|
37
|
+
};
|
|
32
38
|
/**
|
|
33
39
|
* Mounts a route table on the Navigation API: every same-origin navigation
|
|
34
40
|
* that the table claims is handled in the client, and the rest is left to the
|
|
@@ -40,34 +46,41 @@ setBoundaryOutlet(() => /* @__PURE__ */ jsx(Outlet, {}));
|
|
|
40
46
|
* mounts.
|
|
41
47
|
*/
|
|
42
48
|
function Router({ routes }) {
|
|
43
|
-
const [
|
|
49
|
+
const [latest, setLatest] = useState(() => matchUrl(routes, location.pathname));
|
|
44
50
|
const { generation } = useInterceptedNavigation({
|
|
45
|
-
claim: (url) => routes
|
|
46
|
-
load: (url) => routes
|
|
47
|
-
apply:
|
|
51
|
+
claim: (url) => matchUrl(routes, url.pathname) !== null,
|
|
52
|
+
load: (url) => matchUrl(routes, url.pathname),
|
|
53
|
+
apply: setLatest
|
|
48
54
|
});
|
|
55
|
+
const match = useDeferredValue(latest);
|
|
49
56
|
const value = useMemo(() => ({
|
|
50
57
|
routes,
|
|
51
58
|
match
|
|
52
59
|
}), [routes, match]);
|
|
60
|
+
const stack = useMemo(() => match === null ? null : /* @__PURE__ */ jsx(RenderStack, {
|
|
61
|
+
stack: match.stack,
|
|
62
|
+
index: 0
|
|
63
|
+
}), [match]);
|
|
53
64
|
return /* @__PURE__ */ jsx(PathnameProvider, {
|
|
54
|
-
pathname:
|
|
65
|
+
pathname: appPathname(),
|
|
55
66
|
children: /* @__PURE__ */ jsx(NavigationGeneration, {
|
|
56
67
|
value: generation,
|
|
57
68
|
children: /* @__PURE__ */ jsx(RouterContext, {
|
|
58
69
|
value,
|
|
59
|
-
children:
|
|
60
|
-
stack: match.stack,
|
|
61
|
-
index: 0
|
|
62
|
-
})
|
|
70
|
+
children: stack
|
|
63
71
|
})
|
|
64
72
|
})
|
|
65
73
|
});
|
|
66
74
|
}
|
|
67
|
-
/**
|
|
75
|
+
/**
|
|
76
|
+
* The winning pattern and its params, untyped — `useParams` narrows. It reads
|
|
77
|
+
* the match `<Router>` provides, so under `@k8ordo/framework`, which mounts
|
|
78
|
+
* no `<Router>`, a page receives `params` as a prop and a component below it
|
|
79
|
+
* reads them with `useMatch`.
|
|
80
|
+
*/
|
|
68
81
|
function useRoute() {
|
|
69
82
|
const current = use(RouterContext)?.match ?? null;
|
|
70
|
-
if (current === null) throw new Error("useRoute must render inside a matched <Router>");
|
|
83
|
+
if (current === null) throw new Error("useRoute must render inside a matched <Router> — under @k8ordo/framework a page receives params as a prop, and useMatch reads them below it");
|
|
71
84
|
return {
|
|
72
85
|
pattern: current.pattern,
|
|
73
86
|
params: current.params
|
|
@@ -78,7 +91,9 @@ function useRoute() {
|
|
|
78
91
|
* inferred from the pattern literal, verified against `Register`, and read
|
|
79
92
|
* from context at runtime, so no component imports the route table. The
|
|
80
93
|
* belief is checked: rendering under any other pattern throws instead of
|
|
81
|
-
* silently returning params of the wrong shape.
|
|
94
|
+
* silently returning params of the wrong shape. Like `useRoute`, it needs a
|
|
95
|
+
* `<Router>` above it: under `@k8ordo/framework` a page receives `params` as
|
|
96
|
+
* a prop, and a component below it reads them with `useMatch`.
|
|
82
97
|
*/
|
|
83
98
|
function useParams(pattern) {
|
|
84
99
|
const { pattern: current, params } = useRoute();
|
package/docs/GUIDE.md
CHANGED
|
@@ -78,10 +78,14 @@ export const routes = defineRoutes({
|
|
|
78
78
|
only appear once in an object, so two sections at the same depth could not
|
|
79
79
|
otherwise have different layouts.
|
|
80
80
|
- **A trailing slash is the same pathname.** `/products/` matches `/products`.
|
|
81
|
+
- A branch may name a **`loading`** component — no props — to show while
|
|
82
|
+
what is below it suspends: `{ layout, loading, children }` puts a
|
|
83
|
+
`<Suspense>` there, inside the branch's `error` boundary.
|
|
81
84
|
- A branch may name an **`error`** component beside its layout:
|
|
82
85
|
`{ layout, error, children }`. When anything below throws, it renders in
|
|
83
|
-
the layout's hole instead — with `{ error, reset }` as props
|
|
84
|
-
|
|
86
|
+
the layout's hole instead — with `{ error, reset }` as props (`ErrorProps`;
|
|
87
|
+
the component's type is `ErrorComponent`) — and the frame around it
|
|
88
|
+
survives. Leaving the page that failed clears the failure.
|
|
85
89
|
|
|
86
90
|
### Order is the rule
|
|
87
91
|
|
|
@@ -100,13 +104,13 @@ Put `/*` last, for the same reason.
|
|
|
100
104
|
|
|
101
105
|
A table is checked when the module loads, not when someone first navigates:
|
|
102
106
|
|
|
103
|
-
| written | error
|
|
104
|
-
| --------------------------------------------------------- |
|
|
105
|
-
| the same full pattern twice, wherever the copies nest | `route pattern "/x" is declared twice`
|
|
106
|
-
| a group with no children (it would redeclare the index) | `route group "/(oops)" must have children`
|
|
107
|
-
| a key not starting with `/` | `route pattern "x" must start with "/"`
|
|
108
|
-
| parentheses that are not exactly a group (`/(admin)/new`) | `route group "/(admin)/new" must be "/(name)" and nothing else` |
|
|
109
|
-
| a pattern URLPattern cannot parse | URLPattern's own `TypeError`
|
|
107
|
+
| written | error |
|
|
108
|
+
| --------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
|
|
109
|
+
| the same full pattern twice, wherever the copies nest | `route pattern "/x" is declared twice` |
|
|
110
|
+
| a group with no children (it would redeclare the index) | `route group "/(oops)" must have children` |
|
|
111
|
+
| a key not starting with `/` | `route pattern "x" must start with "/"` |
|
|
112
|
+
| parentheses that are not exactly a group (`/(admin)/new`) | `route group "/(admin)/new" must be "/(name)" and nothing else — a regular expression is not part of the grammar` |
|
|
113
|
+
| a pattern URLPattern cannot parse | URLPattern's own `TypeError` |
|
|
110
114
|
|
|
111
115
|
## Mounting it
|
|
112
116
|
|
|
@@ -135,16 +139,30 @@ nothing rather than guessing.
|
|
|
135
139
|
|
|
136
140
|
Four kinds of navigation are never the application's, whatever the table says:
|
|
137
141
|
a reload, a form submitted with a body (POST), a download, and a fragment-only
|
|
138
|
-
change
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
142
|
+
change — nor is any navigation the platform does not let a page intercept,
|
|
143
|
+
such as one to another origin. Intercepting any of the four would silently do
|
|
144
|
+
nothing where the platform would have done the obvious thing — a POST body
|
|
145
|
+
only the server can act on, an `F5` that stops reloading. A GET form carries
|
|
146
|
+
no body, so the search-shaped submissions `@k8ordo/state` builds still come
|
|
147
|
+
through.
|
|
148
|
+
|
|
149
|
+
A `/*` at the end of the table answers every pathname, so it claims a link to
|
|
150
|
+
a file the host serves as well: `/report.pdf` renders the `/*` component
|
|
151
|
+
instead of the file. Mark such a link with `download` — the browser then
|
|
152
|
+
reports a download at the click, and the router leaves it alone. (Under the
|
|
153
|
+
framework the runtime claims every same-origin URL too, finds out from the
|
|
154
|
+
answer, and reloads into the file; `download` saves that round trip.)
|
|
144
155
|
|
|
145
156
|
A leaf can be `React.lazy(...)`, which the table stores as any other component;
|
|
146
|
-
put a `<Suspense>` in
|
|
147
|
-
|
|
157
|
+
put a `<Suspense>` in a layout above it so there is somewhere to fall back to
|
|
158
|
+
while the chunk arrives. The fallback shows on the first render and on a
|
|
159
|
+
navigation that mounts that `<Suspense>` anew. A page change under a
|
|
160
|
+
`<Suspense>` already on screen renders in the background, so the previous page
|
|
161
|
+
stays until the chunk is in. A branch that names an `error` wraps what is
|
|
162
|
+
below in a `<Suspense fallback={null}>` of its own, so a lazy page under that
|
|
163
|
+
branch shows nothing there instead of reaching a layout's fallback above it:
|
|
164
|
+
give such a page its `<Suspense>` below the boundary — in a nested branch's
|
|
165
|
+
layout, or around the lazy component itself.
|
|
148
166
|
|
|
149
167
|
## Links and navigation
|
|
150
168
|
|
|
@@ -185,17 +203,28 @@ matchPath('/products/:id', pathname); // the same, pure, for a pathname in hand
|
|
|
185
203
|
`/*` to mean "everything below it" — what a sidebar asks when it wants to
|
|
186
204
|
know which section of the site is open. The pattern's own page is not below
|
|
187
205
|
it: `/products/*` matches `/products/42` and not `/products`, which is
|
|
188
|
-
`useMatch('/products')
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
206
|
+
`useMatch('/products')`. A section link that wants to be marked on the index
|
|
207
|
+
as much as below it asks `useMatch('/products/*', { inclusive: true })`;
|
|
208
|
+
`matchPath` takes the same options as its third argument. `useMatch` is
|
|
209
|
+
built on `usePathname`, so it re-renders on the pathname and never on the
|
|
210
|
+
search, and it needs no table in the browser — which is what makes it the one
|
|
211
|
+
of these that also works under the framework, where `useRoute` has no match
|
|
212
|
+
to read.
|
|
213
|
+
|
|
214
|
+
`usePathname` answers in the table's terms: a `base` the application is
|
|
215
|
+
served under is taken off (see _Served under a base_).
|
|
192
216
|
|
|
193
217
|
**`usePathname` changes when the URL changes, not when the new page appears.**
|
|
194
218
|
Interception commits the URL first and the tree arrives when it has loaded, so
|
|
195
219
|
on a slow navigation a link marks itself active while the previous page is
|
|
196
|
-
still on screen — the same order the browser's own address bar follows.
|
|
197
|
-
|
|
198
|
-
|
|
220
|
+
still on screen — the same order the browser's own address bar follows. If the
|
|
221
|
+
wait needs showing, `usePendingPathname()` is where a page change in progress
|
|
222
|
+
is going — `null` when none is — set as the navigation starts and cleared once
|
|
223
|
+
the new page is on screen or the navigation is given up; a state change sets
|
|
224
|
+
nothing, unless the host loads the page again for it (a framework page that
|
|
225
|
+
reads the search), which is a load in progress like any other. The caller that
|
|
226
|
+
started it can instead await `navigateTo`'s `finished` — it resolves when the
|
|
227
|
+
tree is on screen (below).
|
|
199
228
|
|
|
200
229
|
`usePathname` reads the platform rather than the table, which is why it is the
|
|
201
230
|
one that also works under the framework, where the browser holds no table at
|
|
@@ -207,26 +236,94 @@ the boundary between the two packages.
|
|
|
207
236
|
|
|
208
237
|
`href` refuses a wildcard: `/*` is something to match, never something to link
|
|
209
238
|
to. Param values are URL-encoded on the way in and decoded on the way out.
|
|
239
|
+
What `href` returns is the URL a link points at — Vite's `base` in front of it
|
|
240
|
+
(below) — so it is typed `string`, not as a path in the table.
|
|
241
|
+
|
|
242
|
+
**A segment the whole application shares is bound once.** A locale, a tenant —
|
|
243
|
+
a param every link would otherwise have to repeat — is supplied by a function
|
|
244
|
+
instead, through `bindParams`:
|
|
245
|
+
|
|
246
|
+
```ts
|
|
247
|
+
// links.ts
|
|
248
|
+
import { bindParams } from '@k8ordo/router';
|
|
249
|
+
|
|
250
|
+
import { locales } from './i18n';
|
|
251
|
+
|
|
252
|
+
export const { href, navigateTo } = bindParams(() => ({
|
|
253
|
+
locale: locales.getLocale(),
|
|
254
|
+
}));
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
```tsx
|
|
258
|
+
href('/:locale/products/:id', { id }); // locale from the source, id as before
|
|
259
|
+
navigateTo('/:locale', { locale: 'en' }, { history: 'replace' }); // or overridden
|
|
260
|
+
navigateTo('/:locale', undefined, { history: 'replace' }); // options stay third
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
Patterns keep their full spelling, so the table's types apply unchanged; the
|
|
264
|
+
source is read at each call, so a value that differs per request or per URL
|
|
265
|
+
is read where it is current. Options still come after the params whenever
|
|
266
|
+
the pattern names one, even when the source supplies them all — params and
|
|
267
|
+
options are both plain objects, and the pattern alone decides which is which —
|
|
268
|
+
so the params slot is passed as `undefined`, as in the last line above. Which
|
|
269
|
+
package supplies the value is the application's business — the router knows a
|
|
270
|
+
param name, nothing more.
|
|
210
271
|
`normalizePathname` is the router's own reading of a pathname — a trailing
|
|
211
272
|
slash dropped, root excepted — for code that compares pathnames the way the
|
|
212
273
|
table does.
|
|
213
274
|
|
|
214
|
-
`navigateTo` returns the platform's own `{ committed, finished }
|
|
215
|
-
|
|
275
|
+
`navigateTo` returns the platform's own `{ committed, finished }`. `finished`
|
|
276
|
+
resolves once the new page is on screen, so an async action can await it and
|
|
277
|
+
let `isPending` cover the wait:
|
|
216
278
|
|
|
217
279
|
```tsx
|
|
218
280
|
const [isPending, startTransition] = useTransition();
|
|
219
281
|
|
|
220
282
|
startTransition(async () => {
|
|
221
|
-
|
|
283
|
+
try {
|
|
284
|
+
await navigateTo('/products/:id', { id }).finished;
|
|
285
|
+
} catch (error) {
|
|
286
|
+
// overtaken by another navigation
|
|
287
|
+
if (!(error instanceof DOMException && error.name === 'AbortError')) {
|
|
288
|
+
throw error;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
222
291
|
});
|
|
223
292
|
```
|
|
224
293
|
|
|
294
|
+
The page change does not join the action, so awaiting `finished` inside one —
|
|
295
|
+
`useTransition`'s, `@k8ordo/ui`'s `Button` `onAction`, a `<form action>` —
|
|
296
|
+
settles as soon as the page is on screen, and `isPending` covers exactly that
|
|
297
|
+
wait. An event handler can await it the same way. A page change started while
|
|
298
|
+
some unrelated action is still pending reaches the screen without waiting for
|
|
299
|
+
that action either.
|
|
300
|
+
|
|
225
301
|
Its default is `push`, the opposite of `@k8ordo/state`'s `update()`, and for
|
|
226
302
|
the same reason: going to a page is what the back button should undo, while
|
|
227
303
|
refining what is on the page is not. **Changing pages goes through
|
|
228
304
|
`navigateTo`; changing state goes through `update`.**
|
|
229
305
|
|
|
306
|
+
## Served under a base
|
|
307
|
+
|
|
308
|
+
The table is written from the application's root, and it stays that way when
|
|
309
|
+
the application is served below one — Vite's
|
|
310
|
+
[`base`](https://vite.dev/config/shared-options#base), `base: '/docs/'`. The
|
|
311
|
+
router reads it from `import.meta.env.BASE_URL` and does the rest:
|
|
312
|
+
|
|
313
|
+
- `href` and `navigateTo` put it in front of every link: `href('/products')`
|
|
314
|
+
is `/docs/products`, and `href('/')` is `/docs/`.
|
|
315
|
+
- `usePathname` takes it off, so what it returns compares with the patterns:
|
|
316
|
+
`/products` at `/docs/products`. `useMatch` follows, and `<Router>` matches
|
|
317
|
+
the table against the pathname below the base.
|
|
318
|
+
- A URL outside the base is not the application's, and `<Router>` leaves it
|
|
319
|
+
to the browser whatever the table says.
|
|
320
|
+
|
|
321
|
+
`withBase(pathname)` and `withoutBase(pathname)` are those two steps for code
|
|
322
|
+
of your own — `withoutBase` answers `null` for a URL outside the base. Each
|
|
323
|
+
takes the base as a second argument for code Vite does not process, which
|
|
324
|
+
has no `import.meta.env` to read. A relative base (`./`) names no path, so it
|
|
325
|
+
adds and removes nothing.
|
|
326
|
+
|
|
230
327
|
## Checking patterns against the real table
|
|
231
328
|
|
|
232
329
|
Params are inferred from the pattern literal on their own, so a missing or
|
|
@@ -234,8 +331,8 @@ misspelled one fails to compile with no setup at all. To have the **pattern**
|
|
|
234
331
|
checked against the app's actual table as well, augment `Register` once:
|
|
235
332
|
|
|
236
333
|
```ts
|
|
237
|
-
//
|
|
238
|
-
import type { routes } from '
|
|
334
|
+
// src/k8ordo-router.d.ts
|
|
335
|
+
import type { routes } from './routes';
|
|
239
336
|
|
|
240
337
|
declare module '@k8ordo/router' {
|
|
241
338
|
interface Register {
|
|
@@ -254,7 +351,7 @@ Without the augmentation the constraint is any `/`-prefixed string. Augment
|
|
|
254
351
|
only in an application — a library doing it would impose its table on every
|
|
255
352
|
consumer.
|
|
256
353
|
|
|
257
|
-
Under `@k8ordo/
|
|
354
|
+
Under `@k8ordo/framework` this file is generated into
|
|
258
355
|
`.k8ordo/register.gen.ts` from `routes/` — for `@k8ordo/state` too, when the
|
|
259
356
|
application depends on it — so hand-writing it there is writing a second
|
|
260
357
|
answer to a question already answered. Hand-write it in a client application
|
|
@@ -265,10 +362,20 @@ type the route file's `paramsSchema` produces. With it, `href` and
|
|
|
265
362
|
`navigateTo` take a param as the page receives it — `{ id: 42 }` for a
|
|
266
363
|
schema that said number — and spell it the one way the schema reads back. A
|
|
267
364
|
value with no URL spelling (an object) is refused. Before `Register` is
|
|
268
|
-
augmented — or
|
|
269
|
-
one spelling (a string, a number, a boolean), so a
|
|
270
|
-
compiles before the generated file exists.
|
|
271
|
-
|
|
365
|
+
augmented — or for a pattern no schema along its stack covers — a link takes
|
|
366
|
+
any value with one spelling (a string, a number, a bigint, a boolean), so a
|
|
367
|
+
link written for a schema compiles before the generated file exists. Once a
|
|
368
|
+
schema covers a pattern, a param the schemas leave alone is a string, as the
|
|
369
|
+
page receives it. `useParams` is unaffected: what a hand-written table matches
|
|
370
|
+
is always a string.
|
|
371
|
+
|
|
372
|
+
The shapes these checks use are exported for code of your own:
|
|
373
|
+
`RegisteredPattern` (every leaf pattern in the table — each page and each
|
|
374
|
+
`/*`, never a prefix with no page of its own), `RegisteredNavigablePattern`
|
|
375
|
+
(the ones a link can point at — no wildcards), `RegisteredParams<P>` (what a
|
|
376
|
+
link to `P` takes) and `RegisteredPageParams<P>` (what the page at `P`
|
|
377
|
+
receives). Before the augmentation the first two are any `/`-prefixed
|
|
378
|
+
string.
|
|
272
379
|
|
|
273
380
|
## Typed paths for @k8ordo/state
|
|
274
381
|
|
|
@@ -284,27 +391,50 @@ declare module '@k8ordo/state' {
|
|
|
284
391
|
|
|
285
392
|
With that, `listState.href('/products', { q })` is checked against the same
|
|
286
393
|
table this router matches against, and the two packages agree on what a path
|
|
287
|
-
is. `
|
|
288
|
-
typed-path consumer
|
|
394
|
+
is. `NavigablePath<typeof routes, Path>` is that check, for any other
|
|
395
|
+
typed-path consumer: `Path` itself when one of the table's linkable patterns
|
|
396
|
+
matches it segment by segment — a literal segment spelled as the pattern
|
|
397
|
+
spells it, a `:param` taking any one non-empty segment, a `${string}` from a
|
|
398
|
+
template literal included — and `never` when none does. A trailing slash is
|
|
399
|
+
refused: matching treats `/products/` as `/products`, but a path built for a
|
|
400
|
+
link is spelled the one canonical way.
|
|
401
|
+
|
|
402
|
+
The path is checked against the patterns rather than collected into a union
|
|
403
|
+
of every path the table has: a `/:locale` page would put `/${string}` in such
|
|
404
|
+
a union, and `/${string}` takes every path there is.
|
|
289
405
|
|
|
290
406
|
## What navigation guarantees
|
|
291
407
|
|
|
292
408
|
**`finished` means the page is on screen.** The intercept handler resolves in
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
render, not the URL write.
|
|
409
|
+
a layout effect, once React has committed the new tree and before the browser
|
|
410
|
+
paints it, so anything awaiting the platform's promise for a page change is
|
|
411
|
+
awaiting the render, not the URL write. The render it waits for is the first
|
|
412
|
+
commit of the new tree: a lazy page that suspends into a `<Suspense>` the
|
|
413
|
+
navigation mounts anew commits its fallback first, and `finished` resolves
|
|
414
|
+
then, before the chunk is in.
|
|
296
415
|
|
|
297
416
|
**A state change is not a page change.** When only the search or the entry
|
|
298
417
|
state moved, the pathname is the one whose tree is on screen: the route tree
|
|
299
418
|
is left alone, nothing remounts, and scroll and focus are not disturbed. This
|
|
300
|
-
is why a search update never scrolls the page back to the top.
|
|
301
|
-
is
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
419
|
+
is why a search update never scrolls the page back to the top. Such a
|
|
420
|
+
navigation is intercepted with no handler, so its `finished` —
|
|
421
|
+
`@k8ordo/state`'s `update().finished` included — settles as soon as the
|
|
422
|
+
navigation commits, with no render to wait for. The comparison is against the
|
|
423
|
+
page showing, not against the address bar — interception commits the URL
|
|
424
|
+
first, so a state update issued while another page is still loading is a page
|
|
425
|
+
change: it lets that page finish arriving, and its `finished` waits for that
|
|
426
|
+
render.
|
|
427
|
+
|
|
428
|
+
**Route changes render in the background.** The new tree renders at the
|
|
429
|
+
priority `useDeferredValue` gives it, so the old page stays on screen and
|
|
430
|
+
interactive while the new one prepares, and a `<Suspense>` that is already
|
|
431
|
+
showing keeps its content instead of falling back. It is deliberately not a
|
|
432
|
+
transition: while any async action is pending React holds every transition
|
|
433
|
+
until that action ends, which would stall a page change behind an unrelated
|
|
434
|
+
action and deadlock an action that awaits `finished`. The commit is still
|
|
435
|
+
tagged with `addTransitionType` — `navigation`, and one of `navigation-push`,
|
|
436
|
+
`navigation-replace`, `navigation-traverse` — which is what a
|
|
437
|
+
`<ViewTransition>` reads to animate a page change and nothing else (below).
|
|
308
438
|
|
|
309
439
|
**A new page starts at the top.** Once the new tree is on screen, the
|
|
310
440
|
viewport goes where a document load would have put it: the top, or the
|
|
@@ -321,12 +451,70 @@ is cancelled outright. A `React.lazy` chunk cannot be — a dynamic import takes
|
|
|
321
451
|
no signal — so in a client application it finishes in the background and is
|
|
322
452
|
kept for the next visit, while the page it belonged to is never shown.
|
|
323
453
|
|
|
454
|
+
## Animating page changes
|
|
455
|
+
|
|
456
|
+
A route change renders in the background, and React's `<ViewTransition>`
|
|
457
|
+
animates what such a render changes, as it does a transition. Put one around
|
|
458
|
+
the hole the pages render into and key it on the router's transition types,
|
|
459
|
+
so it animates page changes and stays out of every transition — a `Button`'s
|
|
460
|
+
pending action is one, and must not cross-fade the page:
|
|
461
|
+
|
|
462
|
+
```tsx
|
|
463
|
+
import { Outlet } from '@k8ordo/router';
|
|
464
|
+
import { ViewTransition } from 'react';
|
|
465
|
+
|
|
466
|
+
export const RootLayout = () => (
|
|
467
|
+
<>
|
|
468
|
+
<nav>…</nav>
|
|
469
|
+
<ViewTransition
|
|
470
|
+
default="none"
|
|
471
|
+
update={{ navigation: 'auto', default: 'none' }}
|
|
472
|
+
>
|
|
473
|
+
<Outlet />
|
|
474
|
+
</ViewTransition>
|
|
475
|
+
</>
|
|
476
|
+
);
|
|
477
|
+
```
|
|
478
|
+
|
|
479
|
+
`update`, because the boundary stays and its content changes; `auto` is the
|
|
480
|
+
browser's own cross-fade. The router tags every page change `navigation`,
|
|
481
|
+
plus the kind the platform reported — `navigation-push`,
|
|
482
|
+
`navigation-replace` or `navigation-traverse` — so a back button can slide
|
|
483
|
+
the other way from a link:
|
|
484
|
+
|
|
485
|
+
```tsx
|
|
486
|
+
<ViewTransition
|
|
487
|
+
default="none"
|
|
488
|
+
update={{
|
|
489
|
+
'navigation-push': 'slide-forward',
|
|
490
|
+
'navigation-replace': 'slide-forward',
|
|
491
|
+
'navigation-traverse': 'slide-back',
|
|
492
|
+
default: 'none',
|
|
493
|
+
}}
|
|
494
|
+
>
|
|
495
|
+
<Outlet />
|
|
496
|
+
</ViewTransition>
|
|
497
|
+
```
|
|
498
|
+
|
|
499
|
+
with each class styled through `::view-transition-old(.slide-back)` and
|
|
500
|
+
`::view-transition-new(.slide-back)`. A state change — `@k8ordo/state`'s
|
|
501
|
+
`update()` — never changes the tree, so it never animates. Under the
|
|
502
|
+
framework the same `<ViewTransition>` wraps a layout's `children`, and a
|
|
503
|
+
Server Component layout can render it directly.
|
|
504
|
+
|
|
505
|
+
`@k8ordo/ui`'s stylesheet turns view-transition animations off under
|
|
506
|
+
`prefers-reduced-motion`; an application without it adds that rule itself.
|
|
507
|
+
|
|
324
508
|
## Testing
|
|
325
509
|
|
|
326
510
|
Nothing here is mocked, so a test needs a browser environment (this package's
|
|
327
|
-
own suite uses Vitest's
|
|
328
|
-
that navigates must intercept, or `navigation.navigate()`
|
|
329
|
-
load that takes the test runner with it.
|
|
511
|
+
own suite uses Vitest's browser mode in Chromium, Firefox and WebKit). The one
|
|
512
|
+
thing to know: a test that navigates must intercept, or `navigation.navigate()`
|
|
513
|
+
is a cross-document load that takes the test runner with it. And Vitest's
|
|
514
|
+
browser mode runs a test inside an iframe, where Firefox and WebKit do not
|
|
515
|
+
restore the scroll position on back and forward — Firefox also runs a
|
|
516
|
+
traversal's handler twice there — so this package asserts going back and
|
|
517
|
+
forward in a top-level page it opens with Playwright.
|
|
330
518
|
|
|
331
519
|
```tsx
|
|
332
520
|
// a test that mounts <Router> is already intercepting; one that sets up a URL
|
|
@@ -341,32 +529,86 @@ and its precedence can all be asserted directly.
|
|
|
341
529
|
|
|
342
530
|
## Under the framework
|
|
343
531
|
|
|
344
|
-
`@k8ordo/
|
|
532
|
+
`@k8ordo/framework` renders pages on the server, so the
|
|
345
533
|
browser receives a tree rather than building one from a table — there is no
|
|
346
534
|
route table in the client bundle at all, and layouts nest through `children`
|
|
347
535
|
instead of `<Outlet />`. What stays is navigation: both build on
|
|
348
536
|
`useInterceptedNavigation`, the primitive `<Router>` itself uses.
|
|
349
537
|
|
|
350
538
|
```tsx
|
|
539
|
+
const [latest, setLatest] = useState(initial);
|
|
351
540
|
const { generation } = useInterceptedNavigation<Value>({
|
|
352
541
|
claim: (url) => boolean, // synchronous: the only moment interception is possible
|
|
353
542
|
load: (url, signal) => Value | Promise<Value>,
|
|
354
|
-
apply:
|
|
543
|
+
apply: setLatest, // an ordinary update, never inside a transition
|
|
355
544
|
});
|
|
545
|
+
const shown = useDeferredValue(latest); // render this, not `latest`
|
|
356
546
|
```
|
|
357
547
|
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
548
|
+
A fourth, optional member, `refresh: (url) => boolean`, says whether a
|
|
549
|
+
navigation that keeps the pathname on screen still has to load — the page
|
|
550
|
+
showing reads what moved. Without it such a navigation is a state change and
|
|
551
|
+
loads nothing; with it, it loads and applies like a page change but as a
|
|
552
|
+
state change otherwise: no scroll, no focus reset, no transition types. The
|
|
553
|
+
framework's runtime answers it for a page that exports `search`.
|
|
554
|
+
|
|
555
|
+
A host renders what `apply` set through `useDeferredValue`, in the same
|
|
556
|
+
component that calls the hook. That is what renders the new page in the
|
|
557
|
+
background and keeps the old one on screen meanwhile, and what the hook
|
|
558
|
+
itself follows: `generation` and `finished` move in the same deferred commit.
|
|
559
|
+
|
|
560
|
+
`generation` changes exactly when a new tree is put on screen — not when the
|
|
561
|
+
URL moved — and a host provides it through `<NavigationGeneration value>` so
|
|
562
|
+
the table's `error` boundaries know when to let a failure go. `<Router>` does
|
|
361
563
|
this itself; the framework's runtime does too.
|
|
362
564
|
|
|
565
|
+
The table itself stays on the server, generated from `routes/`, and the
|
|
566
|
+
framework matches against it with `match(pathname, accept)`. Each match that
|
|
567
|
+
fits is handed to `accept` first, and one it declines — a param a
|
|
568
|
+
`paramsSchema` along its stack refused — is passed over as if the pattern had
|
|
569
|
+
not fit, so the walk goes on to the next pattern, the catch-all included.
|
|
570
|
+
|
|
571
|
+
A framework `route.ts` — an answer that is not a page, a feed or JSON —
|
|
572
|
+
exports a function per request method, and `RouteContext<'/feed.xml'>` is
|
|
573
|
+
what each receives: the `Request`, and `params` typed as a page's are.
|
|
574
|
+
|
|
575
|
+
A page under the framework says it is not there with `notFound()` — the
|
|
576
|
+
product its id names does not exist. It throws, so nothing after it runs, and
|
|
577
|
+
the framework answers with the nearest `not-found.tsx` under a 404. It lives
|
|
578
|
+
here, next to the table it answers for, and `@k8ordo/framework` re-exports it;
|
|
579
|
+
`isNotFound(value)` recognises what it throws, by a registry brand rather than
|
|
580
|
+
a class, since a page and the framework may hold two copies of this package.
|
|
581
|
+
Under a client `<Router>` there is no status to answer with, and `notFound()`
|
|
582
|
+
is an error like any other.
|
|
583
|
+
|
|
363
584
|
What carries across unchanged is everything that needs no table: `href`,
|
|
364
|
-
`navigateTo
|
|
585
|
+
`navigateTo` and `bindParams`, `usePathname`, `useMatch` and `matchPath`, and
|
|
586
|
+
`normalizePathname` — an application on the framework imports them from
|
|
587
|
+
`@k8ordo/framework`, which re-exports them by name, and never names this
|
|
588
|
+
package. `usePathname` needs one thing on the server,
|
|
365
589
|
where there is no Navigation API to read: the pathname the render is for,
|
|
366
590
|
supplied by `<PathnameProvider pathname>`. `<Router>` mounts one itself and
|
|
367
|
-
|
|
368
|
-
building its own seam out of `useInterceptedNavigation` has to.
|
|
591
|
+
the framework's runtime supplies it in either mode, so an application never writes it — only a host
|
|
592
|
+
building its own seam out of `useInterceptedNavigation` has to. Its
|
|
593
|
+
counterpart, `<BrowserPathname>`, says a render has no pathname to give:
|
|
594
|
+
below it, `usePathname` — and `useMatch`, which reads it — asks for the
|
|
595
|
+
browser with `use(browser())` in a server render, so the nearest
|
|
596
|
+
`<Suspense>` is left for the browser to fill. The framework's runtime wraps
|
|
597
|
+
a `fallback.tsx` in it, and an application never writes it either. What does not
|
|
369
598
|
carry across is `useRoute` and `useParams` —
|
|
370
599
|
both read the match from context, and under the framework there is no match in
|
|
371
600
|
the browser to read. A framework page receives its `params` as a prop from the
|
|
372
601
|
server instead, which is the only form Server Components can take them in.
|
|
602
|
+
|
|
603
|
+
Those props have a type here, by the pattern the directory puts the file
|
|
604
|
+
under: `PageProps<'/products/:id'>` is `{ params, pathname }` with `params`
|
|
605
|
+
typed by the schemas the framework ran (the generated `Register` carries
|
|
606
|
+
them), plus `search` for a page that exports `search` (typed by what it
|
|
607
|
+
reads), and `LayoutProps<'/products'>` adds `children` — with `params` left as
|
|
608
|
+
strings whatever the schemas say, since `not-found.tsx` renders under a layout
|
|
609
|
+
whether or not its schemas accepted. In server mode the generated `Register` also carries
|
|
610
|
+
the `request`, so both types gain `request` there and a page that reads it
|
|
611
|
+
fails to type-check under a build into files. A route file may equally declare
|
|
612
|
+
its props inline — the generated table checks them at the import either way —
|
|
613
|
+
and a layout whose pattern has no page of its own has to: `LayoutProps` takes
|
|
614
|
+
only a pattern the table has a page at.
|