@weftui/core 0.29.0 → 0.31.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/{index-4cTlhojA.d.ts → index-B-dPfhKZ.d.ts} +38 -20
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/types/index.d.ts +1 -1
- package/docs/explanation/boundaries-and-suspense.md +38 -8
- package/docs/explanation/combinator-api.md +17 -11
- package/docs/explanation/reactive-primitives.md +40 -13
- package/docs/explanation/rendering-model.md +14 -2
- package/docs/how-to/add-routing.md +299 -28
- package/docs/how-to/author-components.md +71 -105
- package/docs/how-to/compose-behavior-and-markup.md +141 -54
- package/docs/how-to/handle-forms.md +142 -15
- package/docs/how-to/load-async-data.md +110 -6
- package/docs/how-to/load-data-with-rpc.md +171 -18
- package/docs/how-to/provide-services.md +84 -1
- package/docs/how-to/render-keyed-lists.md +120 -14
- package/docs/how-to/render-on-the-server.md +113 -15
- package/docs/how-to/show-navigation-progress.md +120 -14
- package/docs/how-to/split-routes-lazily.md +106 -3
- package/docs/how-to/style-reactively.md +141 -13
- package/docs/how-to/use-element-refs.md +126 -8
- package/docs/reference/core.md +22 -2
- package/docs/reference/dom.md +43 -0
- package/docs/reference/router.md +2 -2
- package/docs/tutorial/01-your-first-app.md +43 -13
- package/docs/tutorial/02-reactivity.md +29 -30
- package/docs/tutorial/03-services-and-async.md +89 -38
- package/docs/tutorial/04-errors-and-server.md +41 -25
- package/package.json +1 -1
|
@@ -9,10 +9,10 @@ description: "@weftui/router: universal nested routing, Router.route / Router.la
|
|
|
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**: matches an incoming request path, renders
|
|
13
|
-
- **Client**: matches
|
|
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,9 +20,22 @@ npm install @weftui/router
|
|
|
20
20
|
|
|
21
21
|
## The mental model
|
|
22
22
|
|
|
23
|
-
A route's **component is its handler**. A page is a component that renders, and its `component` slot is invoked at render time
|
|
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
|
+
});
|
|
32
|
+
|
|
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:
|
|
26
39
|
|
|
27
40
|
| Combinator | Builds |
|
|
28
41
|
| ----------------------------------------------------- | ---------------------------------------------------------------- |
|
|
@@ -34,7 +47,7 @@ The tree is the source of truth. The same sealed `RouterDef` drives both server
|
|
|
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,10 +67,13 @@ 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
|
|
|
@@ -97,9 +113,29 @@ const UserShell = Component.gen(function* () {
|
|
|
97
113
|
|
|
98
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`.
|
|
99
115
|
|
|
100
|
-
That error bubbles into the app node's aggregate `E`, so a user may recover it with `Boundary.catchTag(
|
|
116
|
+
That error bubbles into the app node's aggregate `E`, so a user may recover it with `Boundary.catchTag(…)`.
|
|
101
117
|
|
|
102
|
-
|
|
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
|
+
```
|
|
137
|
+
|
|
138
|
+
A `NotFound` match yields the empty subset rather than failing, so the stream stays live across navigations.
|
|
103
139
|
|
|
104
140
|
## Layouts and the outlet
|
|
105
141
|
|
|
@@ -121,7 +157,30 @@ A layout owns **no `segment` or `path`**; all path structure lives on routes. A
|
|
|
121
157
|
|
|
122
158
|
### Layout persistence
|
|
123
159
|
|
|
124
|
-
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
|
|
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.
|
|
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.
|
|
125
184
|
|
|
126
185
|
## Sealing the tree
|
|
127
186
|
|
|
@@ -175,7 +234,7 @@ Router.route("users/:id", {
|
|
|
175
234
|
});
|
|
176
235
|
```
|
|
177
236
|
|
|
178
|
-
`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.
|
|
179
238
|
|
|
180
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.
|
|
181
240
|
|
|
@@ -183,21 +242,100 @@ Router.route("users/:id", {
|
|
|
183
242
|
|
|
184
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.
|
|
185
244
|
|
|
186
|
-
Give it to `WeftApp.make`. The app runtime owns it for the app's lifetime, built lazily on first hydrate and released only at `WeftApp.dispose`. Do not wrap `Effect.provide` around the mount/hydrate call; services come exclusively from the app layer.
|
|
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)).
|
|
187
246
|
|
|
188
247
|
```typescript
|
|
189
|
-
|
|
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
|
+
```
|
|
320
|
+
|
|
321
|
+
```typescript
|
|
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
|
+
*/
|
|
190
327
|
import { WeftApp } from "@weftui/dom/client";
|
|
191
328
|
import { RouterApp, RouterLive } from "@weftui/router/client";
|
|
192
329
|
import { Effect } from "effect";
|
|
193
330
|
import { App } from "./app";
|
|
194
331
|
|
|
195
332
|
const root = document.getElementById("root")!;
|
|
333
|
+
|
|
196
334
|
const app = WeftApp.make(RouterLive(App));
|
|
197
|
-
void Effect.runPromise(WeftApp.
|
|
335
|
+
void Effect.runPromise(WeftApp.mount(app, RouterApp(App), root));
|
|
198
336
|
```
|
|
199
337
|
|
|
200
|
-
|
|
338
|
+
`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.
|
|
201
339
|
|
|
202
340
|
### Link interception
|
|
203
341
|
|
|
@@ -209,6 +347,11 @@ A plain `h.a({ href })` to a same-origin, route-matching URL performs SPA naviga
|
|
|
209
347
|
- same-document (hash-only) navigations
|
|
210
348
|
- hrefs that don't resolve to a route
|
|
211
349
|
|
|
350
|
+
```typescript
|
|
351
|
+
h.a({ href: "/about" }, "About"); // intercepted: SPA navigation, no reload
|
|
352
|
+
h.a({ href: "/about", target: "_blank" }, "About"); // native: falls through
|
|
353
|
+
```
|
|
354
|
+
|
|
212
355
|
You don't wire anything up. `RouterLive` installs the delegated listener for the layer's lifetime and removes it on teardown.
|
|
213
356
|
|
|
214
357
|
## Programmatic navigation
|
|
@@ -265,10 +408,95 @@ On the server, `RouterServer`:
|
|
|
265
408
|
- renders `RouterApp` to hydratable HTML inside a **document shell**
|
|
266
409
|
- reports a status (404 when no route matches or a page raises `RouterNotFound`)
|
|
267
410
|
|
|
268
|
-
The document shell is itself a `ComponentSlot` that splices the app via `yield* Router.Outlet`, exactly like a layout receives its outlet
|
|
411
|
+
The document shell is itself a `ComponentSlot` that splices the app via `yield* Router.Outlet`, exactly like a layout receives its outlet.
|
|
412
|
+
|
|
413
|
+
```typescript
|
|
414
|
+
const { html, status } = await Effect.runPromise(
|
|
415
|
+
RouterServer.render(App, { document: documentShell, url }),
|
|
416
|
+
);
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
### Full SSR example
|
|
420
|
+
|
|
421
|
+
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).
|
|
422
|
+
|
|
423
|
+
```typescript
|
|
424
|
+
// src/app.ts
|
|
425
|
+
/**
|
|
426
|
+
* Shared, isomorphic router app: three pages under one persistent Shell
|
|
427
|
+
* layout. Side-effect-free: it never mounts or serves. `entry-server.ts`
|
|
428
|
+
* renders the matched route on the server; `entry-client.ts` hydrates over it.
|
|
429
|
+
*/
|
|
430
|
+
import { Component, h } from "@weftui/core";
|
|
431
|
+
import { href, notFound, Router } from "@weftui/router";
|
|
432
|
+
import { Schema } from "effect";
|
|
433
|
+
|
|
434
|
+
const idParam = { id: Schema.NumberFromString };
|
|
435
|
+
|
|
436
|
+
export const homeRoute = Router.route("", {
|
|
437
|
+
component: Component.make(() => h.section({ id: "page" }, [h.h2("Home")])),
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
export const aboutRoute = Router.route("about", {
|
|
441
|
+
component: Component.make(() => h.section({ id: "page" }, [h.h2("About")])),
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
export const userRoute = Router.route("users/:id", {
|
|
445
|
+
path: idParam,
|
|
446
|
+
component: ({ path }) => {
|
|
447
|
+
if (!Number.isFinite(path.id) || path.id < 0) return notFound();
|
|
448
|
+
return h.section({ id: "page" }, [h.h2(`User ${path.id}`)]);
|
|
449
|
+
},
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
const Shell = Component.gen(function* () {
|
|
453
|
+
const outlet = yield* Router.Outlet;
|
|
454
|
+
return yield* h.div({ id: "app" }, [
|
|
455
|
+
h.nav([
|
|
456
|
+
h.a({ href: href(homeRoute) }, "Home"),
|
|
457
|
+
" · ",
|
|
458
|
+
h.a({ href: href(aboutRoute) }, "About"),
|
|
459
|
+
]),
|
|
460
|
+
h.main([outlet]),
|
|
461
|
+
]);
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
export const App = Router.router(
|
|
465
|
+
Router.layout({ component: Shell }, [homeRoute, aboutRoute, userRoute]),
|
|
466
|
+
{ notFound: () => h.section({ id: "page" }, [h.h2("404: page not found")]) },
|
|
467
|
+
);
|
|
468
|
+
```
|
|
469
|
+
|
|
470
|
+
```typescript
|
|
471
|
+
// src/entry-client.ts
|
|
472
|
+
/**
|
|
473
|
+
* Client entry: hydrates the server-rendered markup in `#root`.
|
|
474
|
+
*
|
|
475
|
+
* `RouterApp(App)` is the universal router root; `RouterLive(App)` provides
|
|
476
|
+
* the History-API-backed `Router` (seeded from `window.location`, with the
|
|
477
|
+
* same-origin link click interceptor installed). `hydrate` adopts the server
|
|
478
|
+
* DOM in place and resumes the reactive outlet.
|
|
479
|
+
*/
|
|
480
|
+
import { WeftApp } from "@weftui/dom/client";
|
|
481
|
+
import { RouterApp, RouterLive } from "@weftui/router/client";
|
|
482
|
+
import { Effect } from "effect";
|
|
483
|
+
import { App } from "./app";
|
|
484
|
+
|
|
485
|
+
const root = document.getElementById("root")!;
|
|
486
|
+
|
|
487
|
+
const app = WeftApp.make(RouterLive(App));
|
|
488
|
+
void Effect.runPromise(WeftApp.hydrate(app, RouterApp(App), root));
|
|
489
|
+
```
|
|
269
490
|
|
|
270
491
|
```typescript
|
|
271
|
-
// entry-server.ts
|
|
492
|
+
// src/entry-server.ts
|
|
493
|
+
/**
|
|
494
|
+
* Server entry: renders the matched route to a hydratable HTML document.
|
|
495
|
+
*
|
|
496
|
+
* `documentShell` splices the app via `yield* Router.Outlet` (injected per
|
|
497
|
+
* request by `RouterServer`). `render` drives it for a single `url`; `handler`
|
|
498
|
+
* is a Web `fetch`-style handler ready to bridge into Vite or any Web server.
|
|
499
|
+
*/
|
|
272
500
|
import { Component, h } from "@weftui/core";
|
|
273
501
|
import { Router } from "@weftui/router";
|
|
274
502
|
import { RouterServer } from "@weftui/router/server";
|
|
@@ -287,14 +515,16 @@ const documentShell = Component.gen(function* () {
|
|
|
287
515
|
});
|
|
288
516
|
|
|
289
517
|
// { html, status }: `<!DOCTYPE html>` is prepended for you.
|
|
290
|
-
export const render = (url: string) =>
|
|
518
|
+
export const render = (url: string): Promise<{ html: string; status: number }> =>
|
|
291
519
|
Effect.runPromise(RouterServer.render(App, { document: documentShell, url }));
|
|
292
520
|
|
|
293
|
-
//
|
|
521
|
+
// A Web fetch-style handler, ready to bridge into Vite or any Web server.
|
|
294
522
|
export const handler = RouterServer.toWebHandler(App, { document: documentShell });
|
|
295
523
|
```
|
|
296
524
|
|
|
297
|
-
`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.
|
|
525
|
+
`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).
|
|
526
|
+
|
|
527
|
+
`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.
|
|
298
528
|
|
|
299
529
|
### `effect/unstable/httpapi` is the spine
|
|
300
530
|
|
|
@@ -305,27 +535,68 @@ The result is a single `"pages"` group with one GET endpoint per leaf at its ful
|
|
|
305
535
|
- **Server**: `RouterServer` dispatches through `HttpApiBuilder` (platform owns request→leaf matching, path/query decode, and the 404 status).
|
|
306
536
|
- **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.
|
|
307
537
|
|
|
538
|
+
```typescript
|
|
539
|
+
import { Option } from "effect";
|
|
540
|
+
|
|
541
|
+
App.httpApi; // HttpApi.Top: one "pages" group, a GET endpoint per leaf
|
|
542
|
+
|
|
543
|
+
const { httpApiClient } = yield * Router;
|
|
544
|
+
Option.isSome(httpApiClient); // true under RouterLive, false under RouterServer
|
|
545
|
+
```
|
|
546
|
+
|
|
308
547
|
## Errors
|
|
309
548
|
|
|
310
|
-
| Error | Raised by | Recover with
|
|
311
|
-
| ------------------- | --------------------------------------------------------------------- |
|
|
312
|
-
| `RouterNotFound` | `notFound()`, or no route matched | `Boundary.catchTag(
|
|
313
|
-
| `RouterParamsError` | `Router.params` / `Router.query` on a missing/invalid key or no match | `Boundary.catchTag(
|
|
549
|
+
| Error | Raised by | Recover with |
|
|
550
|
+
| ------------------- | --------------------------------------------------------------------- | --------------------------------------------------------- |
|
|
551
|
+
| `RouterNotFound` | `notFound()`, or no route matched | `Boundary.catchTag(…)` (or the app-level `notFound` page) |
|
|
552
|
+
| `RouterParamsError` | `Router.params` / `Router.query` on a missing/invalid key or no match | `Boundary.catchTag(…)` |
|
|
314
553
|
|
|
315
554
|
Both are modeled as `Schema.TaggedErrorClass`, so they encode/decode across the wire the same way `Boundary.rpc` replays typed failures.
|
|
316
555
|
|
|
556
|
+
Recover locally by wrapping just the subtree that can fail, rather than relying on the app-level `notFound` page for everything:
|
|
557
|
+
|
|
558
|
+
```typescript
|
|
559
|
+
import { Boundary, Component, h } from "@weftui/core";
|
|
560
|
+
import { Router } from "@weftui/router";
|
|
561
|
+
|
|
562
|
+
const UserShell = Component.gen(function* () {
|
|
563
|
+
const outlet = yield* Router.Outlet;
|
|
564
|
+
return yield* h.div({ class: "user" }, [
|
|
565
|
+
Boundary.catchTag(
|
|
566
|
+
{
|
|
567
|
+
tag: "RouterParamsError",
|
|
568
|
+
fallback: () => h.p({ class: "error" }, "Couldn't read this page's params."),
|
|
569
|
+
},
|
|
570
|
+
[outlet],
|
|
571
|
+
),
|
|
572
|
+
]);
|
|
573
|
+
});
|
|
574
|
+
```
|
|
575
|
+
|
|
576
|
+
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.
|
|
577
|
+
|
|
317
578
|
## `Boundary.rpc` interplay
|
|
318
579
|
|
|
319
580
|
Initial SSR navigation works end to end: the server resolves the rpc and inlines its payload, and the client replays it during `hydrate`.
|
|
320
581
|
|
|
321
582
|
**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.
|
|
322
583
|
|
|
323
|
-
`@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.
|
|
584
|
+
`@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.
|
|
585
|
+
|
|
586
|
+
```typescript
|
|
587
|
+
// client (entry-client.ts): network rpc client over the shared group
|
|
588
|
+
const app = WeftApp.make(RouterLive(App, { rpc: { group: StockRpcs } }));
|
|
589
|
+
|
|
590
|
+
// server (entry-server.ts): same group, plus its handler Layer
|
|
591
|
+
const rpc = { group: StockRpcs, handlers: StockLive };
|
|
592
|
+
export const handler = RouterServer.toWebHandler(App, { document: documentShell, rpc });
|
|
593
|
+
```
|
|
324
594
|
|
|
325
595
|
## See also
|
|
326
596
|
|
|
327
597
|
- [`@weftui/router` API reference](https://weftui.dev/docs/reference/router)
|
|
328
|
-
- [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, and programmatic navigation over the `effect/unstable/httpapi` spine
|
|
598
|
+
- [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
|
|
599
|
+
- [examples/router-client](https://github.com/stefvw93/weft/tree/main/examples/router-client): the client-only counterpart, no server, no SSR, no `Boundary.rpc`
|
|
329
600
|
- [Component Authoring](https://weftui.dev/docs/how-to/author-components): `Component.make` / `Component.gen`, the idiomatic way to write route components
|
|
330
601
|
- [Server-Side Rendering](https://weftui.dev/docs/how-to/render-on-the-server): `renderToStringHydratable`, `hydrate`, and `Boundary.rpc`
|
|
331
602
|
- [RPC Data Boundaries](https://weftui.dev/docs/how-to/load-data-with-rpc): `Boundary.rpc`, the `Resource` handle, and the four lifecycles
|