@weftui/core 0.27.1 → 0.29.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 +11 -10
- package/dist/{index-DMNuAQXj.d.ts → index-4cTlhojA.d.ts} +47 -35
- package/dist/index.d.ts +77 -135
- package/dist/types/index.d.ts +1 -1
- package/docs/explanation/boundaries-and-suspense.md +37 -18
- package/docs/explanation/combinator-api.md +14 -12
- package/docs/explanation/reactive-primitives.md +15 -15
- package/docs/explanation/rendering-model.md +20 -18
- package/docs/explanation/services-and-context.md +39 -29
- package/docs/how-to/add-routing.md +76 -52
- package/docs/how-to/author-components.md +46 -32
- 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 -30
- package/docs/how-to/provide-services.md +54 -74
- package/docs/how-to/render-keyed-lists.md +10 -8
- package/docs/how-to/render-on-the-server.md +19 -14
- package/docs/how-to/show-navigation-progress.md +10 -8
- 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 +44 -40
- package/docs/reference/dom.md +395 -58
- package/docs/reference/router.md +71 -49
- package/docs/tutorial/01-your-first-app.md +10 -11
- package/docs/tutorial/02-reactivity.md +11 -8
- package/docs/tutorial/03-services-and-async.md +19 -13
- package/docs/tutorial/04-errors-and-server.md +17 -7
- package/package.json +8 -7
|
@@ -2,15 +2,15 @@
|
|
|
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 the matched nested page to hydratable HTML, and responds with `text/html` (HTTP 404 for not-found).
|
|
13
|
+
- **Client**: matches `window.location`, swaps pages reactively via the History API, and keeps unchanged ancestor layouts mounted across navigations.
|
|
14
14
|
|
|
15
15
|
The package mirrors `@weftui/dom`: a shared (universal) root, a `./client` entry, and a `./server` entry.
|
|
16
16
|
|
|
@@ -20,21 +20,21 @@ 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 on whichever side the request arrives. Server-resolved data stays with [`Boundary.rpc`](https://weftui.dev/docs/how-to/load-data-with-rpc); client-side async stays with `Boundary.suspend`.
|
|
24
24
|
|
|
25
|
-
You author an **explicit nested route tree** with three namespaced combinators
|
|
25
|
+
You author an **explicit nested route tree** with three namespaced combinators (mirroring the `h.div` / `Component.gen` / `Boundary.catchTag` surface) and seal it once:
|
|
26
26
|
|
|
27
|
-
| Combinator | Builds
|
|
28
|
-
| ----------------------------------------------------- |
|
|
29
|
-
| `Router.route(segment, { path?, query?, component })` | A leaf page.
|
|
30
|
-
| `Router.layout({ component }, children)` | A layout that wraps an outlet (purely UI nesting
|
|
31
|
-
| `Router.router(root, { notFound })` | Seals the tree into a `RouterDef`.
|
|
27
|
+
| Combinator | Builds |
|
|
28
|
+
| ----------------------------------------------------- | ---------------------------------------------------------------- |
|
|
29
|
+
| `Router.route(segment, { path?, query?, component })` | A leaf page. |
|
|
30
|
+
| `Router.layout({ component }, children)` | A layout that wraps an outlet (purely UI nesting; owns no path). |
|
|
31
|
+
| `Router.router(root, { notFound })` | Seals the tree into a `RouterDef`. |
|
|
32
32
|
|
|
33
33
|
The tree is the source of truth. The same sealed `RouterDef` drives both server and client.
|
|
34
34
|
|
|
35
35
|
## Authoring routes
|
|
36
36
|
|
|
37
|
-
Every `component` slot is a **`ComponentSlot
|
|
37
|
+
Every `component` slot is a **`ComponentSlot`**: a callable producing a `Node`, passed **uncalled**. 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
38
|
|
|
39
39
|
```typescript
|
|
40
40
|
import { Component, h } from "@weftui/core";
|
|
@@ -57,15 +57,15 @@ const User = Router.route("users/:id", {
|
|
|
57
57
|
- **`segment`** is relative to the parent and may contain `:name` path-param placeholders (e.g. `"users/:id"`). A leading/trailing `/` is tolerated. Each leaf carries its full relative path (e.g. `"users/:id/settings"`).
|
|
58
58
|
- **`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
59
|
|
|
60
|
-
> Authoring components with `Component.make` / `Component.gen` keeps every slot fully typed: the router never sees a `Node<any, any
|
|
60
|
+
> Authoring components with `Component.make` / `Component.gen` keeps every slot fully typed: the router never sees a `Node<any, any>`. Each component's `E`/`R` channels aggregate up through `Router.layout` / `Router.router` into the sealed `RouterDef`.
|
|
61
61
|
|
|
62
62
|
## Reading the match: handler-arg props vs. injection
|
|
63
63
|
|
|
64
|
-
A leaf page reads the current match's decoded `path` / `query` in
|
|
64
|
+
A leaf page reads the current match's decoded `path` / `query` in either of two forms.
|
|
65
65
|
|
|
66
66
|
### Handler-arg props (leaf pages)
|
|
67
67
|
|
|
68
|
-
The router passes the decoded `{ path, query }` straight into a leaf `component` as props
|
|
68
|
+
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
69
|
|
|
70
70
|
```typescript
|
|
71
71
|
const idParam = { id: Schema.NumberFromString };
|
|
@@ -80,7 +80,7 @@ Router.route("users/:id/posts", {
|
|
|
80
80
|
});
|
|
81
81
|
```
|
|
82
82
|
|
|
83
|
-
This is the most direct form for a leaf. A plain zero-arg thunk works too
|
|
83
|
+
This is the most direct form for a leaf. A plain zero-arg thunk works too; it just ignores the props.
|
|
84
84
|
|
|
85
85
|
### Dependency injection (layouts and deep nodes)
|
|
86
86
|
|
|
@@ -95,13 +95,15 @@ const UserShell = Component.gen(function* () {
|
|
|
95
95
|
});
|
|
96
96
|
```
|
|
97
97
|
|
|
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,
|
|
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. When no route matches, they fail with a tagged [`RouterParamsError`](#errors) carrying `source: "path" | "query"` and the requested `keys`.
|
|
99
99
|
|
|
100
|
-
|
|
100
|
+
That error bubbles into the app node's aggregate `E`, so a user may recover it with `Boundary.catchTag("RouterParamsError", …)`.
|
|
101
|
+
|
|
102
|
+
> **Reactive accessors.** `Router.paramsStream(fields)` / `Router.queryStream(fields)` are the reactive counterparts. Each resolves a `Subscribable` derived from `currentMatch.changes`. A component can render `[(yield* Router.queryStream(sortQuery)).changes]` and update **in place** even when the same leaf stays mounted (the query-only case `Router.query` would miss). See [Programmatic navigation](#programmatic-navigation).
|
|
101
103
|
|
|
102
104
|
## Layouts and the outlet
|
|
103
105
|
|
|
104
|
-
A **layout** wraps the next level down
|
|
106
|
+
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
107
|
|
|
106
108
|
```typescript
|
|
107
109
|
const UserShell = Component.gen(function* () {
|
|
@@ -113,13 +115,13 @@ const UserShell = Component.gen(function* () {
|
|
|
113
115
|
Router.layout({ component: UserShell }, [settingsRoute, postsRoute]);
|
|
114
116
|
```
|
|
115
117
|
|
|
116
|
-
`Router.Outlet` is typed **opaque** (`Node<never, never>`), so splicing it adds nothing to the layout's own channels
|
|
118
|
+
`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
119
|
|
|
118
|
-
A layout owns **no `segment` or `path
|
|
120
|
+
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
121
|
|
|
120
122
|
### Layout persistence
|
|
121
123
|
|
|
122
|
-
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
|
|
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. Its DOM identity and any local state (a `SubscriptionRef`, a scroll position) survive while only the inner outlet swaps.
|
|
123
125
|
|
|
124
126
|
## Sealing the tree
|
|
125
127
|
|
|
@@ -131,11 +133,11 @@ export const App = Router.router(
|
|
|
131
133
|
homeRoute,
|
|
132
134
|
Router.layout({ component: UserShell }, [settingsRoute, postsRoute]),
|
|
133
135
|
]),
|
|
134
|
-
{ notFound: () => h.section({ id: "page" }, [h.h2("404
|
|
136
|
+
{ notFound: () => h.section({ id: "page" }, [h.h2("404: page not found")]) },
|
|
135
137
|
);
|
|
136
138
|
```
|
|
137
139
|
|
|
138
|
-
`App` is a `RouterDef` whose phantom `E`/`R` carry the aggregate channels of the whole tree (plus the not-found page)
|
|
140
|
+
`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
141
|
|
|
140
142
|
## Type-safe links with `href`
|
|
141
143
|
|
|
@@ -152,11 +154,13 @@ const Home = Component.make(() =>
|
|
|
152
154
|
);
|
|
153
155
|
```
|
|
154
156
|
|
|
155
|
-
Path params encode into the pattern (`/users/:id` + `{ id: 42 }` ⇒ `/users/42`)
|
|
157
|
+
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.
|
|
158
|
+
|
|
159
|
+
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
160
|
|
|
157
161
|
## Not-found
|
|
158
162
|
|
|
159
|
-
`notFound(path?)` short-circuits the current render with a `RouterNotFound` failure. Callable from any page or layout
|
|
163
|
+
`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
164
|
|
|
161
165
|
```typescript
|
|
162
166
|
import { notFound, Router } from "@weftui/router";
|
|
@@ -171,31 +175,41 @@ Router.route("users/:id", {
|
|
|
171
175
|
});
|
|
172
176
|
```
|
|
173
177
|
|
|
174
|
-
`RouterNotFound` is exported, so a `Boundary.catchTag("RouterNotFound", …)` placed inside a subtree overrides the app-level fallback for that subtree
|
|
178
|
+
`RouterNotFound` is exported, so a `Boundary.catchTag("RouterNotFound", …)` 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
179
|
|
|
176
|
-
> **`Schema.NumberFromString` gotcha.** Decoding no longer fails on a non-numeric segment
|
|
180
|
+
> **`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
181
|
|
|
178
182
|
## Client setup
|
|
179
183
|
|
|
180
|
-
On the client, provide the `Router` via `RouterLive(def)` and render `RouterApp(def)`. `RouterLive` is a **scoped layer
|
|
184
|
+
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
|
+
|
|
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.
|
|
181
187
|
|
|
182
188
|
```typescript
|
|
183
189
|
// entry-client.ts
|
|
184
|
-
import {
|
|
190
|
+
import { WeftApp } from "@weftui/dom/client";
|
|
185
191
|
import { RouterApp, RouterLive } from "@weftui/router/client";
|
|
186
|
-
import {
|
|
192
|
+
import { Effect } from "effect";
|
|
187
193
|
import { App } from "./app";
|
|
188
194
|
|
|
189
195
|
const root = document.getElementById("root")!;
|
|
190
|
-
const
|
|
191
|
-
void
|
|
196
|
+
const app = WeftApp.make(RouterLive(App));
|
|
197
|
+
void Effect.runPromise(WeftApp.hydrate(app, RouterApp(App), root));
|
|
192
198
|
```
|
|
193
199
|
|
|
194
|
-
For a client-only app (no SSR), swap `hydrate` for `mount
|
|
200
|
+
For a client-only app (no SSR), swap `WeftApp.hydrate` for `WeftApp.mount`; everything else is identical.
|
|
195
201
|
|
|
196
202
|
### Link interception
|
|
197
203
|
|
|
198
|
-
A plain `h.a({ href })` to a same-origin, route-matching URL performs SPA navigation when clicked
|
|
204
|
+
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:
|
|
205
|
+
|
|
206
|
+
- modified clicks (ctrl/meta/shift/alt or non-left button)
|
|
207
|
+
- `target=_blank` and `download`
|
|
208
|
+
- external origins
|
|
209
|
+
- same-document (hash-only) navigations
|
|
210
|
+
- hrefs that don't resolve to a route
|
|
211
|
+
|
|
212
|
+
You don't wire anything up. `RouterLive` installs the delegated listener for the layer's lifetime and removes it on teardown.
|
|
199
213
|
|
|
200
214
|
## Programmatic navigation
|
|
201
215
|
|
|
@@ -230,24 +244,28 @@ yield * setQuery({ sort: "old" }); // replaces the query
|
|
|
230
244
|
yield * patchQuery({ sort: "old" }); // merges into the current query
|
|
231
245
|
```
|
|
232
246
|
|
|
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
|
|
247
|
+
- **`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`.
|
|
248
|
+
- **`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
249
|
|
|
236
250
|
### Scroll position on navigation
|
|
237
251
|
|
|
238
|
-
A client navigation whose **path** changes resets the window scroll to the top at commit
|
|
252
|
+
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
253
|
|
|
240
|
-
- **Query-only navigations preserve scroll.** `setQuery` / `patchQuery` (and any navigation that keeps the same path) don't reset
|
|
254
|
+
- **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
255
|
- **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
|
|
256
|
+
- **Hash navigation (`#section`) is unaffected.** It's browser-native, and the link interceptor already lets same-document/hash-only clicks fall through.
|
|
243
257
|
|
|
244
258
|
There's no opt-out; the behavior is hardwired.
|
|
245
259
|
|
|
246
260
|
## Server setup
|
|
247
261
|
|
|
248
|
-
On the server, `RouterServer
|
|
262
|
+
On the server, `RouterServer`:
|
|
263
|
+
|
|
264
|
+
- matches a request URL and builds a fixed-match `Router`
|
|
265
|
+
- renders `RouterApp` to hydratable HTML inside a **document shell**
|
|
266
|
+
- reports a status (404 when no route matches or a page raises `RouterNotFound`)
|
|
249
267
|
|
|
250
|
-
The document shell is itself a `ComponentSlot` that splices the app via `yield* Router.Outlet
|
|
268
|
+
The document shell is itself a `ComponentSlot` that splices the app via `yield* Router.Outlet`, exactly like a layout receives its outlet:
|
|
251
269
|
|
|
252
270
|
```typescript
|
|
253
271
|
// entry-server.ts
|
|
@@ -268,7 +286,7 @@ const documentShell = Component.gen(function* () {
|
|
|
268
286
|
]);
|
|
269
287
|
});
|
|
270
288
|
|
|
271
|
-
// { html, status }
|
|
289
|
+
// { html, status }: `<!DOCTYPE html>` is prepended for you.
|
|
272
290
|
export const render = (url: string) =>
|
|
273
291
|
Effect.runPromise(RouterServer.render(App, { document: documentShell, url }));
|
|
274
292
|
|
|
@@ -276,14 +294,16 @@ export const render = (url: string) =>
|
|
|
276
294
|
export const handler = RouterServer.toWebHandler(App, { document: documentShell });
|
|
277
295
|
```
|
|
278
296
|
|
|
279
|
-
`render` provides both `Router.Outlet` (the app, per request) and `Router` (so the shell may read params)
|
|
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.
|
|
280
298
|
|
|
281
299
|
### `effect/unstable/httpapi` is the spine
|
|
282
300
|
|
|
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
|
|
301
|
+
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`.
|
|
302
|
+
|
|
303
|
+
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:
|
|
284
304
|
|
|
285
|
-
- **Server
|
|
286
|
-
- **Client
|
|
305
|
+
- **Server**: `RouterServer` dispatches through `HttpApiBuilder` (platform owns request→leaf matching, path/query decode, and the 404 status).
|
|
306
|
+
- **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.
|
|
287
307
|
|
|
288
308
|
## Errors
|
|
289
309
|
|
|
@@ -296,13 +316,17 @@ Both are modeled as `Schema.TaggedErrorClass`, so they encode/decode across the
|
|
|
296
316
|
|
|
297
317
|
## `Boundary.rpc` interplay
|
|
298
318
|
|
|
299
|
-
Initial SSR navigation works end to end: the server resolves the rpc and inlines its payload, and the client replays it during `hydrate`.
|
|
319
|
+
Initial SSR navigation works end to end: the server resolves the rpc and inlines its payload, and the client replays it during `hydrate`.
|
|
320
|
+
|
|
321
|
+
**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
|
+
|
|
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. See the [rpc data boundaries guide](https://weftui.dev/docs/how-to/load-data-with-rpc).
|
|
300
324
|
|
|
301
325
|
## See also
|
|
302
326
|
|
|
303
327
|
- [`@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
|
-
- [Component Authoring](https://weftui.dev/docs/how-to/author-components)
|
|
306
|
-
- [Server-Side Rendering](https://weftui.dev/docs/how-to/render-on-the-server)
|
|
307
|
-
- [RPC Data Boundaries](https://weftui.dev/docs/how-to/load-data-with-rpc)
|
|
308
|
-
- [`packages/router/router.specs.md`](https://github.com/stefvw93/weft/blob/main/packages/router/router.specs.md)
|
|
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
|
|
329
|
+
- [Component Authoring](https://weftui.dev/docs/how-to/author-components): `Component.make` / `Component.gen`, the idiomatic way to write route components
|
|
330
|
+
- [Server-Side Rendering](https://weftui.dev/docs/how-to/render-on-the-server): `renderToStringHydratable`, `hydrate`, and `Boundary.rpc`
|
|
331
|
+
- [RPC Data Boundaries](https://weftui.dev/docs/how-to/load-data-with-rpc): `Boundary.rpc`, the `Resource` handle, and the four lifecycles
|
|
332
|
+
- [`packages/router/router.specs.md`](https://github.com/stefvw93/weft/blob/main/packages/router/router.specs.md): the full specification
|
|
@@ -32,7 +32,7 @@ Use a plain function when:
|
|
|
32
32
|
|
|
33
33
|
## Components with internal state
|
|
34
34
|
|
|
35
|
-
When a component needs reactive state, use `Effect.gen` to set it up before building the tree. The component function still runs once
|
|
35
|
+
When a component needs reactive state, use `Effect.gen` to set it up before building the tree. The component function still runs once. The setup happens at mount time:
|
|
36
36
|
|
|
37
37
|
```typescript
|
|
38
38
|
import { h } from "@weftui/core";
|
|
@@ -49,25 +49,28 @@ const Counter = () =>
|
|
|
49
49
|
});
|
|
50
50
|
```
|
|
51
51
|
|
|
52
|
-
The return type here is `Effect.Effect<Node, never, never
|
|
52
|
+
The return type here is `Effect.Effect<Node, never, never>`, itself a valid `Node`, so it composes naturally with other tree-building calls.
|
|
53
|
+
|
|
54
|
+
As soon as such a component is reused or takes props, wrap the same generator in [`Component.gen`](#componentgen--componentmake-for-reusable-components) (below). That way the caller's reactive prop and children channels flow into its node type.
|
|
53
55
|
|
|
54
56
|
## Component scope and background effects
|
|
55
57
|
|
|
56
|
-
Every component instance is rendered under its own **instance scope
|
|
58
|
+
Every component instance is rendered under its own **instance scope**, a child of the
|
|
57
59
|
mount scope created fresh for that instance. Anything bound to the instance scope lives
|
|
58
|
-
exactly as long as the component is mounted
|
|
59
|
-
component unmounts (or when
|
|
60
|
-
|
|
61
|
-
|
|
60
|
+
exactly as long as the component is mounted. It is torn down automatically when the
|
|
61
|
+
component unmounts (or when its root unmounts via `RootHandle.unmount()`).
|
|
62
|
+
|
|
63
|
+
The renderer provides this scope as the ambient `Scope.Scope` while it evaluates the
|
|
64
|
+
component body, so it is already in context when you need it.
|
|
62
65
|
|
|
63
|
-
This matters the moment a component starts **background work
|
|
66
|
+
This matters the moment a component starts **background work**: a subscription, an
|
|
64
67
|
observer of a `ref`, a polling timer, anything you `fork`. The rule:
|
|
65
68
|
|
|
66
69
|
> Fork background work with **`Effect.forkScoped`**, never a bare `Effect.forkChild`.
|
|
67
70
|
|
|
68
71
|
`Effect.forkScoped` attaches the fiber to the instance scope, so it keeps running for
|
|
69
72
|
the component's lifetime and is interrupted on unmount. A bare `Effect.forkChild` instead
|
|
70
|
-
attaches the fiber to the component-body fiber
|
|
73
|
+
attaches the fiber to the component-body fiber, the one that runs your `Effect.gen` to
|
|
71
74
|
produce the tree. That fiber completes the instant the gen returns its node, so the
|
|
72
75
|
forked work is cancelled almost immediately.
|
|
73
76
|
|
|
@@ -86,8 +89,8 @@ const AutoFocusInput = () =>
|
|
|
86
89
|
Stream.filter(Option.isSome),
|
|
87
90
|
Stream.take(1),
|
|
88
91
|
Stream.runForEach((el) => Effect.sync(() => el.value.focus())),
|
|
89
|
-
Effect.forkScoped, // ✅ tied to the instance scope
|
|
90
|
-
// Effect.forkChild, // ❌ tied to the body fiber
|
|
92
|
+
Effect.forkScoped, // ✅ tied to the instance scope: survives until unmount
|
|
93
|
+
// Effect.forkChild, // ❌ tied to the body fiber: interrupted when the gen returns
|
|
91
94
|
);
|
|
92
95
|
|
|
93
96
|
return yield* h.input({ ref: inputRef, type: "text" });
|
|
@@ -96,7 +99,7 @@ const AutoFocusInput = () =>
|
|
|
96
99
|
|
|
97
100
|
You do not manage the scope yourself: you do not create it, close it, or pass it
|
|
98
101
|
around. `forkScoped` reads it from context, and unmount closes it for you. If you ever
|
|
99
|
-
fork outside a component body (rare), you must supply a `Scope.Scope` yourself
|
|
102
|
+
fork outside a component body (rare), you must supply a `Scope.Scope` yourself. The
|
|
100
103
|
type system will tell you, because `forkScoped` carries a `Scope.Scope` requirement.
|
|
101
104
|
|
|
102
105
|
See `examples/element-ref` for the auto-focus, measure, and canvas recipes built on
|
|
@@ -106,8 +109,8 @@ this pattern.
|
|
|
106
109
|
|
|
107
110
|
When you want the caller's reactive prop types to flow into the returned node's type, use one of the `Component` factories. Both have the same call semantics; pick the body style that fits:
|
|
108
111
|
|
|
109
|
-
- **`Component.make
|
|
110
|
-
- **`Component.gen
|
|
112
|
+
- **`Component.make`**: body is a plain function returning any `Effect` (typically a `Node`). Use for one-liners and pipe compositions.
|
|
113
|
+
- **`Component.gen`**: body is a generator. Use when you need `yield*` to set up local state or pull from services.
|
|
111
114
|
|
|
112
115
|
```typescript
|
|
113
116
|
import { Component, h, Source } from "@weftui/core";
|
|
@@ -125,14 +128,16 @@ const Card = Component.make((props: CardProps) =>
|
|
|
125
128
|
);
|
|
126
129
|
```
|
|
127
130
|
|
|
128
|
-
`Source.Source<string>` is Weft's caller-facing prop vocabulary
|
|
131
|
+
`Source.Source<string>` is Weft's caller-facing prop vocabulary: a single type covering a static `string`, a `Stream<string>`, an `Effect<string>`, or a `Subscribable<string>`. You don't hand-write `string | Stream.Stream<string> | …` on every prop.
|
|
132
|
+
|
|
133
|
+
Passing a `Source` straight to `h` (as above) is all you need when the value is just spliced into the tree. The renderer normalizes it.
|
|
129
134
|
|
|
130
135
|
Now the caller's stream types are visible in the returned node:
|
|
131
136
|
|
|
132
137
|
```typescript
|
|
133
138
|
declare const titleStream: Stream.Stream<string, never, I18nService>;
|
|
134
139
|
|
|
135
|
-
// Node<never, I18nService
|
|
140
|
+
// Node<never, I18nService>: I18nService requirement flows out
|
|
136
141
|
const card = Card({ title: titleStream });
|
|
137
142
|
```
|
|
138
143
|
|
|
@@ -140,7 +145,7 @@ Without a `Component` factory, a plain function's return type is fixed at defini
|
|
|
140
145
|
|
|
141
146
|
### Body `E`/`R` inference
|
|
142
147
|
|
|
143
|
-
You don't declare the body's `E`/`R` channels explicitly
|
|
148
|
+
You don't declare the body's `E`/`R` channels explicitly. They're inferred from the returned (or yielded) effect:
|
|
144
149
|
|
|
145
150
|
- The body's `E`/`R` come from whatever effects appear inside.
|
|
146
151
|
- The caller's reactive prop channels and reactive children channels are unioned on top at the call site.
|
|
@@ -156,7 +161,7 @@ type Component.Children<Input = never> =
|
|
|
156
161
|
| ((input: Input) => readonly Renderable[]);
|
|
157
162
|
```
|
|
158
163
|
|
|
159
|
-
The function form is the render-prop / slot pattern
|
|
164
|
+
The function form is the render-prop / slot pattern. The component invokes the function with whatever input it chooses, and the returned array's `E`/`R` propagate out:
|
|
160
165
|
|
|
161
166
|
```typescript
|
|
162
167
|
const ItemList = Component.make(
|
|
@@ -169,7 +174,7 @@ ItemList({ items: ["a", "b"] }, (item) => [h.li(item)]);
|
|
|
169
174
|
|
|
170
175
|
## Props typing
|
|
171
176
|
|
|
172
|
-
For a prop that accepts both static and reactive values, type it as [`Source.Source<T>`](https://weftui.dev/docs/reference/core#source-namespace) rather than hand-writing the union. `Source.Source<T>` **is** that union
|
|
177
|
+
For a prop that accepts both static and reactive values, type it as [`Source.Source<T>`](https://weftui.dev/docs/reference/core#source-namespace) rather than hand-writing the union. `Source.Source<T>` **is** that union: `T | Stream<T> | Effect<T> | Subscribable<T>`. The caller can pass a plain value or any reactive shape interchangeably, and you write it once:
|
|
173
178
|
|
|
174
179
|
```typescript
|
|
175
180
|
import { Source } from "@weftui/core";
|
|
@@ -181,11 +186,11 @@ interface ButtonProps {
|
|
|
181
186
|
}
|
|
182
187
|
```
|
|
183
188
|
|
|
184
|
-
When a caller passes a plain string, the component's node type has `never` for that prop's channels. When they pass a `Stream.Stream<string, never, SomeService>`, `SomeService` appears in the `R` channel
|
|
189
|
+
When a caller passes a plain string, the component's node type has `never` for that prop's channels. When they pass a `Stream.Stream<string, never, SomeService>`, `SomeService` appears in the `R` channel. The extraction is exactly `Source.Success` / `Source.Error` / `Source.Context`.
|
|
185
190
|
|
|
186
191
|
### Reading a `Source` in the body
|
|
187
192
|
|
|
188
|
-
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
|
|
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>`:
|
|
189
194
|
|
|
190
195
|
```typescript
|
|
191
196
|
import { Component, h, Source } from "@weftui/core";
|
|
@@ -193,12 +198,19 @@ import { Stream } from "effect";
|
|
|
193
198
|
|
|
194
199
|
const LoudLabel = Component.gen(function* (props: { label: Source.Source<string> }) {
|
|
195
200
|
const label = yield* Source.toSubscribable(props.label); // Subscribable<string>
|
|
196
|
-
// Now derive from it like any Subscribable
|
|
201
|
+
// Now derive from it like any Subscribable: static, Effect, and Stream inputs all work.
|
|
197
202
|
return yield* h.strong([Stream.map(label.changes, (text) => text.toUpperCase())]);
|
|
198
203
|
});
|
|
199
204
|
```
|
|
200
205
|
|
|
201
|
-
`toSubscribable` is scoped:
|
|
206
|
+
`toSubscribable` is scoped:
|
|
207
|
+
|
|
208
|
+
- A `Stream` prop is pumped by a fiber that terminates with the component's instance scope.
|
|
209
|
+
- An `Effect` prop is memoized.
|
|
210
|
+
- An existing `Subscribable` is threaded through by reference.
|
|
211
|
+
- A static value emits once.
|
|
212
|
+
|
|
213
|
+
It is the same normalization the renderer applies to props internally. Reach for it whenever you need the value as a `Subscribable` instead of leaving it opaque.
|
|
202
214
|
|
|
203
215
|
## Composing components
|
|
204
216
|
|
|
@@ -231,16 +243,18 @@ const UserAvatar = Component.gen(function* (props: { userId: string }) {
|
|
|
231
243
|
return yield* h.img({ src: user.avatarUrl, alt: user.name });
|
|
232
244
|
});
|
|
233
245
|
|
|
234
|
-
// Node<never, UserService
|
|
246
|
+
// Node<never, UserService>: regardless of what the caller passes
|
|
235
247
|
const avatar = UserAvatar({ userId: "123" });
|
|
236
248
|
```
|
|
237
249
|
|
|
238
|
-
|
|
250
|
+
Give the service to the app layer:
|
|
239
251
|
|
|
240
252
|
```typescript
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
253
|
+
import { WeftApp } from "@weftui/dom/client";
|
|
254
|
+
import { Effect } from "effect";
|
|
255
|
+
|
|
256
|
+
const app = WeftApp.make(UserServiceLive);
|
|
257
|
+
void Effect.runPromise(WeftApp.mount(app, App(), document.getElementById("root")!));
|
|
244
258
|
```
|
|
245
259
|
|
|
246
260
|
## Returning fragments
|
|
@@ -258,7 +272,7 @@ const TableCells = ({ row }: { row: Row }) =>
|
|
|
258
272
|
|
|
259
273
|
## See also
|
|
260
274
|
|
|
261
|
-
- [The Combinator API](https://weftui.dev/docs/explanation/combinator-api)
|
|
262
|
-
- [Reactive Primitives](https://weftui.dev/docs/explanation/reactive-primitives)
|
|
263
|
-
- [Add Routing](https://weftui.dev/docs/how-to/add-routing)
|
|
264
|
-
- [`@weftui/core` reference](https://weftui.dev/docs/reference/core)
|
|
275
|
+
- [The Combinator API](https://weftui.dev/docs/explanation/combinator-api): `h`, `h.fragment`, and how `E`/`R` accumulate
|
|
276
|
+
- [Reactive Primitives](https://weftui.dev/docs/explanation/reactive-primitives): the `Source` vocabulary props accept
|
|
277
|
+
- [Add Routing](https://weftui.dev/docs/how-to/add-routing): route components are `Component` slots
|
|
278
|
+
- [`@weftui/core` reference](https://weftui.dev/docs/reference/core): `Component`, `Source`, and the full surface
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Compose Behavior and Markup
|
|
3
|
+
order: 13
|
|
4
|
+
section: how-to
|
|
5
|
+
description: "Use Props.merge to combine a behavior prop bag with your own markup: chained handlers, ref fan-out, and reactive classes on one element."
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Compose Behavior and Markup
|
|
9
|
+
|
|
10
|
+
**Goal:** share behavior (aria wiring, handlers, refs, reactive state) without
|
|
11
|
+
giving up ownership of the element it applies to.
|
|
12
|
+
|
|
13
|
+
## The problem
|
|
14
|
+
|
|
15
|
+
Behavior and markup usually want different owners. A dropdown's keyboard
|
|
16
|
+
handling, `aria-expanded` wiring and anchor ref are worth writing once. The
|
|
17
|
+
button itself is yours: your classes, your text, your extra handlers.
|
|
18
|
+
|
|
19
|
+
Object spread cannot combine them. `{ ...behavior, ...mine }` silently drops the
|
|
20
|
+
behavior's `onclick` when you supply your own, and drops its `ref` when you
|
|
21
|
+
supply yours. Nothing warns you.
|
|
22
|
+
|
|
23
|
+
`Props.merge` reconciles both bags instead.
|
|
24
|
+
|
|
25
|
+
## Behavior as a prop bag
|
|
26
|
+
|
|
27
|
+
A behavior primitive is a plain Effect that yields a prop bag. There is no
|
|
28
|
+
component wrapper and no hook rules, so you can `yield*` it anywhere and hold
|
|
29
|
+
the result.
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
import { Effect, Option, SubscriptionRef } from "effect";
|
|
33
|
+
|
|
34
|
+
const makeDisclosure = () =>
|
|
35
|
+
Effect.gen(function* () {
|
|
36
|
+
const isOpen = yield* SubscriptionRef.make(false);
|
|
37
|
+
const anchor = yield* SubscriptionRef.make(Option.none<HTMLElement>());
|
|
38
|
+
|
|
39
|
+
const trigger = {
|
|
40
|
+
ref: anchor,
|
|
41
|
+
"aria-expanded": SubscriptionRef.changes(isOpen),
|
|
42
|
+
onclick: () => SubscriptionRef.update(isOpen, (open) => !open),
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
return { isOpen, trigger };
|
|
46
|
+
});
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
`makeDisclosure` returns a plain object, not a `DomProps`-typed value. `merge`
|
|
50
|
+
accepts it as-is: it dispatches on each key's name, not on the bag's declared
|
|
51
|
+
type.
|
|
52
|
+
|
|
53
|
+
## Merge it onto your element
|
|
54
|
+
|
|
55
|
+
You write the element. The bag merges onto it.
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
import { h } from "@weftui/core";
|
|
59
|
+
import { Props } from "@weftui/dom";
|
|
60
|
+
|
|
61
|
+
const Panel = () =>
|
|
62
|
+
Effect.gen(function* () {
|
|
63
|
+
const disclosure = yield* makeDisclosure();
|
|
64
|
+
const measure = yield* SubscriptionRef.make(Option.none<HTMLElement>());
|
|
65
|
+
|
|
66
|
+
return yield* h.button(
|
|
67
|
+
Props.merge(disclosure.trigger, {
|
|
68
|
+
class: Props.cx("btn", { "btn--open": SubscriptionRef.changes(disclosure.isOpen) }),
|
|
69
|
+
onclick: (ev: MouseEvent) => trackClick(ev),
|
|
70
|
+
ref: measure,
|
|
71
|
+
}),
|
|
72
|
+
"Details",
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Three rules earn their keep in that one call:
|
|
78
|
+
|
|
79
|
+
1. **Handlers chain.** The disclosure toggles, then `trackClick` runs. Both
|
|
80
|
+
always run, and a failure in one does not prevent the other. A broken
|
|
81
|
+
analytics call cannot block the toggle.
|
|
82
|
+
2. **Refs fan out.** `anchor` and `measure` both receive the element. Spread
|
|
83
|
+
would have kept only one, silently.
|
|
84
|
+
3. **`cx` takes a reactive condition.** `btn--open` follows `isOpen`, and only
|
|
85
|
+
the class attribute updates.
|
|
86
|
+
|
|
87
|
+
Type an inline handler's event explicitly, as `(ev: MouseEvent)` above. `merge`
|
|
88
|
+
does not know which element the bag will land on, so it cannot infer it.
|
|
89
|
+
|
|
90
|
+
## Typed errors flow through
|
|
91
|
+
|
|
92
|
+
A handler that fails with a tagged error, or needs a service, keeps both
|
|
93
|
+
channels through the merge. They surface on the component's `Node<E, R>`, so the
|
|
94
|
+
app must provide the service and can catch the error at a boundary.
|
|
95
|
+
|
|
96
|
+
```ts
|
|
97
|
+
declare const rowBehavior: object;
|
|
98
|
+
declare const itemId: string;
|
|
99
|
+
|
|
100
|
+
const deleteItem = Effect.gen(function* () {
|
|
101
|
+
const files = yield* FileService;
|
|
102
|
+
yield* files.remove(itemId);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// The merged node requires FileService and can fail with whatever error
|
|
106
|
+
// `files.remove` declares. Both channels flow through `merge` untouched.
|
|
107
|
+
h.button(Props.merge(rowBehavior, { onclick: () => deleteItem }), "Delete");
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Two gotchas that differ from spread
|
|
111
|
+
|
|
112
|
+
- **`false` on a handler is an explicit opt-out and wins.** `null` and
|
|
113
|
+
`undefined` mean "not provided", so the other side survives instead.
|
|
114
|
+
- **Every other key is genuinely last-wins.** Forwarding an omitted optional
|
|
115
|
+
prop (`{ id: props.id }`) still overwrites a default with `undefined`, the
|
|
116
|
+
same as `{ ...base, ...override }` would. Guard at the call site if that
|
|
117
|
+
matters.
|
|
118
|
+
|
|
119
|
+
The [reference](https://weftui.dev/docs/reference/dom#propsmerge) has the full per-key rule
|
|
120
|
+
table, including the `style` and reactive-class cases this guide doesn't
|
|
121
|
+
cover.
|
|
122
|
+
|
|
123
|
+
## When to use
|
|
124
|
+
|
|
125
|
+
Reach for `Props.merge` when two parties contribute props to one element: a
|
|
126
|
+
shared behavior and a caller, or a base variant and a caller's override. For
|
|
127
|
+
a single bag you already control, write the object directly. Merge earns its
|
|
128
|
+
cost only when a key could collide.
|
|
129
|
+
|
|
130
|
+
`Props.merge` is pure: calling it has no side effects and subscribes
|
|
131
|
+
nothing. A merged `class` that turns out reactive is a `Stream` description.
|
|
132
|
+
The renderer subscribes it once the element mounts, the same as any other
|
|
133
|
+
reactive prop.
|
|
134
|
+
|
|
135
|
+
## See also
|
|
136
|
+
|
|
137
|
+
- [Headless Menu example](https://github.com/stefvw93/weft/tree/main/examples/headless-menu): a full behavior
|
|
138
|
+
primitive (`Menu.trigger`/`popup`/`item`) merged onto consumer-owned markup,
|
|
139
|
+
with handler chaining, ref fan-out, and a service requirement flowing
|
|
140
|
+
through the merge into `Node<E, R>`.
|
|
141
|
+
- [`@weftui/dom` reference](https://weftui.dev/docs/reference/dom): the full per-key rules and `cx` grammar
|
|
142
|
+
- [Use Element Refs](https://weftui.dev/docs/how-to/use-element-refs): the single-ref contract that fan-out builds on
|
|
143
|
+
- [Style Reactively](https://weftui.dev/docs/how-to/style-reactively): per-property style streams and `cx`
|
|
144
|
+
- [The Combinator API](https://weftui.dev/docs/explanation/combinator-api): why elements are plain data you always own
|