@k8ordo/router 0.1.0 → 1.1.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/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 [match, setMatch] = useState(() => routes.match(location.pathname));
49
+ const [latest, setLatest] = useState(() => matchUrl(routes, location.pathname));
44
50
  const { generation } = useInterceptedNavigation({
45
- claim: (url) => routes.match(url.pathname) !== null,
46
- load: (url) => routes.match(url.pathname),
47
- apply: setMatch
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: location.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: match === null ? null : /* @__PURE__ */ jsx(RenderStack, {
60
- stack: match.stack,
61
- index: 0
62
- })
70
+ children: stack
63
71
  })
64
72
  })
65
73
  });
66
74
  }
67
- /** The winning pattern and its params, untyped — `useParams` narrows. */
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 — and the
84
- frame around it survives. Leaving the page that failed clears the failure.
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. Intercepting any of them would silently do nothing where the platform
139
- would have done the obvious thing — a POST body only the server can act on, an
140
- `F5` that stops reloading. A GET form carries no body, so the search-shaped
141
- submissions `@k8ordo/state` builds still come through. Mark a link to a file
142
- the host serves as an attachment with `download`, so the browser tells the
143
- router before the click rather than after the answer.
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 the layout above it so there is somewhere to fall back
147
- to while the chunk arrives.
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')`; ask both when a section includes its index. It is built on `usePathname`, so it
189
- re-renders on the pathname and never on the search, and it needs no table in
190
- the browser — which is what makes it the one of these that also works under
191
- the framework, where `useRoute` has no match to read.
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. Pair it
197
- with `useTransition` if the wait needs showing — `navigateTo`'s `finished`
198
- resolves when the tree is on screen (below).
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 }`, so it
215
- composes with React 19's async transitions:
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
- await navigateTo('/products/:id', { id }).finished;
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
- // types/k8ordo-router.d.ts
238
- import type { routes } from '../src/routes';
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/static` or `@k8ordo/server` this file is generated into
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 where no schema covers a param — a link takes any value with
269
- one spelling (a string, a number, a boolean), so a link written for a schema
270
- compiles before the generated file exists. `useParams` is unaffected: what a
271
- hand-written table matches is always a string.
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. `RouteOf<typeof routes>` is that pathname space as a union, for any other
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
- an effect after React commits the new tree, so anything awaiting the platform's
294
- promise — including `@k8ordo/state`'s `update().finished` — is awaiting the
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. The comparison
301
- is against the page showing, not against the address bar — interception
302
- commits the URL first, so a state update issued while another page is still
303
- loading is a page change and lets that page finish arriving.
304
-
305
- **Route changes run in a transition.** The new tree is applied inside
306
- `startTransition`, so React can keep the old page interactive while the new
307
- one prepares.
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 Chromium browser mode). The one thing to know: a test
328
- that navigates must intercept, or `navigation.navigate()` is a cross-document
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/static` and `@k8ordo/server` render pages on the server, so the
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: (value) => void, // called inside a transition
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
- `generation` changes exactly when a new tree is applied — not when the URL
359
- moved — and a host provides it through `<NavigationGeneration value>` so the
360
- table's `error` boundaries know when to let a failure go. `<Router>` does
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`, `usePathname` and `useMatch`. `usePathname` needs one thing on the server,
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
- both mode runtimes supply it, so an application never writes it — only a host
368
- building its own seam out of `useInterceptedNavigation` has to. What does not
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.