@weftui/core 0.28.0 → 0.30.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 +8 -8
- package/dist/{index-Bsh2WLtx.d.ts → index-CX9uEejU.d.ts} +70 -51
- package/dist/index.d.ts +68 -119
- package/dist/index.js +1 -1
- package/dist/types/index.d.ts +1 -1
- package/docs/explanation/boundaries-and-suspense.md +39 -20
- package/docs/explanation/combinator-api.md +14 -12
- package/docs/explanation/reactive-primitives.md +14 -14
- package/docs/explanation/rendering-model.md +20 -18
- package/docs/explanation/services-and-context.md +35 -21
- package/docs/how-to/add-routing.md +361 -60
- package/docs/how-to/author-components.md +42 -30
- package/docs/how-to/compose-behavior-and-markup.md +144 -0
- package/docs/how-to/handle-forms.md +6 -6
- package/docs/how-to/load-async-data.md +15 -13
- package/docs/how-to/load-data-with-rpc.md +34 -32
- package/docs/how-to/provide-services.md +20 -18
- package/docs/how-to/render-keyed-lists.md +14 -12
- package/docs/how-to/render-on-the-server.md +18 -14
- package/docs/how-to/show-navigation-progress.md +12 -10
- package/docs/how-to/split-routes-lazily.md +16 -14
- package/docs/how-to/style-reactively.md +13 -13
- package/docs/how-to/use-element-refs.md +10 -8
- package/docs/index.md +20 -18
- package/docs/reference/core.md +46 -42
- package/docs/reference/dom.md +274 -58
- package/docs/reference/router.md +69 -47
- package/docs/tutorial/01-your-first-app.md +7 -9
- package/docs/tutorial/02-reactivity.md +8 -6
- package/docs/tutorial/03-services-and-async.md +10 -6
- package/docs/tutorial/04-errors-and-server.md +14 -5
- package/package.json +2 -2
|
@@ -2,17 +2,17 @@
|
|
|
2
2
|
title: Routing
|
|
3
3
|
order: 4
|
|
4
4
|
section: how-to
|
|
5
|
-
description: "@weftui/router
|
|
5
|
+
description: "@weftui/router: universal nested routing, Router.route / Router.layout / Router.router, type-safe href, layouts, and programmatic navigation."
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# Routing
|
|
9
9
|
|
|
10
10
|
`@weftui/router` is a universal (server + client) nested router for Weft. It maps a URL to a rendered `Node` tree on both sides:
|
|
11
11
|
|
|
12
|
-
- **Server
|
|
13
|
-
- **Client
|
|
12
|
+
- **Server**: matches an incoming request path, renders to hydratable HTML.
|
|
13
|
+
- **Client**: matches reactively via the History API.
|
|
14
14
|
|
|
15
|
-
The package
|
|
15
|
+
The package exports a shared (universal) root, a `./client` entry, and a `./server` entry.
|
|
16
16
|
|
|
17
17
|
```bash
|
|
18
18
|
npm install @weftui/router
|
|
@@ -20,21 +20,34 @@ npm install @weftui/router
|
|
|
20
20
|
|
|
21
21
|
## The mental model
|
|
22
22
|
|
|
23
|
-
A route's **component is its handler
|
|
23
|
+
A route's **component is its handler**. A page is a component that renders, and its `component` slot is invoked at render time.
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
```typescript
|
|
26
|
+
const homeRoute = Router.route("", { component: Home });
|
|
27
|
+
const aboutRoute = Router.route("about", { component: About });
|
|
28
|
+
const userRoute = Router.route("users/:id", {
|
|
29
|
+
path: { id: Schema.NumberFromString },
|
|
30
|
+
component: ({ path }) => h.h1(`User ${path.id}`),
|
|
31
|
+
});
|
|
26
32
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
33
|
+
const App = Router.router(Router.layout({ component: Shell }, [homeRoute, aboutRoute, userRoute]), {
|
|
34
|
+
notFound: () => h.h1("404"),
|
|
35
|
+
});
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
You author a **nested route tree** with namespaced combinators and seal it once:
|
|
39
|
+
|
|
40
|
+
| Combinator | Builds |
|
|
41
|
+
| ----------------------------------------------------- | ---------------------------------------------------------------- |
|
|
42
|
+
| `Router.route(segment, { path?, query?, component })` | A leaf page. |
|
|
43
|
+
| `Router.layout({ component }, children)` | A layout that wraps an outlet (purely UI nesting; owns no path). |
|
|
44
|
+
| `Router.router(root, { notFound })` | Seals the tree into a `RouterDef`. |
|
|
32
45
|
|
|
33
46
|
The tree is the source of truth. The same sealed `RouterDef` drives both server and client.
|
|
34
47
|
|
|
35
48
|
## Authoring routes
|
|
36
49
|
|
|
37
|
-
Every
|
|
50
|
+
Every **`ComponentSlot`** produces a `Node` when called. Use [`Component.make` / `Component.gen`](https://weftui.dev/docs/how-to/author-components) (or a plain `() => Node` thunk). The router invokes it at render time, which lets `href(…)` resolve after the tree is compiled.
|
|
38
51
|
|
|
39
52
|
```typescript
|
|
40
53
|
import { Component, h } from "@weftui/core";
|
|
@@ -54,18 +67,21 @@ const User = Router.route("users/:id", {
|
|
|
54
67
|
});
|
|
55
68
|
```
|
|
56
69
|
|
|
57
|
-
- **`segment`** is relative to the parent and may contain `:name` path-param placeholders (e.g. `"users/:id"`). A leading/trailing `/` is tolerated.
|
|
58
|
-
|
|
70
|
+
- **`segment`** is relative to the parent and may contain `:name` path-param placeholders (e.g. `"users/:id"`). A leading/trailing `/` is tolerated.
|
|
71
|
+
|
|
72
|
+
Each leaf carries its full relative path (e.g. `"users/:id/settings"`).
|
|
73
|
+
|
|
74
|
+
**`path` / `query`** are `Schema.Struct.Fields` (a record of `name → Schema`), declared **only on routes**. The compiler covers every `:name` placeholder in `pathSchema`, defaulting to `Schema.String` when a placeholder has no declared field. Query fields are optional by default.
|
|
59
75
|
|
|
60
|
-
> Authoring components with `Component.make` / `Component.gen` keeps every slot fully typed:
|
|
76
|
+
> Authoring components with `Component.make` / `Component.gen` keeps every slot fully typed: Each component's `E`/`R` channels aggregate up through `Router.layout` / `Router.router` into the sealed `RouterDef`.
|
|
61
77
|
|
|
62
78
|
## Reading the match: handler-arg props vs. injection
|
|
63
79
|
|
|
64
|
-
A leaf page reads the current match's decoded `path` / `query` in
|
|
80
|
+
A leaf page reads the current match's decoded `path` / `query` in either of two forms.
|
|
65
81
|
|
|
66
82
|
### Handler-arg props (leaf pages)
|
|
67
83
|
|
|
68
|
-
The router passes the decoded `{ path, query }` straight into a leaf `component` as props
|
|
84
|
+
The router passes the decoded `{ path, query }` straight into a leaf `component` as props. The props are typed `RouteHandlerProps<Path, Query>`, inferred from the route's `path` / `query` fields. No `Router` access, no validation step. Just read the props:
|
|
69
85
|
|
|
70
86
|
```typescript
|
|
71
87
|
const idParam = { id: Schema.NumberFromString };
|
|
@@ -80,7 +96,7 @@ Router.route("users/:id/posts", {
|
|
|
80
96
|
});
|
|
81
97
|
```
|
|
82
98
|
|
|
83
|
-
This is the most direct form for a leaf. A plain zero-arg thunk works too
|
|
99
|
+
This is the most direct form for a leaf. A plain zero-arg thunk works too; it just ignores the props.
|
|
84
100
|
|
|
85
101
|
### Dependency injection (layouts and deep nodes)
|
|
86
102
|
|
|
@@ -95,13 +111,35 @@ const UserShell = Component.gen(function* () {
|
|
|
95
111
|
});
|
|
96
112
|
```
|
|
97
113
|
|
|
98
|
-
`Router.params(fields)` / `Router.query(fields)` read the live match and pick the requested `fields` keys (already decoded by the matcher, so no re-validation). They return the typed values,
|
|
114
|
+
`Router.params(fields)` / `Router.query(fields)` read the live match and pick the requested `fields` keys (already decoded by the matcher, so no re-validation). They return the typed values. When no route matches, they fail with a tagged [`RouterParamsError`](#errors) carrying `source: "path" | "query"` and the requested `keys`.
|
|
115
|
+
|
|
116
|
+
That error bubbles into the app node's aggregate `E`, so a user may recover it with `Boundary.catchTag(…)`.
|
|
117
|
+
|
|
118
|
+
### Reactive accessors: `paramsStream` / `queryStream`
|
|
119
|
+
|
|
120
|
+
`Router.paramsStream(fields)` / `Router.queryStream(fields)` are the reactive counterparts of `params` / `query`. Each resolves a `Subscribable` derived from `Subscribable.changes(currentMatch)`, so a component can update **in place** even when the same leaf stays mounted, the case a query-only navigation (`setQuery` / `patchQuery`, see [Programmatic navigation](#programmatic-navigation)) produces and a snapshot `Router.query` would miss:
|
|
121
|
+
|
|
122
|
+
```typescript
|
|
123
|
+
import { Component, h, Subscribable } from "@weftui/core";
|
|
124
|
+
import { Router } from "@weftui/router";
|
|
125
|
+
import { Schema, Stream } from "effect";
|
|
126
|
+
|
|
127
|
+
const sortQuery = { sort: Schema.optional(Schema.String) };
|
|
128
|
+
|
|
129
|
+
const ProductsPage = Component.gen(function* () {
|
|
130
|
+
const query = yield* Router.queryStream(sortQuery);
|
|
131
|
+
return yield* h.section([
|
|
132
|
+
h.h2("Products"),
|
|
133
|
+
h.p(["sort: ", Stream.map(Subscribable.changes(query), (q) => q.sort ?? "none")]),
|
|
134
|
+
]);
|
|
135
|
+
});
|
|
136
|
+
```
|
|
99
137
|
|
|
100
|
-
|
|
138
|
+
A `NotFound` match yields the empty subset rather than failing, so the stream stays live across navigations.
|
|
101
139
|
|
|
102
140
|
## Layouts and the outlet
|
|
103
141
|
|
|
104
|
-
A **layout** wraps the next level down
|
|
142
|
+
A **layout** wraps the next level down: the **outlet**, which is also delivered by injection. A layout reads it with `yield* Router.Outlet` and places it like any `h`-style child:
|
|
105
143
|
|
|
106
144
|
```typescript
|
|
107
145
|
const UserShell = Component.gen(function* () {
|
|
@@ -113,14 +151,37 @@ const UserShell = Component.gen(function* () {
|
|
|
113
151
|
Router.layout({ component: UserShell }, [settingsRoute, postsRoute]);
|
|
114
152
|
```
|
|
115
153
|
|
|
116
|
-
`Router.Outlet` is typed **opaque** (`Node<never, never>`), so splicing it adds nothing to the layout's own channels
|
|
154
|
+
`Router.Outlet` is typed **opaque** (`Node<never, never>`), so splicing it adds nothing to the layout's own channels. The subtree's real `E`/`R` are aggregated structurally by `Router.layout`. The router discharges the `Outlet` requirement at render time, so it never appears in a layout's (or the sealed app's) aggregate requirement channel.
|
|
117
155
|
|
|
118
|
-
A layout owns **no `segment` or `path
|
|
156
|
+
A layout owns **no `segment` or `path`**; all path structure lives on routes. A layout that needs a param reads it via `Router.params`.
|
|
119
157
|
|
|
120
158
|
### Layout persistence
|
|
121
159
|
|
|
122
160
|
Each nesting level renders as a reactive stream child keyed by `(pattern + the param values that level depends on)` and `dedupe`d. An unchanged ancestor layout therefore **stays mounted** across a navigation that only changes a deeper level: its DOM identity and any local state (a `SubscriptionRef`, a scroll position) survive while only the inner outlet swaps.
|
|
123
161
|
|
|
162
|
+
```typescript
|
|
163
|
+
import { Component, h } from "@weftui/core";
|
|
164
|
+
import { Router } from "@weftui/router";
|
|
165
|
+
import { Clock } from "effect";
|
|
166
|
+
|
|
167
|
+
// UserShell's body runs once per distinct `:id`. Navigating between
|
|
168
|
+
// /users/1/settings and /users/1/posts doesn't change `:id`, so this
|
|
169
|
+
// instance (and `sessionStart`) is never recreated: only `outlet` swaps.
|
|
170
|
+
const UserShell = Component.gen(function* () {
|
|
171
|
+
const { id } = yield* Router.params(idParam);
|
|
172
|
+
const outlet = yield* Router.Outlet;
|
|
173
|
+
const sessionStart = yield* Clock.currentTimeMillis;
|
|
174
|
+
|
|
175
|
+
return yield* h.div({ class: "user" }, [
|
|
176
|
+
h.p(`shell mounted at ${sessionStart}`),
|
|
177
|
+
h.h1(`User ${id}`),
|
|
178
|
+
outlet,
|
|
179
|
+
]);
|
|
180
|
+
});
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
Navigate from `/users/1/settings` to `/users/1/posts` and the mounted timestamp stays the same; navigate to `/users/2/settings` and it re-renders, since `:id` changed.
|
|
184
|
+
|
|
124
185
|
## Sealing the tree
|
|
125
186
|
|
|
126
187
|
`Router.router(root, { notFound })` compiles the tree eagerly (stamping leaf references so `href` works) and captures the app-level not-found page:
|
|
@@ -131,11 +192,11 @@ export const App = Router.router(
|
|
|
131
192
|
homeRoute,
|
|
132
193
|
Router.layout({ component: UserShell }, [settingsRoute, postsRoute]),
|
|
133
194
|
]),
|
|
134
|
-
{ notFound: () => h.section({ id: "page" }, [h.h2("404
|
|
195
|
+
{ notFound: () => h.section({ id: "page" }, [h.h2("404: page not found")]) },
|
|
135
196
|
);
|
|
136
197
|
```
|
|
137
198
|
|
|
138
|
-
`App` is a `RouterDef` whose phantom `E`/`R` carry the aggregate channels of the whole tree (plus the not-found page)
|
|
199
|
+
`App` is a `RouterDef` whose phantom `E`/`R` carry the aggregate channels of the whole tree (plus the not-found page). Keep `app.ts` side-effect-free (no `mount`/`hydrate`) so both entries can import it.
|
|
139
200
|
|
|
140
201
|
## Type-safe links with `href`
|
|
141
202
|
|
|
@@ -152,11 +213,13 @@ const Home = Component.make(() =>
|
|
|
152
213
|
);
|
|
153
214
|
```
|
|
154
215
|
|
|
155
|
-
Path params encode into the pattern (`/users/:id` + `{ id: 42 }` ⇒ `/users/42`)
|
|
216
|
+
Path params encode into the pattern (`/users/:id` + `{ id: 42 }` ⇒ `/users/42`). Query values encode through the query schema into a key-sorted search string. `href` round-trips with the matcher.
|
|
217
|
+
|
|
218
|
+
The leaf must belong to a tree sealed with `Router.router()`. This is why deferring the `component` body via `Component.make` matters: `href` runs at render time, after compile.
|
|
156
219
|
|
|
157
220
|
## Not-found
|
|
158
221
|
|
|
159
|
-
`notFound(path?)` short-circuits the current render with a `RouterNotFound` failure. Callable from any page or layout
|
|
222
|
+
`notFound(path?)` short-circuits the current render with a `RouterNotFound` failure. Callable from any page or layout. The nearest enclosing not-found boundary renders the configured `notFound` page in its place, and the server responds with HTTP 404:
|
|
160
223
|
|
|
161
224
|
```typescript
|
|
162
225
|
import { notFound, Router } from "@weftui/router";
|
|
@@ -171,31 +234,128 @@ Router.route("users/:id", {
|
|
|
171
234
|
});
|
|
172
235
|
```
|
|
173
236
|
|
|
174
|
-
`RouterNotFound` is exported, so a `Boundary.catchTag(
|
|
237
|
+
`RouterNotFound` is exported, so a `Boundary.catchTag(…)` placed inside a subtree overrides the app-level fallback for that subtree. The router's internal boundary is outermost, so a nearer user boundary wins.
|
|
175
238
|
|
|
176
|
-
> **`Schema.NumberFromString` gotcha.** Decoding no longer fails on a non-numeric segment
|
|
239
|
+
> **`Schema.NumberFromString` gotcha.** Decoding no longer fails on a non-numeric segment: `/users/abc` decodes `id` to `NaN` instead of missing the route. A leaf that guards a numeric param must check `Number.isFinite(id)` itself (as above). Relying on the schema alone to 404 non-numeric input no longer works.
|
|
177
240
|
|
|
178
241
|
## Client setup
|
|
179
242
|
|
|
180
|
-
On the client, provide the `Router` via `RouterLive(def)` and render `RouterApp(def)`. `RouterLive` is a **scoped layer
|
|
243
|
+
On the client, provide the `Router` via `RouterLive(def)` and render `RouterApp(def)`. `RouterLive` is a **scoped layer**: it owns the `popstate` listener and the same-origin link-click interceptor, so it must outlive the mount.
|
|
244
|
+
|
|
245
|
+
Give it to `WeftApp.make`. The app runtime owns it for the app's lifetime, built lazily on first mount/hydrate and released only at `WeftApp.dispose`. Do not wrap `Effect.provide` around the mount/hydrate call; services come exclusively from the app layer. `RouterLive`'s only required argument is the sealed `App`; a second `options` argument adds an rpc group or a custom `baseUrl` when needed (see [`Boundary.rpc` interplay](#boundaryrpc-interplay)).
|
|
246
|
+
|
|
247
|
+
```typescript
|
|
248
|
+
const app = WeftApp.make(RouterLive(App));
|
|
249
|
+
void Effect.runPromise(WeftApp.mount(app, RouterApp(App), root));
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
### Client-only app
|
|
253
|
+
|
|
254
|
+
A complete, no-SSR app: three routes under one `Shell` layout, mounted directly into an empty `#root`. This is the whole file set, copy/paste runnable in a `vite` + `@weftui/router` project.
|
|
255
|
+
|
|
256
|
+
```html
|
|
257
|
+
<!-- index.html -->
|
|
258
|
+
<!doctype html>
|
|
259
|
+
<html lang="en">
|
|
260
|
+
<head>
|
|
261
|
+
<meta charset="UTF-8" />
|
|
262
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
263
|
+
<title>Weft routing demo</title>
|
|
264
|
+
</head>
|
|
265
|
+
<body>
|
|
266
|
+
<div id="root"></div>
|
|
267
|
+
<script type="module" src="/src/main.ts"></script>
|
|
268
|
+
</body>
|
|
269
|
+
</html>
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
```typescript
|
|
273
|
+
// src/app.ts
|
|
274
|
+
/**
|
|
275
|
+
* Client-only routing demo: a Shell layout with Home, About, and a dynamic
|
|
276
|
+
* User page, sealed into a single RouterDef. Side-effect-free (no mount call),
|
|
277
|
+
* so `main.ts` and any test can import `App` directly.
|
|
278
|
+
*/
|
|
279
|
+
import { Component, h } from "@weftui/core";
|
|
280
|
+
import { href, notFound, Router } from "@weftui/router";
|
|
281
|
+
import { Schema } from "effect";
|
|
282
|
+
|
|
283
|
+
const idParam = { id: Schema.NumberFromString };
|
|
284
|
+
|
|
285
|
+
const homeRoute = Router.route("", {
|
|
286
|
+
component: Component.make(() => h.section({ id: "page" }, [h.h2("Home")])),
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
const aboutRoute = Router.route("about", {
|
|
290
|
+
component: Component.make(() => h.section({ id: "page" }, [h.h2("About")])),
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
const userRoute = Router.route("users/:id", {
|
|
294
|
+
path: idParam,
|
|
295
|
+
component: ({ path }) => {
|
|
296
|
+
if (!Number.isFinite(path.id) || path.id < 0) return notFound();
|
|
297
|
+
return h.section({ id: "page" }, [h.h2(`User ${path.id}`)]);
|
|
298
|
+
},
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
const Shell = Component.gen(function* () {
|
|
302
|
+
const outlet = yield* Router.Outlet;
|
|
303
|
+
return yield* h.div({ id: "app" }, [
|
|
304
|
+
h.nav([
|
|
305
|
+
h.a({ href: href(homeRoute) }, "Home"),
|
|
306
|
+
" · ",
|
|
307
|
+
h.a({ href: href(aboutRoute) }, "About"),
|
|
308
|
+
" · ",
|
|
309
|
+
h.a({ href: href(userRoute, { path: { id: 1 } }) }, "User 1"),
|
|
310
|
+
]),
|
|
311
|
+
h.main([outlet]),
|
|
312
|
+
]);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
export const App = Router.router(
|
|
316
|
+
Router.layout({ component: Shell }, [homeRoute, aboutRoute, userRoute]),
|
|
317
|
+
{ notFound: () => h.section({ id: "page" }, [h.h2("404: page not found")]) },
|
|
318
|
+
);
|
|
319
|
+
```
|
|
181
320
|
|
|
182
321
|
```typescript
|
|
183
|
-
//
|
|
322
|
+
// src/main.ts
|
|
323
|
+
/**
|
|
324
|
+
* Browser entry: mounts the routing demo into `#root`. No server render to
|
|
325
|
+
* hydrate, so this uses `WeftApp.mount`, not `hydrate`.
|
|
326
|
+
*/
|
|
184
327
|
import { WeftApp } from "@weftui/dom/client";
|
|
185
328
|
import { RouterApp, RouterLive } from "@weftui/router/client";
|
|
186
329
|
import { Effect } from "effect";
|
|
187
330
|
import { App } from "./app";
|
|
188
331
|
|
|
189
|
-
const root = document.getElementById("root")
|
|
332
|
+
const root = document.getElementById("root");
|
|
333
|
+
if (root === null) {
|
|
334
|
+
throw new Error("#root not found");
|
|
335
|
+
}
|
|
336
|
+
|
|
190
337
|
const app = WeftApp.make(RouterLive(App));
|
|
191
|
-
void Effect.runPromise(WeftApp.
|
|
338
|
+
void Effect.runPromise(WeftApp.mount(app, RouterApp(App), root));
|
|
192
339
|
```
|
|
193
340
|
|
|
194
|
-
|
|
341
|
+
`WeftApp.mount(app, node, root)` clears `root` and renders `node` fresh, in contrast to `hydrate`, which adopts existing server-rendered DOM (see [Full SSR example](#full-ssr-example) below). Everything else, the layout, `href`, params, navigation, is identical between the two setups.
|
|
195
342
|
|
|
196
343
|
### Link interception
|
|
197
344
|
|
|
198
|
-
A plain `h.a({ href })` to a same-origin, route-matching URL performs SPA navigation when clicked
|
|
345
|
+
A plain `h.a({ href })` to a same-origin, route-matching URL performs SPA navigation when clicked: no full page load. The interceptor leaves the browser's native behaviour untouched for:
|
|
346
|
+
|
|
347
|
+
- modified clicks (ctrl/meta/shift/alt or non-left button)
|
|
348
|
+
- `target=_blank` and `download`
|
|
349
|
+
- external origins
|
|
350
|
+
- same-document (hash-only) navigations
|
|
351
|
+
- hrefs that don't resolve to a route
|
|
352
|
+
|
|
353
|
+
```typescript
|
|
354
|
+
h.a({ href: "/about" }, "About"); // intercepted: SPA navigation, no reload
|
|
355
|
+
h.a({ href: "/about", target: "_blank" }, "About"); // native: falls through
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
You don't wire anything up. `RouterLive` installs the delegated listener for the layer's lifetime and removes it on teardown.
|
|
199
359
|
|
|
200
360
|
## Programmatic navigation
|
|
201
361
|
|
|
@@ -230,27 +390,119 @@ yield * setQuery({ sort: "old" }); // replaces the query
|
|
|
230
390
|
yield * patchQuery({ sort: "old" }); // merges into the current query
|
|
231
391
|
```
|
|
232
392
|
|
|
233
|
-
- **`navigate(ref, args)`** builds the URL via [`href`](#type-safe-links-with-href)
|
|
234
|
-
- **`setQuery` / `patchQuery`** keep the path, so the active leaf is never remounted
|
|
393
|
+
- **`navigate(ref, args)`** builds the URL via [`href`](#type-safe-links-with-href), so it round-trips with the matcher. It pushes the History entry, or replaces it with `{ replace: true }`. `args` follows the same requiredness rules as `href`.
|
|
394
|
+
- **`setQuery` / `patchQuery`** keep the path, so the active leaf is never remounted. Pair them with `Router.queryStream` for in-place reactive updates. They are a no-op when no route is matched.
|
|
235
395
|
|
|
236
396
|
### Scroll position on navigation
|
|
237
397
|
|
|
238
|
-
A client navigation whose **path** changes resets the window scroll to the top at commit
|
|
398
|
+
A client navigation whose **path** changes resets the window scroll to the top at commit. This matches a full page load, which a raw History `pushState`/`replaceState` otherwise doesn't. It applies uniformly to `Router.navigate`, clicking a link the [interceptor](#link-interception) handles, and the `push` / `replace` helpers.
|
|
239
399
|
|
|
240
|
-
- **Query-only navigations preserve scroll.** `setQuery` / `patchQuery` (and any navigation that keeps the same path) don't reset
|
|
400
|
+
- **Query-only navigations preserve scroll.** `setQuery` / `patchQuery` (and any navigation that keeps the same path) don't reset; the leaf stays mounted, so there's nothing to scroll away from.
|
|
241
401
|
- **Back/forward is untouched.** The router never resets scroll on `popstate`; the browser's native `history.scrollRestoration: "auto"` restores the offset the entry had when the user left it.
|
|
242
|
-
- **Hash navigation (`#section`) is unaffected
|
|
402
|
+
- **Hash navigation (`#section`) is unaffected.** It's browser-native, and the link interceptor already lets same-document/hash-only clicks fall through.
|
|
243
403
|
|
|
244
404
|
There's no opt-out; the behavior is hardwired.
|
|
245
405
|
|
|
246
406
|
## Server setup
|
|
247
407
|
|
|
248
|
-
On the server, `RouterServer
|
|
408
|
+
On the server, `RouterServer`:
|
|
249
409
|
|
|
250
|
-
|
|
410
|
+
- matches a request URL and builds a fixed-match `Router`
|
|
411
|
+
- renders `RouterApp` to hydratable HTML inside a **document shell**
|
|
412
|
+
- reports a status (404 when no route matches or a page raises `RouterNotFound`)
|
|
413
|
+
|
|
414
|
+
The document shell is itself a `ComponentSlot` that splices the app via `yield* Router.Outlet`, exactly like a layout receives its outlet.
|
|
251
415
|
|
|
252
416
|
```typescript
|
|
253
|
-
|
|
417
|
+
const { html, status } = await Effect.runPromise(
|
|
418
|
+
RouterServer.render(App, { document: documentShell, url }),
|
|
419
|
+
);
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
### Full SSR example
|
|
423
|
+
|
|
424
|
+
The same three routes as the [client-only app](#client-only-app), rendered on the server as hydratable HTML and hydrated in the browser. This is the whole file set (drop it alongside a dev server that bridges `entry-server.ts`'s `handler` into Vite or any Web-platform server; see [`examples/router-ssr/server.ts`](https://github.com/stefvw93/weft/blob/main/examples/router-ssr/server.ts) for a working one).
|
|
425
|
+
|
|
426
|
+
```typescript
|
|
427
|
+
// src/app.ts
|
|
428
|
+
/**
|
|
429
|
+
* Shared, isomorphic router app: three pages under one persistent Shell
|
|
430
|
+
* layout. Side-effect-free: it never mounts or serves. `entry-server.ts`
|
|
431
|
+
* renders the matched route on the server; `entry-client.ts` hydrates over it.
|
|
432
|
+
*/
|
|
433
|
+
import { Component, h } from "@weftui/core";
|
|
434
|
+
import { href, notFound, Router } from "@weftui/router";
|
|
435
|
+
import { Schema } from "effect";
|
|
436
|
+
|
|
437
|
+
const idParam = { id: Schema.NumberFromString };
|
|
438
|
+
|
|
439
|
+
export const homeRoute = Router.route("", {
|
|
440
|
+
component: Component.make(() => h.section({ id: "page" }, [h.h2("Home")])),
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
export const aboutRoute = Router.route("about", {
|
|
444
|
+
component: Component.make(() => h.section({ id: "page" }, [h.h2("About")])),
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
export const userRoute = Router.route("users/:id", {
|
|
448
|
+
path: idParam,
|
|
449
|
+
component: ({ path }) => {
|
|
450
|
+
if (!Number.isFinite(path.id) || path.id < 0) return notFound();
|
|
451
|
+
return h.section({ id: "page" }, [h.h2(`User ${path.id}`)]);
|
|
452
|
+
},
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
const Shell = Component.gen(function* () {
|
|
456
|
+
const outlet = yield* Router.Outlet;
|
|
457
|
+
return yield* h.div({ id: "app" }, [
|
|
458
|
+
h.nav([
|
|
459
|
+
h.a({ href: href(homeRoute) }, "Home"),
|
|
460
|
+
" · ",
|
|
461
|
+
h.a({ href: href(aboutRoute) }, "About"),
|
|
462
|
+
]),
|
|
463
|
+
h.main([outlet]),
|
|
464
|
+
]);
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
export const App = Router.router(
|
|
468
|
+
Router.layout({ component: Shell }, [homeRoute, aboutRoute, userRoute]),
|
|
469
|
+
{ notFound: () => h.section({ id: "page" }, [h.h2("404: page not found")]) },
|
|
470
|
+
);
|
|
471
|
+
```
|
|
472
|
+
|
|
473
|
+
```typescript
|
|
474
|
+
// src/entry-client.ts
|
|
475
|
+
/**
|
|
476
|
+
* Client entry: hydrates the server-rendered markup in `#root`.
|
|
477
|
+
*
|
|
478
|
+
* `RouterApp(App)` is the universal router root; `RouterLive(App)` provides
|
|
479
|
+
* the History-API-backed `Router` (seeded from `window.location`, with the
|
|
480
|
+
* same-origin link click interceptor installed). `hydrate` adopts the server
|
|
481
|
+
* DOM in place and resumes the reactive outlet.
|
|
482
|
+
*/
|
|
483
|
+
import { WeftApp } from "@weftui/dom/client";
|
|
484
|
+
import { RouterApp, RouterLive } from "@weftui/router/client";
|
|
485
|
+
import { Effect } from "effect";
|
|
486
|
+
import { App } from "./app";
|
|
487
|
+
|
|
488
|
+
const root = document.getElementById("root");
|
|
489
|
+
if (root === null) {
|
|
490
|
+
throw new Error("#root not found");
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
const app = WeftApp.make(RouterLive(App));
|
|
494
|
+
void Effect.runPromise(WeftApp.hydrate(app, RouterApp(App), root));
|
|
495
|
+
```
|
|
496
|
+
|
|
497
|
+
```typescript
|
|
498
|
+
// src/entry-server.ts
|
|
499
|
+
/**
|
|
500
|
+
* Server entry: renders the matched route to a hydratable HTML document.
|
|
501
|
+
*
|
|
502
|
+
* `documentShell` splices the app via `yield* Router.Outlet` (injected per
|
|
503
|
+
* request by `RouterServer`). `render` drives it for a single `url`; `handler`
|
|
504
|
+
* is a Web `fetch`-style handler ready to bridge into Vite or any Web server.
|
|
505
|
+
*/
|
|
254
506
|
import { Component, h } from "@weftui/core";
|
|
255
507
|
import { Router } from "@weftui/router";
|
|
256
508
|
import { RouterServer } from "@weftui/router/server";
|
|
@@ -268,41 +520,90 @@ const documentShell = Component.gen(function* () {
|
|
|
268
520
|
]);
|
|
269
521
|
});
|
|
270
522
|
|
|
271
|
-
// { html, status }
|
|
272
|
-
export const render = (url: string) =>
|
|
523
|
+
// { html, status }: `<!DOCTYPE html>` is prepended for you.
|
|
524
|
+
export const render = (url: string): Promise<{ html: string; status: number }> =>
|
|
273
525
|
Effect.runPromise(RouterServer.render(App, { document: documentShell, url }));
|
|
274
526
|
|
|
275
|
-
//
|
|
527
|
+
// A Web fetch-style handler, ready to bridge into Vite or any Web server.
|
|
276
528
|
export const handler = RouterServer.toWebHandler(App, { document: documentShell });
|
|
277
529
|
```
|
|
278
530
|
|
|
279
|
-
`render` provides both `Router.Outlet` (the app, per request) and `Router` (so the shell may read params)
|
|
531
|
+
`render` provides both `Router.Outlet` (the app, per request) and `Router` (so the shell may read params). It renders through `renderToStringHydratable` so the client can `hydrate` in place. Neither `RouterLive` nor `RouterServer` needs an `rpc` option here: it's optional and only required once a page uses [`Boundary.rpc`](#boundaryrpc-interplay).
|
|
532
|
+
|
|
533
|
+
`handler` still needs a server to call it. [`examples/router-ssr/server.ts`](https://github.com/stefvw93/weft/blob/main/examples/router-ssr/server.ts) shows the shape: a Node HTTP server that runs Vite in middleware mode, converts each request to a Web `Request`, calls `handler`, and runs HTML responses through `vite.transformIndexHtml` for HMR (non-HTML responses, like a `Boundary.rpc` refetch, are forwarded untouched). See that file and its co-located [`vite.config.ts`](https://github.com/stefvw93/weft/blob/main/examples/router-ssr/vite.config.ts) for the full dev-server wiring; it's the same shape in production behind any Web-platform host.
|
|
280
534
|
|
|
281
535
|
### `effect/unstable/httpapi` is the spine
|
|
282
536
|
|
|
283
|
-
The tree is the authoring surface, but `effect/unstable/httpapi`'s `HttpApi` is the **single source of truth** for paths and schemas. Sealing the tree with `Router.router(...)` builds it once (`buildHttpApi`) and stamps it onto `def.httpApi
|
|
537
|
+
The tree is the authoring surface, but `effect/unstable/httpapi`'s `HttpApi` is the **single source of truth** for paths and schemas. Sealing the tree with `Router.router(...)` builds it once (`buildHttpApi`) and stamps it onto `def.httpApi`.
|
|
538
|
+
|
|
539
|
+
The result is a single `"pages"` group with one GET endpoint per leaf at its full path pattern, carrying `params: pathSchema`, `query: querySchema`, and a `RouterNotFound → 404` error. Both sides read that one definition, so they always agree:
|
|
540
|
+
|
|
541
|
+
- **Server**: `RouterServer` dispatches through `HttpApiBuilder` (platform owns request→leaf matching, path/query decode, and the 404 status).
|
|
542
|
+
- **Client**: `RouterLive` derives a real `HttpApiClient` from the same `def.httpApi` (exposed as `Router.httpApiClient`) for network work. SPA URL→leaf resolution stays **local**; there is no public client-side "match this URL against my `HttpApi`" utility in platform. It is fed from the same endpoint definitions, so it never drifts from the server.
|
|
543
|
+
|
|
544
|
+
```typescript
|
|
545
|
+
import { Option } from "effect";
|
|
546
|
+
|
|
547
|
+
App.httpApi; // HttpApi.Top: one "pages" group, a GET endpoint per leaf
|
|
284
548
|
|
|
285
|
-
|
|
286
|
-
|
|
549
|
+
const { httpApiClient } = yield * Router;
|
|
550
|
+
Option.isSome(httpApiClient); // true under RouterLive, false under RouterServer
|
|
551
|
+
```
|
|
287
552
|
|
|
288
553
|
## Errors
|
|
289
554
|
|
|
290
|
-
| Error | Raised by | Recover with
|
|
291
|
-
| ------------------- | --------------------------------------------------------------------- |
|
|
292
|
-
| `RouterNotFound` | `notFound()`, or no route matched | `Boundary.catchTag(
|
|
293
|
-
| `RouterParamsError` | `Router.params` / `Router.query` on a missing/invalid key or no match | `Boundary.catchTag(
|
|
555
|
+
| Error | Raised by | Recover with |
|
|
556
|
+
| ------------------- | --------------------------------------------------------------------- | --------------------------------------------------------- |
|
|
557
|
+
| `RouterNotFound` | `notFound()`, or no route matched | `Boundary.catchTag(…)` (or the app-level `notFound` page) |
|
|
558
|
+
| `RouterParamsError` | `Router.params` / `Router.query` on a missing/invalid key or no match | `Boundary.catchTag(…)` |
|
|
294
559
|
|
|
295
560
|
Both are modeled as `Schema.TaggedErrorClass`, so they encode/decode across the wire the same way `Boundary.rpc` replays typed failures.
|
|
296
561
|
|
|
562
|
+
Recover locally by wrapping just the subtree that can fail, rather than relying on the app-level `notFound` page for everything:
|
|
563
|
+
|
|
564
|
+
```typescript
|
|
565
|
+
import { Boundary, Component, h } from "@weftui/core";
|
|
566
|
+
import { Router } from "@weftui/router";
|
|
567
|
+
|
|
568
|
+
const UserShell = Component.gen(function* () {
|
|
569
|
+
const outlet = yield* Router.Outlet;
|
|
570
|
+
return yield* h.div({ class: "user" }, [
|
|
571
|
+
Boundary.catchTag(
|
|
572
|
+
{
|
|
573
|
+
tag: "RouterParamsError",
|
|
574
|
+
fallback: () => h.p({ class: "error" }, "Couldn't read this page's params."),
|
|
575
|
+
},
|
|
576
|
+
[outlet],
|
|
577
|
+
),
|
|
578
|
+
]);
|
|
579
|
+
});
|
|
580
|
+
```
|
|
581
|
+
|
|
582
|
+
The matched tag is removed from the boundary's output `E`; an unmatched error (e.g. `RouterNotFound`) re-raises to the nearest parent boundary, which is the router's own not-found boundary if nothing closer catches it.
|
|
583
|
+
|
|
297
584
|
## `Boundary.rpc` interplay
|
|
298
585
|
|
|
299
|
-
Initial SSR navigation works end to end: the server resolves the rpc and inlines its payload, and the client replays it during `hydrate`.
|
|
586
|
+
Initial SSR navigation works end to end: the server resolves the rpc and inlines its payload, and the client replays it during `hydrate`.
|
|
587
|
+
|
|
588
|
+
**Client-side** navigation into a page containing a `Boundary.rpc` has no SSR payload, so the boundary performs a **client-first mount**. It renders the boundary's `fallback`, forks the rpc call over `POST /_eui/rpc`, and swaps in the result.
|
|
589
|
+
|
|
590
|
+
`@weftui/router` provides the `AppRpcClientTag` seam on both sides (network client on the client, in-process on the server). The same rpc backs SSR-replay, refetch, and client-first mount. Both `RouterLive` and `RouterServer` take an optional `{ rpc: { group } }` (server also needs `handlers`) to wire it: see the [rpc data boundaries guide](https://weftui.dev/docs/how-to/load-data-with-rpc) and [`examples/router-ssr`](https://github.com/stefvw93/weft/tree/main/examples/router-ssr) for the full contract/handler split.
|
|
591
|
+
|
|
592
|
+
```typescript
|
|
593
|
+
// client (entry-client.ts): network rpc client over the shared group
|
|
594
|
+
const app = WeftApp.make(RouterLive(App, { rpc: { group: StockRpcs } }));
|
|
595
|
+
|
|
596
|
+
// server (entry-server.ts): same group, plus its handler Layer
|
|
597
|
+
const rpc = { group: StockRpcs, handlers: StockLive };
|
|
598
|
+
export const handler = RouterServer.toWebHandler(App, { document: documentShell, rpc });
|
|
599
|
+
```
|
|
300
600
|
|
|
301
601
|
## See also
|
|
302
602
|
|
|
303
603
|
- [`@weftui/router` API reference](https://weftui.dev/docs/reference/router)
|
|
304
|
-
- [examples/router-ssr](https://github.com/stefvw93/weft/tree/main/examples/router-ssr)
|
|
305
|
-
- [
|
|
306
|
-
- [
|
|
307
|
-
- [
|
|
308
|
-
- [
|
|
604
|
+
- [examples/router-ssr](https://github.com/stefvw93/weft/tree/main/examples/router-ssr): a runnable SSR + hydration app with nested layouts, persistent layout state, type-safe `href`s, handler-arg props, `Boundary.rpc`, and programmatic navigation over the `effect/unstable/httpapi` spine
|
|
605
|
+
- [examples/router-client](https://github.com/stefvw93/weft/tree/main/examples/router-client): the client-only counterpart, no server, no SSR, no `Boundary.rpc`
|
|
606
|
+
- [Component Authoring](https://weftui.dev/docs/how-to/author-components): `Component.make` / `Component.gen`, the idiomatic way to write route components
|
|
607
|
+
- [Server-Side Rendering](https://weftui.dev/docs/how-to/render-on-the-server): `renderToStringHydratable`, `hydrate`, and `Boundary.rpc`
|
|
608
|
+
- [RPC Data Boundaries](https://weftui.dev/docs/how-to/load-data-with-rpc): `Boundary.rpc`, the `Resource` handle, and the four lifecycles
|
|
609
|
+
- [`packages/router/router.specs.md`](https://github.com/stefvw93/weft/blob/main/packages/router/router.specs.md): the full specification
|