@weftui/router 0.29.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/dist/client/index.d.ts +2 -2
- package/dist/client/index.js +3 -3
- package/dist/{compile-Bb7AknG_.d.ts → compile-DFlX4Atp.d.ts} +3 -3
- package/dist/{href-3swSSbn_.js → href-wIrP-_-3.js} +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/{outlet-2AnUWKQD.d.ts → outlet-CWpiNOxW.d.ts} +1 -1
- package/dist/{outlet-BgJmsO_g.js → outlet-Crd9mxZA.js} +15 -13
- package/dist/server/index.d.ts +1 -1
- package/dist/server/index.js +1 -1
- package/docs/explanation/boundaries-and-suspense.md +2 -2
- package/docs/how-to/add-routing.md +306 -29
- package/docs/how-to/author-components.md +2 -2
- package/docs/how-to/load-data-with-rpc.md +4 -4
- package/docs/how-to/render-keyed-lists.md +5 -5
- package/docs/how-to/render-on-the-server.md +2 -2
- package/docs/how-to/show-navigation-progress.md +2 -2
- package/docs/reference/core.md +2 -2
- package/docs/reference/router.md +2 -2
- package/package.json +3 -3
package/dist/client/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { S as Router, b as NavState, i as RouterDef, m as RouteNode, u as Fields, x as NavigateOptions } from "../compile-
|
|
2
|
-
import { n as outletNode, r as HrefArgs, t as RouterApp } from "../outlet-
|
|
1
|
+
import { S as Router, b as NavState, i as RouterDef, m as RouteNode, u as Fields, x as NavigateOptions } from "../compile-DFlX4Atp.js";
|
|
2
|
+
import { n as outletNode, r as HrefArgs, t as RouterApp } from "../outlet-CWpiNOxW.js";
|
|
3
3
|
import { Effect, Layer, Scope } from "effect";
|
|
4
4
|
import { AppRpcClientTag } from "@weftui/core";
|
|
5
5
|
import { RpcGroup } from "effect/unstable/rpc";
|
package/dist/client/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { a as Router, i as setResolvedCommit, n as outletNode, o as getPreload, r as preRunLeaf, t as RouterApp } from "../outlet-
|
|
2
|
-
import { r as match, t as href } from "../href-
|
|
1
|
+
import { a as Router, i as setResolvedCommit, n as outletNode, o as getPreload, r as preRunLeaf, t as RouterApp } from "../outlet-Crd9mxZA.js";
|
|
2
|
+
import { r as match, t as href } from "../href-wIrP-_-3.js";
|
|
3
3
|
import { HttpApiClient } from "effect/unstable/httpapi";
|
|
4
4
|
import { Context, Effect, Exit, Fiber, Layer, Option, Schema, Scope, Stream, SubscriptionRef } from "effect";
|
|
5
5
|
import { AppRpcClientTag, Subscribable } from "@weftui/core";
|
|
@@ -245,7 +245,7 @@ function pathOf(url) {
|
|
|
245
245
|
function applyQuery(transform, options) {
|
|
246
246
|
return Effect.gen(function* () {
|
|
247
247
|
const router = yield* Router;
|
|
248
|
-
const match = yield* router.currentMatch
|
|
248
|
+
const match = yield* Subscribable.get(router.currentMatch);
|
|
249
249
|
if (match._tag !== "Matched") return;
|
|
250
250
|
const next = transform(match.query);
|
|
251
251
|
const search = encodeSearch(Schema.encodeUnknownSync(match.leaf.querySchema)(next));
|
|
@@ -182,7 +182,7 @@ declare const OutletTag_base: Context.ServiceClass<OutletTag, "@weftui/router/Ou
|
|
|
182
182
|
declare class OutletTag extends OutletTag_base {}
|
|
183
183
|
/**
|
|
184
184
|
* Reads the live match's **path params** for the requested `fields`. Snapshot
|
|
185
|
-
* semantics (reads `yield* Router`, then `
|
|
185
|
+
* semantics (reads `yield* Router`, then `Subscribable.get(currentMatch)`), returning the
|
|
186
186
|
* already-decoded values **directly**: the matcher decoded them against the leaf's
|
|
187
187
|
* full path schema, so no re-validation is needed. The cast is sound because the
|
|
188
188
|
* picked subset is the `Type` side of `fields`. Fails with a {@link RouterParamsError}
|
|
@@ -198,9 +198,9 @@ declare function readParams<F extends Fields>(fields: F): Effect.Effect<FieldsTy
|
|
|
198
198
|
declare function readQuery<F extends Fields>(fields: F): Effect.Effect<FieldsType<F>, RouterParamsError, Router>;
|
|
199
199
|
/**
|
|
200
200
|
* Reactive counterpart to {@link readParams}: a {@link Subscribable} of the live
|
|
201
|
-
* match's **path params** for `fields`, derived from `
|
|
201
|
+
* match's **path params** for `fields`, derived from `Subscribable.changes(currentMatch)`. It
|
|
202
202
|
* re-emits on every navigation and stays live across `NotFound` (yielding the empty
|
|
203
|
-
* subset), so a component can render `[(yield* Router.paramsStream(fields))
|
|
203
|
+
* subset), so a component can render `[Subscribable.changes(yield* Router.paramsStream(fields))]`
|
|
204
204
|
* and update in place even when the outlet keeps the same leaf mounted. Re-exported
|
|
205
205
|
* as `Router.paramsStream`.
|
|
206
206
|
*/
|
package/dist/index.d.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
import { A as notFound, C as RouterHttpApiClient, D as RouterNotFound, E as match, O as RouterParamsError, S as Router, T as compileMatchers, _ as TreeE, a as RouterOptions, b as NavState, c as leafRegistry, d as FieldsType, f as LayoutNode, g as SubtreeR, h as SubtreeE, i as RouterDef, k as isRouterNotFound, l as ComponentSlot, m as RouteNode, n as CompiledLayout, o as buildHttpApi, p as RouteHandlerProps, r as CompiledLeaf, s as compile, t as Compiled, u as Fields, v as TreeNode, w as RouteMatch, x as NavigateOptions, y as TreeR } from "./compile-
|
|
2
|
-
import { i as href, n as outletNode, r as HrefArgs, t as RouterApp } from "./outlet-
|
|
1
|
+
import { A as notFound, C as RouterHttpApiClient, D as RouterNotFound, E as match, O as RouterParamsError, S as Router, T as compileMatchers, _ as TreeE, a as RouterOptions, b as NavState, c as leafRegistry, d as FieldsType, f as LayoutNode, g as SubtreeR, h as SubtreeE, i as RouterDef, k as isRouterNotFound, l as ComponentSlot, m as RouteNode, n as CompiledLayout, o as buildHttpApi, p as RouteHandlerProps, r as CompiledLeaf, s as compile, t as Compiled, u as Fields, v as TreeNode, w as RouteMatch, x as NavigateOptions, y as TreeR } from "./compile-DFlX4Atp.js";
|
|
2
|
+
import { i as href, n as outletNode, r as HrefArgs, t as RouterApp } from "./outlet-CWpiNOxW.js";
|
|
3
3
|
export { type Compiled, type CompiledLayout, type CompiledLeaf, type ComponentSlot, type Fields, type FieldsType, type HrefArgs, type LayoutNode, type NavState, type NavigateOptions, type RouteHandlerProps, type RouteMatch, type RouteNode, Router, RouterApp, type RouterDef, type RouterHttpApiClient, RouterNotFound, type RouterOptions, RouterParamsError, type SubtreeE, type SubtreeR, type TreeE, type TreeNode, type TreeR, buildHttpApi, compile, compileMatchers, href, isRouterNotFound, leafRegistry, match, notFound, outletNode };
|
package/dist/index.js
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
import { a as Router, c as compile, d as RouterParamsError, f as isRouterNotFound, l as leafRegistry, n as outletNode, p as notFound, s as buildHttpApi, t as RouterApp, u as RouterNotFound } from "./outlet-
|
|
2
|
-
import { n as compileMatchers, r as match, t as href } from "./href-
|
|
1
|
+
import { a as Router, c as compile, d as RouterParamsError, f as isRouterNotFound, l as leafRegistry, n as outletNode, p as notFound, s as buildHttpApi, t as RouterApp, u as RouterNotFound } from "./outlet-Crd9mxZA.js";
|
|
2
|
+
import { n as compileMatchers, r as match, t as href } from "./href-wIrP-_-3.js";
|
|
3
3
|
export { Router, RouterApp, RouterNotFound, RouterParamsError, buildHttpApi, compile, compileMatchers, href, isRouterNotFound, leafRegistry, match, notFound, outletNode };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { D as RouterNotFound, S as Router, d as FieldsType, i as RouterDef, m as RouteNode, u as Fields } from "./compile-
|
|
1
|
+
import { D as RouterNotFound, S as Router, d as FieldsType, i as RouterDef, m as RouteNode, u as Fields } from "./compile-DFlX4Atp.js";
|
|
2
2
|
import { Node } from "@weftui/core";
|
|
3
3
|
//#region src/href.d.ts
|
|
4
4
|
/**
|
|
@@ -301,7 +301,7 @@ function pick(fields, record) {
|
|
|
301
301
|
}
|
|
302
302
|
/**
|
|
303
303
|
* Reads the live match's **path params** for the requested `fields`. Snapshot
|
|
304
|
-
* semantics (reads `yield* Router`, then `
|
|
304
|
+
* semantics (reads `yield* Router`, then `Subscribable.get(currentMatch)`), returning the
|
|
305
305
|
* already-decoded values **directly**: the matcher decoded them against the leaf's
|
|
306
306
|
* full path schema, so no re-validation is needed. The cast is sound because the
|
|
307
307
|
* picked subset is the `Type` side of `fields`. Fails with a {@link RouterParamsError}
|
|
@@ -309,7 +309,8 @@ function pick(fields, record) {
|
|
|
309
309
|
*/
|
|
310
310
|
function readParams(fields) {
|
|
311
311
|
return Effect.gen(function* () {
|
|
312
|
-
const
|
|
312
|
+
const router = yield* Router;
|
|
313
|
+
const match = yield* Subscribable.get(router.currentMatch);
|
|
313
314
|
if (match._tag === "NotFound") return yield* Effect.fail(new RouterParamsError({
|
|
314
315
|
source: "path",
|
|
315
316
|
keys: Object.keys(fields)
|
|
@@ -325,7 +326,8 @@ function readParams(fields) {
|
|
|
325
326
|
*/
|
|
326
327
|
function readQuery(fields) {
|
|
327
328
|
return Effect.gen(function* () {
|
|
328
|
-
const
|
|
329
|
+
const router = yield* Router;
|
|
330
|
+
const match = yield* Subscribable.get(router.currentMatch);
|
|
329
331
|
if (match._tag === "NotFound") return yield* Effect.fail(new RouterParamsError({
|
|
330
332
|
source: "query",
|
|
331
333
|
keys: Object.keys(fields)
|
|
@@ -343,15 +345,15 @@ function readQuery(fields) {
|
|
|
343
345
|
function selectStream(currentMatch, fields, source) {
|
|
344
346
|
const select = (m) => pick(fields, m._tag === "Matched" ? m[source] : {});
|
|
345
347
|
return Subscribable.make({
|
|
346
|
-
get: Effect.map(
|
|
347
|
-
changes: Stream.map(
|
|
348
|
+
get: Effect.map(Subscribable.get(currentMatch), select),
|
|
349
|
+
changes: Stream.map(Subscribable.changes(currentMatch), select)
|
|
348
350
|
});
|
|
349
351
|
}
|
|
350
352
|
/**
|
|
351
353
|
* Reactive counterpart to {@link readParams}: a {@link Subscribable} of the live
|
|
352
|
-
* match's **path params** for `fields`, derived from `
|
|
354
|
+
* match's **path params** for `fields`, derived from `Subscribable.changes(currentMatch)`. It
|
|
353
355
|
* re-emits on every navigation and stays live across `NotFound` (yielding the empty
|
|
354
|
-
* subset), so a component can render `[(yield* Router.paramsStream(fields))
|
|
356
|
+
* subset), so a component can render `[Subscribable.changes(yield* Router.paramsStream(fields))]`
|
|
355
357
|
* and update in place even when the outlet keeps the same leaf mounted. Re-exported
|
|
356
358
|
* as `Router.paramsStream`.
|
|
357
359
|
*/
|
|
@@ -370,7 +372,7 @@ function subscribeQuery(fields) {
|
|
|
370
372
|
}
|
|
371
373
|
/**
|
|
372
374
|
* Reactive {@link Subscribable} of the client {@link NavState}. A component reads
|
|
373
|
-
* `[(yield* Router.navigatingStream)
|
|
375
|
+
* `[Subscribable.changes(yield* Router.navigatingStream)]` to render pending UI during a
|
|
374
376
|
* deferred-commit navigation (`pending-navigation.specs.md`,
|
|
375
377
|
* `resolve-before-commit.specs.md`). Re-exported as `Router.navigatingStream`.
|
|
376
378
|
*/
|
|
@@ -436,10 +438,10 @@ function takeResolvedCommit(router, url) {
|
|
|
436
438
|
}
|
|
437
439
|
/**
|
|
438
440
|
* The staged `Router` view the pre-run executes under (AC-R4): identical to
|
|
439
|
-
* `router` except `
|
|
441
|
+
* `router` except `Subscribable.get(currentMatch)` resolves to the **target** match. The URL
|
|
440
442
|
* ref has not moved yet, and one-shot reads (`Router.params`, `Router.query`,
|
|
441
|
-
* `
|
|
442
|
-
* destination, not the page being left. `
|
|
443
|
+
* `Subscribable.get(currentMatch)`) inside the pre-running component body must decode the
|
|
444
|
+
* destination, not the page being left. `Subscribable.changes(currentMatch)` (and `navigate` /
|
|
443
445
|
* `httpApiClient` / `navigating`) delegate to the live service, so reactive
|
|
444
446
|
* subscriptions, which occur at render/mount time post-commit, observe the
|
|
445
447
|
* committed match onward.
|
|
@@ -449,7 +451,7 @@ function stageMatch(router, target) {
|
|
|
449
451
|
...router,
|
|
450
452
|
currentMatch: Subscribable.make({
|
|
451
453
|
get: Effect.succeed(target),
|
|
452
|
-
changes: router.currentMatch
|
|
454
|
+
changes: Subscribable.changes(router.currentMatch)
|
|
453
455
|
})
|
|
454
456
|
};
|
|
455
457
|
}
|
|
@@ -524,7 +526,7 @@ function renderLevel(def, router, index, match) {
|
|
|
524
526
|
* reactive child region.
|
|
525
527
|
*/
|
|
526
528
|
function levelStream(def, router, index) {
|
|
527
|
-
return pipe(router.currentMatch
|
|
529
|
+
return pipe(Subscribable.changes(router.currentMatch), Stream.map((match) => [keyOf(index, match), match]), Stream.changesWith((a, b) => a[0] === b[0]), Stream.map(([, match]) => renderLevel(def, router, index, match)));
|
|
528
530
|
}
|
|
529
531
|
/**
|
|
530
532
|
* The bare nested-outlet node for a router definition: a fragment whose single
|
package/dist/server/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { S as Router, i as RouterDef, l as ComponentSlot } from "../compile-
|
|
1
|
+
import { S as Router, i as RouterDef, l as ComponentSlot } from "../compile-DFlX4Atp.js";
|
|
2
2
|
import { Effect, Layer } from "effect";
|
|
3
3
|
import { AppRpcClientTag } from "@weftui/core";
|
|
4
4
|
import { RpcGroup } from "effect/unstable/rpc";
|
package/dist/server/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { a as Router, f as isRouterNotFound, n as outletNode, u as RouterNotFound } from "../outlet-
|
|
1
|
+
import { a as Router, f as isRouterNotFound, n as outletNode, u as RouterNotFound } from "../outlet-Crd9mxZA.js";
|
|
2
2
|
import { HttpApiBuilder, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi";
|
|
3
3
|
import { Cause, Effect, Exit, Layer, Option, Schema, Scope, Stream } from "effect";
|
|
4
4
|
import { AppRpcClientTag, Subscribable } from "@weftui/core";
|
|
@@ -81,13 +81,13 @@ On the server, `renderToStreamHydratable` emits the fallback inline and appends
|
|
|
81
81
|
Conceptually it is the same idea as the other boundaries: a node that decides what renders in a subtree. But the thing it intercepts is a **round-trip to a server handler**. Instead of a children array, it takes a `render` function that receives a reactive [`Resource`](https://weftui.dev/docs/reference/core#resourcea).
|
|
82
82
|
|
|
83
83
|
```typescript
|
|
84
|
-
import { Boundary, h } from "@weftui/core";
|
|
84
|
+
import { Boundary, h, Subscribable } from "@weftui/core";
|
|
85
85
|
import { Stream } from "effect";
|
|
86
86
|
|
|
87
87
|
Boundary.rpc(
|
|
88
88
|
GetStock,
|
|
89
89
|
() => ({ id: productId }),
|
|
90
|
-
(resource) => h.span([Stream.map(resource.value
|
|
90
|
+
(resource) => h.span([Stream.map(Subscribable.changes(resource.value), (s) => String(s.units))]),
|
|
91
91
|
{ fallback: h.p("loading…") },
|
|
92
92
|
);
|
|
93
93
|
```
|
|
@@ -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"`).
|
|
59
73
|
|
|
60
|
-
|
|
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.
|
|
75
|
+
|
|
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(…)`.
|
|
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
|
+
```
|
|
101
137
|
|
|
102
|
-
|
|
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,103 @@ 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)).
|
|
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
|
+
```
|
|
187
320
|
|
|
188
321
|
```typescript
|
|
189
|
-
//
|
|
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
|
-
const root = document.getElementById("root")
|
|
332
|
+
const root = document.getElementById("root");
|
|
333
|
+
if (root === null) {
|
|
334
|
+
throw new Error("#root not found");
|
|
335
|
+
}
|
|
336
|
+
|
|
196
337
|
const app = WeftApp.make(RouterLive(App));
|
|
197
|
-
void Effect.runPromise(WeftApp.
|
|
338
|
+
void Effect.runPromise(WeftApp.mount(app, RouterApp(App), root));
|
|
198
339
|
```
|
|
199
340
|
|
|
200
|
-
|
|
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.
|
|
201
342
|
|
|
202
343
|
### Link interception
|
|
203
344
|
|
|
@@ -209,6 +350,11 @@ A plain `h.a({ href })` to a same-origin, route-matching URL performs SPA naviga
|
|
|
209
350
|
- same-document (hash-only) navigations
|
|
210
351
|
- hrefs that don't resolve to a route
|
|
211
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
|
+
|
|
212
358
|
You don't wire anything up. `RouterLive` installs the delegated listener for the layer's lifetime and removes it on teardown.
|
|
213
359
|
|
|
214
360
|
## Programmatic navigation
|
|
@@ -265,10 +411,98 @@ On the server, `RouterServer`:
|
|
|
265
411
|
- renders `RouterApp` to hydratable HTML inside a **document shell**
|
|
266
412
|
- reports a status (404 when no route matches or a page raises `RouterNotFound`)
|
|
267
413
|
|
|
268
|
-
The document shell is itself a `ComponentSlot` that splices the app via `yield* Router.Outlet`, exactly like a layout receives its outlet
|
|
414
|
+
The document shell is itself a `ComponentSlot` that splices the app via `yield* Router.Outlet`, exactly like a layout receives its outlet.
|
|
415
|
+
|
|
416
|
+
```typescript
|
|
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
|
+
```
|
|
269
496
|
|
|
270
497
|
```typescript
|
|
271
|
-
// entry-server.ts
|
|
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
|
+
*/
|
|
272
506
|
import { Component, h } from "@weftui/core";
|
|
273
507
|
import { Router } from "@weftui/router";
|
|
274
508
|
import { RouterServer } from "@weftui/router/server";
|
|
@@ -287,14 +521,16 @@ const documentShell = Component.gen(function* () {
|
|
|
287
521
|
});
|
|
288
522
|
|
|
289
523
|
// { html, status }: `<!DOCTYPE html>` is prepended for you.
|
|
290
|
-
export const render = (url: string) =>
|
|
524
|
+
export const render = (url: string): Promise<{ html: string; status: number }> =>
|
|
291
525
|
Effect.runPromise(RouterServer.render(App, { document: documentShell, url }));
|
|
292
526
|
|
|
293
|
-
//
|
|
527
|
+
// A Web fetch-style handler, ready to bridge into Vite or any Web server.
|
|
294
528
|
export const handler = RouterServer.toWebHandler(App, { document: documentShell });
|
|
295
529
|
```
|
|
296
530
|
|
|
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.
|
|
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.
|
|
298
534
|
|
|
299
535
|
### `effect/unstable/httpapi` is the spine
|
|
300
536
|
|
|
@@ -305,27 +541,68 @@ The result is a single `"pages"` group with one GET endpoint per leaf at its ful
|
|
|
305
541
|
- **Server**: `RouterServer` dispatches through `HttpApiBuilder` (platform owns request→leaf matching, path/query decode, and the 404 status).
|
|
306
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.
|
|
307
543
|
|
|
544
|
+
```typescript
|
|
545
|
+
import { Option } from "effect";
|
|
546
|
+
|
|
547
|
+
App.httpApi; // HttpApi.Top: one "pages" group, a GET endpoint per leaf
|
|
548
|
+
|
|
549
|
+
const { httpApiClient } = yield * Router;
|
|
550
|
+
Option.isSome(httpApiClient); // true under RouterLive, false under RouterServer
|
|
551
|
+
```
|
|
552
|
+
|
|
308
553
|
## Errors
|
|
309
554
|
|
|
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(
|
|
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(…)` |
|
|
314
559
|
|
|
315
560
|
Both are modeled as `Schema.TaggedErrorClass`, so they encode/decode across the wire the same way `Boundary.rpc` replays typed failures.
|
|
316
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
|
+
|
|
317
584
|
## `Boundary.rpc` interplay
|
|
318
585
|
|
|
319
586
|
Initial SSR navigation works end to end: the server resolves the rpc and inlines its payload, and the client replays it during `hydrate`.
|
|
320
587
|
|
|
321
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.
|
|
322
589
|
|
|
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.
|
|
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
|
+
```
|
|
324
600
|
|
|
325
601
|
## See also
|
|
326
602
|
|
|
327
603
|
- [`@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
|
|
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`
|
|
329
606
|
- [Component Authoring](https://weftui.dev/docs/how-to/author-components): `Component.make` / `Component.gen`, the idiomatic way to write route components
|
|
330
607
|
- [Server-Side Rendering](https://weftui.dev/docs/how-to/render-on-the-server): `renderToStringHydratable`, `hydrate`, and `Boundary.rpc`
|
|
331
608
|
- [RPC Data Boundaries](https://weftui.dev/docs/how-to/load-data-with-rpc): `Boundary.rpc`, the `Resource` handle, and the four lifecycles
|
|
@@ -193,13 +193,13 @@ When a caller passes a plain string, the component's node type has `never` for t
|
|
|
193
193
|
Splicing a `Source` straight into `h` (`[props.label]`) is enough when you only place it in the tree. When the body needs to **read or derive** from the value (combine two props, feed a stream operator, drive logic), normalize it first with [`Source.toSubscribable`](https://weftui.dev/docs/reference/core#sourcetosubscribablesource-key). It turns any `Source<A>` into an await-first, hot `Subscribable<A>`:
|
|
194
194
|
|
|
195
195
|
```typescript
|
|
196
|
-
import { Component, h, Source } from "@weftui/core";
|
|
196
|
+
import { Component, h, Source, Subscribable } from "@weftui/core";
|
|
197
197
|
import { Stream } from "effect";
|
|
198
198
|
|
|
199
199
|
const LoudLabel = Component.gen(function* (props: { label: Source.Source<string> }) {
|
|
200
200
|
const label = yield* Source.toSubscribable(props.label); // Subscribable<string>
|
|
201
201
|
// Now derive from it like any Subscribable: static, Effect, and Stream inputs all work.
|
|
202
|
-
return yield* h.strong([Stream.map(
|
|
202
|
+
return yield* h.strong([Stream.map(Subscribable.changes(label), (text) => text.toUpperCase())]);
|
|
203
203
|
});
|
|
204
204
|
```
|
|
205
205
|
|
|
@@ -14,7 +14,7 @@ description: Boundary.rpc, server-resolved and client-refreshable data; the cont
|
|
|
14
14
|
A `Boundary.rpc` is a **thin consumer**. It carries an rpc, a payload thunk, and a `render` that receives a reactive [`Resource`](https://weftui.dev/docs/reference/core#resourcea). The renderer resolves the rpc through the ambient [`AppRpcClientTag`](https://weftui.dev/docs/reference/core#apprpcclienttag) seam (provided by `@weftui/router`). The same rpc serves every lifecycle, so SSR-replay, refetch, and client-first mount are one mechanism, not three.
|
|
15
15
|
|
|
16
16
|
```typescript
|
|
17
|
-
import { Boundary, h } from "@weftui/core";
|
|
17
|
+
import { Boundary, h, Subscribable } from "@weftui/core";
|
|
18
18
|
import { Stream } from "effect";
|
|
19
19
|
import { GetStock } from "./data/inventory";
|
|
20
20
|
|
|
@@ -26,7 +26,7 @@ Boundary.rpc(
|
|
|
26
26
|
) =>
|
|
27
27
|
h.p([
|
|
28
28
|
"in stock: ",
|
|
29
|
-
h.span([Stream.map(resource.value
|
|
29
|
+
h.span([Stream.map(Subscribable.changes(resource.value), (s) => String(s.units))]),
|
|
30
30
|
h.button({ type: "button", onclick: () => resource.refetch }, "Refresh"),
|
|
31
31
|
]),
|
|
32
32
|
{ fallback: h.p("loading stock…") }, // shown only on a client-first mount
|
|
@@ -127,8 +127,8 @@ Because the SSR path seeds `value` await-first (it emits the seed immediately),
|
|
|
127
127
|
```typescript
|
|
128
128
|
(resource) =>
|
|
129
129
|
h.section({ class: "product" }, [
|
|
130
|
-
h.span([Stream.map(resource.value
|
|
131
|
-
h.span([Stream.map(resource.pending
|
|
130
|
+
h.span([Stream.map(Subscribable.changes(resource.value), (s) => String(s.units))]),
|
|
131
|
+
h.span([Stream.map(Subscribable.changes(resource.pending), (p) => (p ? "refreshing…" : ""))]),
|
|
132
132
|
h.button({ type: "button", onclick: () => resource.refetch }, "Refresh stock"),
|
|
133
133
|
]);
|
|
134
134
|
```
|
|
@@ -12,14 +12,14 @@ description: Render a reactive collection with List.each so reordering, insertin
|
|
|
12
12
|
Use [`List.each`](https://weftui.dev/docs/reference/core#listeach), the keyed-list combinator. It renders each item **once per key** and reconciles across emissions. A reorder _moves_ existing DOM nodes, an insert adds one, a remove drops one, and untouched rows are left entirely alone.
|
|
13
13
|
|
|
14
14
|
```typescript
|
|
15
|
-
import { h, List } from "@weftui/core";
|
|
15
|
+
import { h, List, Subscribable } from "@weftui/core";
|
|
16
16
|
import { Stream } from "effect";
|
|
17
17
|
|
|
18
18
|
declare const rows: Subscribable.Subscribable<ReadonlyArray<{ id: number; name: string }>>;
|
|
19
19
|
|
|
20
20
|
h.ul([
|
|
21
21
|
List.each(
|
|
22
|
-
{ of:
|
|
22
|
+
{ of: Subscribable.changes(rows), by: (row) => row.id }, // key by stable identity
|
|
23
23
|
(row) => h.li(row.name),
|
|
24
24
|
),
|
|
25
25
|
]);
|
|
@@ -30,7 +30,7 @@ h.ul([
|
|
|
30
30
|
|
|
31
31
|
## Why not `map`?
|
|
32
32
|
|
|
33
|
-
Mapping items by hand (`Stream.map(
|
|
33
|
+
Mapping items by hand (`Stream.map(Subscribable.changes(rows), (rs) => rs.map(r => h.li(r.name)))`) produces a **new children array on every emission**. The renderer then rebuilds the whole region: every row's DOM node is recreated even if only one item moved.
|
|
34
34
|
|
|
35
35
|
`List.each` reconciles by key instead, so DOM identity (and the focus/scroll/typed-input state attached to it) survives across updates.
|
|
36
36
|
|
|
@@ -39,8 +39,8 @@ Mapping items by hand (`Stream.map(rows.changes, (rs) => rs.map(r => h.li(r.name
|
|
|
39
39
|
Because `render` runs **exactly once per key**, reconciliation never re-runs it for a kept row, so it never refreshes that row's content on its own. To make a row's content reactive, thread a `Stream` **inside** the row rather than expecting a re-render:
|
|
40
40
|
|
|
41
41
|
```typescript
|
|
42
|
-
List.each({ of:
|
|
43
|
-
h.li([h.span([Stream.map(row.status
|
|
42
|
+
List.each({ of: Subscribable.changes(rows), by: (row) => row.id }, (row) =>
|
|
43
|
+
h.li([h.span([Stream.map(Subscribable.changes(row.status), (s) => s)])]),
|
|
44
44
|
);
|
|
45
45
|
```
|
|
46
46
|
|
|
@@ -54,7 +54,7 @@ Use a hydratable renderer whenever the client will call `hydrate`. The plain ren
|
|
|
54
54
|
It follows the same server/client split: the rpc **contract** (pure Schema) is shared, while its **handler** lives in a server-only Layer the client never imports.
|
|
55
55
|
|
|
56
56
|
```typescript
|
|
57
|
-
import { Boundary, h } from "@weftui/core";
|
|
57
|
+
import { Boundary, h, Subscribable } from "@weftui/core";
|
|
58
58
|
import { Stream } from "effect";
|
|
59
59
|
import { GetStock } from "./data/inventory";
|
|
60
60
|
|
|
@@ -65,7 +65,7 @@ const StockPanel = (productId: number) =>
|
|
|
65
65
|
(resource) =>
|
|
66
66
|
h.p([
|
|
67
67
|
"in stock: ",
|
|
68
|
-
h.span([Stream.map(resource.value
|
|
68
|
+
h.span([Stream.map(Subscribable.changes(resource.value), (stock) => String(stock.units))]),
|
|
69
69
|
h.button({ type: "button", onclick: () => resource.refetch }, "Refresh"),
|
|
70
70
|
]),
|
|
71
71
|
{ fallback: h.p("loading stock…") }, // shown only on a client-first SPA mount
|
|
@@ -14,7 +14,7 @@ When you navigate to a route, the router is **deferred-commit**. It resolves the
|
|
|
14
14
|
That resolve window is exposed as a reactive signal, [`Router.navigating`](https://weftui.dev/docs/reference/router#routernavigating), that you read to render pending UI.
|
|
15
15
|
|
|
16
16
|
```typescript
|
|
17
|
-
import { Component, h } from "@weftui/core";
|
|
17
|
+
import { Component, h, Subscribable } from "@weftui/core";
|
|
18
18
|
import { Router } from "@weftui/router";
|
|
19
19
|
import { Stream } from "effect";
|
|
20
20
|
|
|
@@ -25,7 +25,7 @@ const Shell = Component.gen(function* () {
|
|
|
25
25
|
h.div({
|
|
26
26
|
id: "nav-progress",
|
|
27
27
|
"aria-hidden": "true",
|
|
28
|
-
class: Stream.map(
|
|
28
|
+
class: Stream.map(Subscribable.changes(nav), (s) =>
|
|
29
29
|
s._tag === "Navigating" ? "nav-progress is-navigating" : "nav-progress",
|
|
30
30
|
),
|
|
31
31
|
}),
|
package/docs/reference/core.md
CHANGED
|
@@ -358,9 +358,9 @@ The keyed-list combinator. It is the opt-in alternative to wholesale child rebui
|
|
|
358
358
|
> elements.
|
|
359
359
|
|
|
360
360
|
```typescript
|
|
361
|
-
import { h, List } from "@weftui/core";
|
|
361
|
+
import { h, List, Subscribable } from "@weftui/core";
|
|
362
362
|
|
|
363
|
-
h.ul([List.each({ of:
|
|
363
|
+
h.ul([List.each({ of: Subscribable.changes(rows), by: (row) => row.id }, (row) => h.li(row.name))]);
|
|
364
364
|
```
|
|
365
365
|
|
|
366
366
|
#### `List.each`
|
package/docs/reference/router.md
CHANGED
|
@@ -111,7 +111,7 @@ Router.params<F extends Fields>(fields: F): Effect<FieldsType<F>, RouterParamsEr
|
|
|
111
111
|
Router.query<F extends Fields>(fields: F): Effect<FieldsType<F>, RouterParamsError, Router>;
|
|
112
112
|
```
|
|
113
113
|
|
|
114
|
-
Snapshot accessors that read the **live match** (`
|
|
114
|
+
Snapshot accessors that read the **live match** (`Subscribable.get(currentMatch)`) and pick the requested `fields` keys from the decoded path/query. The matcher already decoded the values against the leaf's full schema, so they are returned directly (no re-validation).
|
|
115
115
|
|
|
116
116
|
Readable from **any** component, not just the leaf: this is the dependency-injection path layouts and deep nodes use (leaves can instead take [handler-arg props](#routerroute)). They fail with a [`RouterParamsError`](#routerparamserror) (`source: "path" | "query"`, plus the requested `keys`) when no route matches.
|
|
117
117
|
|
|
@@ -122,7 +122,7 @@ Router.paramsStream<F extends Fields>(fields: F): Effect<Subscribable<FieldsType
|
|
|
122
122
|
Router.queryStream<F extends Fields>(fields: F): Effect<Subscribable<FieldsType<F>>, never, Router>;
|
|
123
123
|
```
|
|
124
124
|
|
|
125
|
-
The **reactive** counterparts. Each resolves a `Subscribable<FieldsType<F>>` derived from `
|
|
125
|
+
The **reactive** counterparts. Each resolves a `Subscribable<FieldsType<F>>` derived from `Subscribable.changes(currentMatch)`. A component can render `[Subscribable.changes(yield* Router.queryStream(fields))]` and update **in place** even when the outlet keeps the same leaf mounted. That is exactly the query-only case (`setQuery` / `patchQuery`) a snapshot `Router.query` would miss.
|
|
126
126
|
|
|
127
127
|
Resilient across navigations: a `NotFound` match yields the empty subset rather than failing, so the stream stays live.
|
|
128
128
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@weftui/router",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.30.0",
|
|
4
4
|
"description": "Universal nested router for Weft: one route tree, server and client, with type-safe params and href",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"effect",
|
|
@@ -44,8 +44,8 @@
|
|
|
44
44
|
"access": "public"
|
|
45
45
|
},
|
|
46
46
|
"dependencies": {
|
|
47
|
-
"@weftui/core": "0.
|
|
48
|
-
"@weftui/dom": "0.
|
|
47
|
+
"@weftui/core": "0.30.0",
|
|
48
|
+
"@weftui/dom": "0.30.0"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
51
|
"@types/jsdom": "^28.0.3",
|