@weftui/core 0.26.0 → 0.26.2
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 +60 -0
- package/docs/explanation/boundaries-and-suspense.md +91 -0
- package/docs/explanation/combinator-api.md +164 -0
- package/docs/explanation/reactive-primitives.md +162 -0
- package/docs/explanation/rendering-model.md +65 -0
- package/docs/explanation/services-and-context.md +91 -0
- package/docs/how-to/add-routing.md +296 -0
- package/docs/how-to/author-components.md +264 -0
- package/docs/how-to/handle-forms.md +76 -0
- package/docs/how-to/load-async-data.md +70 -0
- package/docs/how-to/load-data-with-rpc.md +172 -0
- package/docs/how-to/provide-services.md +124 -0
- package/docs/how-to/render-keyed-lists.md +51 -0
- package/docs/how-to/render-on-the-server.md +86 -0
- package/docs/how-to/show-navigation-progress.md +65 -0
- package/docs/how-to/split-routes-lazily.md +62 -0
- package/docs/how-to/style-reactively.md +63 -0
- package/docs/how-to/use-element-refs.md +63 -0
- package/docs/index.md +59 -0
- package/docs/reference/core.md +496 -0
- package/docs/reference/dom.md +142 -0
- package/docs/reference/router.md +348 -0
- package/docs/tutorial/01-your-first-app.md +48 -0
- package/docs/tutorial/02-reactivity.md +57 -0
- package/docs/tutorial/03-services-and-async.md +77 -0
- package/docs/tutorial/04-errors-and-server.md +61 -0
- package/package.json +19 -4
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Routing
|
|
3
|
+
order: 4
|
|
4
|
+
section: how-to
|
|
5
|
+
description: "@weftui/router — universal nested routing, Router.route / Router.layout / Router.router, type-safe href, layouts, and programmatic navigation."
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Routing
|
|
9
|
+
|
|
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
|
+
|
|
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
|
+
|
|
15
|
+
The package mirrors `@weftui/dom`: a shared (universal) root, a `./client` entry, and a `./server` entry.
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npm install @weftui/router
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## The mental model
|
|
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 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
|
+
|
|
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
|
+
|
|
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
|
+
|
|
33
|
+
The tree is the source of truth. The same sealed `RouterDef` drives both server and client.
|
|
34
|
+
|
|
35
|
+
## Authoring routes
|
|
36
|
+
|
|
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 is what lets `href(…)` resolve after the tree is compiled.
|
|
38
|
+
|
|
39
|
+
```typescript
|
|
40
|
+
import { Component, h } from "@weftui/core";
|
|
41
|
+
import { Router } from "@weftui/router";
|
|
42
|
+
import { Schema } from "effect";
|
|
43
|
+
|
|
44
|
+
const About = Router.route("about", {
|
|
45
|
+
component: Component.make(() => h.h1("About")),
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const User = Router.route("users/:id", {
|
|
49
|
+
path: { id: Schema.NumberFromString },
|
|
50
|
+
component: Component.gen(function* () {
|
|
51
|
+
const { id } = yield* Router.params({ id: Schema.NumberFromString });
|
|
52
|
+
return yield* h.div(`User ${id}`);
|
|
53
|
+
}),
|
|
54
|
+
});
|
|
55
|
+
```
|
|
56
|
+
|
|
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
|
+
- **`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
|
+
|
|
60
|
+
> Authoring components with `Component.make` / `Component.gen` keeps every slot fully typed: the router never sees a `Node<any, any>`, and each component's `E`/`R` channels aggregate up through `Router.layout` / `Router.router` into the sealed `RouterDef`.
|
|
61
|
+
|
|
62
|
+
## Reading the match: handler-arg props vs. injection
|
|
63
|
+
|
|
64
|
+
A leaf page reads the current match's decoded `path` / `query` in **either** of two forms.
|
|
65
|
+
|
|
66
|
+
### Handler-arg props (leaf pages)
|
|
67
|
+
|
|
68
|
+
The router passes the decoded `{ path, query }` straight into a leaf `component` as props (typed `RouteHandlerProps<Path, Query>`, inferred from the route's `path` / `query` fields). No `Router` access, no validation step — just read the props:
|
|
69
|
+
|
|
70
|
+
```typescript
|
|
71
|
+
const idParam = { id: Schema.NumberFromString };
|
|
72
|
+
const sortQuery = { sort: Schema.optional(Schema.String) };
|
|
73
|
+
|
|
74
|
+
Router.route("users/:id/posts", {
|
|
75
|
+
path: idParam,
|
|
76
|
+
query: sortQuery,
|
|
77
|
+
// `path.id` is already a number; `query.sort` is `string | undefined`.
|
|
78
|
+
component: ({ path, query }) =>
|
|
79
|
+
h.section([h.h2(`Posts for user ${path.id}`), h.p(`sort: ${query.sort ?? "none"}`)]),
|
|
80
|
+
});
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
This is the most direct form for a leaf. A plain zero-arg thunk works too — it just ignores the props.
|
|
84
|
+
|
|
85
|
+
### Dependency injection (layouts and deep nodes)
|
|
86
|
+
|
|
87
|
+
A **layout** sits above the leaf and so can't take handler args; it reads the match by **dependency injection** instead. `Router.params(fields)` / `Router.query(fields)` are readable from **any** component:
|
|
88
|
+
|
|
89
|
+
```typescript
|
|
90
|
+
// A /users/:id layout (above the leaf) reads `:id` by injection.
|
|
91
|
+
const UserShell = Component.gen(function* () {
|
|
92
|
+
const { id } = yield* Router.params(idParam);
|
|
93
|
+
const outlet = yield* Router.Outlet;
|
|
94
|
+
return yield* h.div({ class: "user" }, [h.h1(`User ${id}`), outlet]);
|
|
95
|
+
});
|
|
96
|
+
```
|
|
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, or fail with a tagged [`RouterParamsError`](#errors) (carrying `source: "path" | "query"` and the requested `keys`) when no route matches. That error bubbles into the app node's aggregate `E`, so a user may recover it with `Boundary.catchTag("RouterParamsError", …)`.
|
|
99
|
+
|
|
100
|
+
> **Reactive accessors.** `Router.paramsStream(fields)` / `Router.queryStream(fields)` are the reactive counterparts — each resolves a `Subscribable` derived from `currentMatch.changes`, so 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
|
+
|
|
102
|
+
## Layouts and the outlet
|
|
103
|
+
|
|
104
|
+
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
|
+
|
|
106
|
+
```typescript
|
|
107
|
+
const UserShell = Component.gen(function* () {
|
|
108
|
+
const { id } = yield* Router.params(idParam);
|
|
109
|
+
const outlet = yield* Router.Outlet;
|
|
110
|
+
return yield* h.div({ class: "user" }, [h.h1(`User ${id}`), outlet]);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
Router.layout({ component: UserShell }, [settingsRoute, postsRoute]);
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
`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
|
+
|
|
118
|
+
A layout owns **no `segment` or `path`** — all path structure lives on routes. A layout that needs a param simply reads it via `Router.params`.
|
|
119
|
+
|
|
120
|
+
### Layout persistence
|
|
121
|
+
|
|
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: its DOM identity and any local state (a `SubscriptionRef`, a scroll position) survive while only the inner outlet swaps.
|
|
123
|
+
|
|
124
|
+
## Sealing the tree
|
|
125
|
+
|
|
126
|
+
`Router.router(root, { notFound })` compiles the tree eagerly (stamping leaf references so `href` works) and captures the app-level not-found page:
|
|
127
|
+
|
|
128
|
+
```typescript
|
|
129
|
+
export const App = Router.router(
|
|
130
|
+
Router.layout({ component: Shell }, [
|
|
131
|
+
homeRoute,
|
|
132
|
+
Router.layout({ component: UserShell }, [settingsRoute, postsRoute]),
|
|
133
|
+
]),
|
|
134
|
+
{ notFound: () => h.section({ id: "page" }, [h.h2("404 — page not found")]) },
|
|
135
|
+
);
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
`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
|
+
|
|
140
|
+
## Type-safe links with `href`
|
|
141
|
+
|
|
142
|
+
`href(leafRef, args)` builds a URL from a leaf route reference (the value returned by `Router.route`). Path params are **required** in the argument type and query is optional when every query field is optional:
|
|
143
|
+
|
|
144
|
+
```typescript
|
|
145
|
+
import { href } from "@weftui/router";
|
|
146
|
+
|
|
147
|
+
const Home = Component.make(() =>
|
|
148
|
+
h.nav([
|
|
149
|
+
h.a({ href: href(settingsRoute, { path: { id: 1 } }) }, "User 1 settings"),
|
|
150
|
+
h.a({ href: href(postsRoute, { path: { id: 2 }, query: { sort: "new" } }) }, "User 2 posts"),
|
|
151
|
+
]),
|
|
152
|
+
);
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
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. The leaf must belong to a tree sealed with `Router.router()` (which is why deferring the `component` body via `Component.make` matters — `href` runs at render time, after compile).
|
|
156
|
+
|
|
157
|
+
## Not-found
|
|
158
|
+
|
|
159
|
+
`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
|
+
|
|
161
|
+
```typescript
|
|
162
|
+
import { notFound, Router } from "@weftui/router";
|
|
163
|
+
|
|
164
|
+
Router.route("users/:id", {
|
|
165
|
+
path: idParam,
|
|
166
|
+
component: Component.gen(function* () {
|
|
167
|
+
const { id } = yield* Router.params(idParam);
|
|
168
|
+
if (id < 0) return yield* notFound();
|
|
169
|
+
return yield* h.div(`User ${id}`);
|
|
170
|
+
}),
|
|
171
|
+
});
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
`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
|
+
|
|
176
|
+
## Client setup
|
|
177
|
+
|
|
178
|
+
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. Provide it through a long-lived `ManagedRuntime` rather than `Effect.provide` at the node level:
|
|
179
|
+
|
|
180
|
+
```typescript
|
|
181
|
+
// entry-client.ts
|
|
182
|
+
import { hydrate } from "@weftui/dom/client";
|
|
183
|
+
import { RouterApp, RouterLive } from "@weftui/router/client";
|
|
184
|
+
import { ManagedRuntime } from "effect";
|
|
185
|
+
import { App } from "./app";
|
|
186
|
+
|
|
187
|
+
const root = document.getElementById("root")!;
|
|
188
|
+
const runtime = ManagedRuntime.make(RouterLive(App));
|
|
189
|
+
void runtime.runPromise(hydrate(RouterApp(App), root));
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
For a client-only app (no SSR), swap `hydrate` for `mount` — everything else is identical.
|
|
193
|
+
|
|
194
|
+
### Link interception
|
|
195
|
+
|
|
196
|
+
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 modified clicks (ctrl/meta/shift/alt or non-left button), `target=_blank`, `download`, external origins, same-document (hash-only) navigations, and hrefs that don't resolve to a route. You don't wire anything up: `RouterLive` installs the delegated listener for the layer's lifetime and removes it on teardown.
|
|
197
|
+
|
|
198
|
+
## Programmatic navigation
|
|
199
|
+
|
|
200
|
+
For navigation that isn't a link click, `@weftui/router/client` exposes typed helpers. They run as `Effect`s within the `RouterLive` layer (except `back` / `forward`, which only touch `window.history`):
|
|
201
|
+
|
|
202
|
+
```typescript
|
|
203
|
+
import {
|
|
204
|
+
back,
|
|
205
|
+
forward,
|
|
206
|
+
navigate,
|
|
207
|
+
patchQuery,
|
|
208
|
+
push,
|
|
209
|
+
replace,
|
|
210
|
+
setQuery,
|
|
211
|
+
} from "@weftui/router/client";
|
|
212
|
+
|
|
213
|
+
// Typed: build the URL from a leaf ref + decoded args (same rules as `href`).
|
|
214
|
+
yield * navigate(postsRoute, { path: { id: 42 }, query: { sort: "new" } });
|
|
215
|
+
yield * navigate(settingsRoute, { path: { id: 42 } }, { replace: true });
|
|
216
|
+
|
|
217
|
+
// Raw path + search string.
|
|
218
|
+
yield * push("/users/1/posts?sort=new");
|
|
219
|
+
yield * replace("/users/1/settings");
|
|
220
|
+
|
|
221
|
+
// History stepping (popstate resyncs the router).
|
|
222
|
+
yield * back();
|
|
223
|
+
yield * forward();
|
|
224
|
+
|
|
225
|
+
// Change only the current route's query, re-encoded through its query schema.
|
|
226
|
+
// The path is kept, so the leaf stays mounted and `queryStream` readers update.
|
|
227
|
+
yield * setQuery({ sort: "old" }); // replaces the query
|
|
228
|
+
yield * patchQuery({ sort: "old" }); // merges into the current query
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
- **`navigate(ref, args)`** builds the URL via [`href`](#type-safe-links-with-href) (so it round-trips with the matcher) and pushes — or, with `{ replace: true }`, replaces — the History entry. `args` follows the same requiredness rules as `href`.
|
|
232
|
+
- **`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.
|
|
233
|
+
|
|
234
|
+
## Server setup
|
|
235
|
+
|
|
236
|
+
On the server, `RouterServer` matches a request URL, builds a fixed-match `Router`, renders `RouterApp` to hydratable HTML inside a **document shell**, and reports a status (404 when no route matches or a page raises `RouterNotFound`).
|
|
237
|
+
|
|
238
|
+
The document shell is itself a `ComponentSlot` that splices the app via `yield* Router.Outlet` — exactly like a layout receives its outlet:
|
|
239
|
+
|
|
240
|
+
```typescript
|
|
241
|
+
// entry-server.ts
|
|
242
|
+
import { Component, h } from "@weftui/core";
|
|
243
|
+
import { Router } from "@weftui/router";
|
|
244
|
+
import { RouterServer } from "@weftui/router/server";
|
|
245
|
+
import { Effect } from "effect";
|
|
246
|
+
import { App } from "./app";
|
|
247
|
+
|
|
248
|
+
const documentShell = Component.gen(function* () {
|
|
249
|
+
const app = yield* Router.Outlet;
|
|
250
|
+
return yield* h.html({ lang: "en" }, [
|
|
251
|
+
h.head([h.meta({ charset: "utf-8" }), h.title("My app")]),
|
|
252
|
+
h.body([
|
|
253
|
+
h.div({ id: "root" }, [app]),
|
|
254
|
+
h.script({ type: "module", src: "/src/entry-client.ts" }),
|
|
255
|
+
]),
|
|
256
|
+
]);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
// { html, status } — `<!DOCTYPE html>` is prepended for you.
|
|
260
|
+
export const render = (url: string) =>
|
|
261
|
+
Effect.runPromise(RouterServer.render(App, { document: documentShell, url }));
|
|
262
|
+
|
|
263
|
+
// Or a Web fetch-style handler, ready to bridge into Vite or any Web server.
|
|
264
|
+
export const handler = RouterServer.toWebHandler(App, { document: documentShell });
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
`render` provides both `Router.Outlet` (the app, per request) and `Router` (so the shell may read params), and renders through `renderToStringHydratable` so the client can `hydrate` in place.
|
|
268
|
+
|
|
269
|
+
### `@effect/platform` is the spine
|
|
270
|
+
|
|
271
|
+
The tree is the authoring surface, but `@effect/platform`'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`: a single `"pages"` group with one GET endpoint per leaf at its full path pattern, carrying `setPath(pathSchema)`, `setUrlParams(querySchema)`, and a `RouterNotFound → 404` error. Both sides read that one definition, so they always agree:
|
|
272
|
+
|
|
273
|
+
- **Server** — `RouterServer` dispatches through `HttpApiBuilder` (platform owns request→leaf matching, path/query decode, and the 404 status).
|
|
274
|
+
- **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), fed from the same endpoint definitions so it never drifts from the server.
|
|
275
|
+
|
|
276
|
+
## Errors
|
|
277
|
+
|
|
278
|
+
| Error | Raised by | Recover with |
|
|
279
|
+
| ------------------- | --------------------------------------------------------------------- | --------------------------------------------------------------------------- |
|
|
280
|
+
| `RouterNotFound` | `notFound()`, or no route matched | `Boundary.catchTag("RouterNotFound", …)` (or the app-level `notFound` page) |
|
|
281
|
+
| `RouterParamsError` | `Router.params` / `Router.query` on a missing/invalid key or no match | `Boundary.catchTag("RouterParamsError", …)` |
|
|
282
|
+
|
|
283
|
+
Both are modeled as `Schema.TaggedError`, so they encode/decode across the wire the same way `Boundary.rpc` replays typed failures.
|
|
284
|
+
|
|
285
|
+
## `Boundary.rpc` interplay
|
|
286
|
+
|
|
287
|
+
Initial SSR navigation works end to end: the server resolves the rpc and inlines its payload, and the client replays it during `hydrate`. **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. `@weftui/router` provides the `AppRpcClientTag` seam on both sides (network client on the client, in-process on the server), so 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).
|
|
288
|
+
|
|
289
|
+
## See also
|
|
290
|
+
|
|
291
|
+
- [`@weftui/router` API reference](https://weftui.dev/docs/reference/router)
|
|
292
|
+
- [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/platform` spine
|
|
293
|
+
- [Component Authoring](https://weftui.dev/docs/how-to/author-components) — `Component.make` / `Component.gen`, the idiomatic way to write route components
|
|
294
|
+
- [Server-Side Rendering](https://weftui.dev/docs/how-to/render-on-the-server) — `renderToStringHydratable`, `hydrate`, and `Boundary.rpc`
|
|
295
|
+
- [RPC Data Boundaries](https://weftui.dev/docs/how-to/load-data-with-rpc) — `Boundary.rpc`, the `Resource` handle, and the four lifecycles
|
|
296
|
+
- [`packages/router/router.specs.md`](https://github.com/stefvw93/weft/blob/main/packages/router/router.specs.md) — the full specification
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Component Authoring
|
|
3
|
+
order: 1
|
|
4
|
+
section: how-to
|
|
5
|
+
description: Plain functions vs. Component.gen / Component.make, instance scope, fragments, render-prop children, and service requirements.
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Component Authoring
|
|
9
|
+
|
|
10
|
+
Weft components are plain TypeScript functions that return a `Node<E, R>`. This guide covers the two ways to define them and when to choose each.
|
|
11
|
+
|
|
12
|
+
## Plain functions
|
|
13
|
+
|
|
14
|
+
The simplest component is just a function:
|
|
15
|
+
|
|
16
|
+
```typescript
|
|
17
|
+
import { h } from "@weftui/core";
|
|
18
|
+
|
|
19
|
+
function Greeting({ name }: { name: string }) {
|
|
20
|
+
return h.p(`Hello, ${name}!`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Call it like a function
|
|
24
|
+
Greeting({ name: "World" });
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Use a plain function when:
|
|
28
|
+
|
|
29
|
+
- Props are all static (strings, numbers, plain functions)
|
|
30
|
+
- The component has no internal state
|
|
31
|
+
- You don't need the caller's reactive prop types to propagate
|
|
32
|
+
|
|
33
|
+
## Components with internal state
|
|
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 — the setup happens at mount time:
|
|
36
|
+
|
|
37
|
+
```typescript
|
|
38
|
+
import { h } from "@weftui/core";
|
|
39
|
+
import { Effect, SubscriptionRef } from "effect";
|
|
40
|
+
|
|
41
|
+
const Counter = () =>
|
|
42
|
+
Effect.gen(function* () {
|
|
43
|
+
const count = yield* SubscriptionRef.make(0);
|
|
44
|
+
|
|
45
|
+
return yield* h.div([
|
|
46
|
+
h.span([count.changes]),
|
|
47
|
+
h.button({ onclick: () => SubscriptionRef.update(count, (n) => n + 1) }, "+"),
|
|
48
|
+
]);
|
|
49
|
+
});
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
The return type here is `Effect.Effect<Node, never, never>` — itself a valid `Node`, so it composes naturally with other tree-building calls. As soon as such a component is reused or takes props, prefer wrapping the same generator in [`Component.gen`](#componentgen--componentmake-for-reusable-components) (below) so the caller's reactive prop and children channels flow into its node type.
|
|
53
|
+
|
|
54
|
+
## Component scope and background effects
|
|
55
|
+
|
|
56
|
+
Every component instance is rendered under its own **instance scope** — a child of the
|
|
57
|
+
mount scope created fresh for that instance. Anything bound to the instance scope lives
|
|
58
|
+
exactly as long as the component is mounted and is torn down automatically when the
|
|
59
|
+
component unmounts (or when the whole tree unmounts via the `MountHandle`). The renderer
|
|
60
|
+
provides this scope as the ambient `Scope.Scope` while it evaluates the component body,
|
|
61
|
+
so it is already in context when you need it.
|
|
62
|
+
|
|
63
|
+
This matters the moment a component starts **background work** — a subscription, an
|
|
64
|
+
observer of a `ref`, a polling timer, anything you `fork`. The rule:
|
|
65
|
+
|
|
66
|
+
> Fork background work with **`Effect.forkScoped`**, never a bare `Effect.fork`.
|
|
67
|
+
|
|
68
|
+
`Effect.forkScoped` attaches the fiber to the instance scope, so it keeps running for
|
|
69
|
+
the component's lifetime and is interrupted on unmount. A bare `Effect.fork` instead
|
|
70
|
+
attaches the fiber to the component-body fiber — the one that runs your `Effect.gen` to
|
|
71
|
+
produce the tree. That fiber completes the instant the gen returns its node, so the
|
|
72
|
+
forked work is cancelled almost immediately.
|
|
73
|
+
|
|
74
|
+
Concretely, an observer that runs an effect when a `ref`'s element mounts:
|
|
75
|
+
|
|
76
|
+
```typescript
|
|
77
|
+
import { h } from "@weftui/core";
|
|
78
|
+
import { Effect, Option, pipe, Stream, SubscriptionRef } from "effect";
|
|
79
|
+
|
|
80
|
+
const AutoFocusInput = () =>
|
|
81
|
+
Effect.gen(function* () {
|
|
82
|
+
const inputRef = yield* SubscriptionRef.make<Option.Option<HTMLInputElement>>(Option.none());
|
|
83
|
+
|
|
84
|
+
yield* pipe(
|
|
85
|
+
inputRef.changes,
|
|
86
|
+
Stream.filter(Option.isSome),
|
|
87
|
+
Stream.take(1),
|
|
88
|
+
Stream.runForEach((el) => Effect.sync(() => el.value.focus())),
|
|
89
|
+
Effect.forkScoped, // ✅ tied to the instance scope — survives until unmount
|
|
90
|
+
// Effect.fork, // ❌ tied to the body fiber — interrupted when the gen returns
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
return yield* h.input({ ref: inputRef, type: "text" });
|
|
94
|
+
});
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
You do not manage the scope yourself: you do not create it, close it, or pass it
|
|
98
|
+
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 — the
|
|
100
|
+
type system will tell you, because `forkScoped` carries a `Scope.Scope` requirement.
|
|
101
|
+
|
|
102
|
+
See `examples/element-ref` for the auto-focus, measure, and canvas recipes built on
|
|
103
|
+
this pattern.
|
|
104
|
+
|
|
105
|
+
## `Component.gen` / `Component.make` for reusable components
|
|
106
|
+
|
|
107
|
+
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
|
+
|
|
109
|
+
- **`Component.make`** — body is a plain function returning any `Effect` (typically a `Node`). Use for one-liners and pipe compositions.
|
|
110
|
+
- **`Component.gen`** — body is a generator. Use when you need `yield*` to set up local state or pull from services.
|
|
111
|
+
|
|
112
|
+
```typescript
|
|
113
|
+
import { Component, h, Source } from "@weftui/core";
|
|
114
|
+
|
|
115
|
+
interface CardProps {
|
|
116
|
+
title: Source.Source<string>;
|
|
117
|
+
body?: Source.Source<string>;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const Card = Component.make((props: CardProps) =>
|
|
121
|
+
h.div({ class: "card" }, [
|
|
122
|
+
h.h3({ class: "card-title" }, [props.title]),
|
|
123
|
+
props.body ? h.p({ class: "card-body" }, [props.body]) : null,
|
|
124
|
+
]),
|
|
125
|
+
);
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
`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>` — so you don't hand-write `string | Stream.Stream<string> | …` on every prop. 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
|
+
|
|
130
|
+
Now the caller's stream types are visible in the returned node:
|
|
131
|
+
|
|
132
|
+
```typescript
|
|
133
|
+
declare const titleStream: Stream.Stream<string, never, I18nService>;
|
|
134
|
+
|
|
135
|
+
// Node<never, I18nService> — I18nService requirement flows out
|
|
136
|
+
const card = Card({ title: titleStream });
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Without a `Component` factory, a plain function's return type is fixed at definition time and won't reflect the caller's reactive prop types.
|
|
140
|
+
|
|
141
|
+
### Body `E`/`R` inference
|
|
142
|
+
|
|
143
|
+
You don't declare the body's `E`/`R` channels explicitly — they're inferred from the returned (or yielded) effect:
|
|
144
|
+
|
|
145
|
+
- The body's `E`/`R` come from whatever effects appear inside.
|
|
146
|
+
- The caller's reactive prop channels and reactive children channels are unioned on top at the call site.
|
|
147
|
+
- Static prop values (`string`, `number`, plain functions) contribute `never`.
|
|
148
|
+
|
|
149
|
+
### Children: array or function
|
|
150
|
+
|
|
151
|
+
Both factories accept an optional second `children` argument, typed as:
|
|
152
|
+
|
|
153
|
+
```typescript
|
|
154
|
+
type Component.Children<Input = never> =
|
|
155
|
+
| readonly Renderable[]
|
|
156
|
+
| ((input: Input) => readonly Renderable[]);
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
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
|
+
|
|
161
|
+
```typescript
|
|
162
|
+
const ItemList = Component.make(
|
|
163
|
+
(props: { items: readonly string[] }, renderItem: (item: string) => readonly Renderable[]) =>
|
|
164
|
+
h.ul(props.items.flatMap(renderItem)),
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
ItemList({ items: ["a", "b"] }, (item) => [h.li(item)]);
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
## Props typing
|
|
171
|
+
|
|
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 — `T | Stream<T> | Effect<T> | Subscribable<T>` — so the caller can pass a plain value or any reactive shape interchangeably, and you write it once:
|
|
173
|
+
|
|
174
|
+
```typescript
|
|
175
|
+
import { Source } from "@weftui/core";
|
|
176
|
+
|
|
177
|
+
interface ButtonProps {
|
|
178
|
+
label: Source.Source<string>; // static or reactive text
|
|
179
|
+
disabled?: Source.Source<boolean>; // static or reactive boolean
|
|
180
|
+
onclick?: () => void | Effect.Effect<void>; // plain or Effect-returning handler
|
|
181
|
+
}
|
|
182
|
+
```
|
|
183
|
+
|
|
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 — the extraction is exactly `Source.Success` / `Source.Error` / `Source.Context`.
|
|
185
|
+
|
|
186
|
+
### Reading a `Source` in the body
|
|
187
|
+
|
|
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 — combine two props, feed a stream operator, drive logic — normalize it first with [`Source.toSubscribable`](https://weftui.dev/docs/reference/core#sourcetosubscribablesource-key), which turns any `Source<A>` into an await-first, hot `Subscribable<A>`:
|
|
189
|
+
|
|
190
|
+
```typescript
|
|
191
|
+
import { Component, h, Source } from "@weftui/core";
|
|
192
|
+
import { Stream } from "effect";
|
|
193
|
+
|
|
194
|
+
const LoudLabel = Component.gen(function* (props: { label: Source.Source<string> }) {
|
|
195
|
+
const label = yield* Source.toSubscribable(props.label); // Subscribable<string>
|
|
196
|
+
// Now derive from it like any Subscribable — static, Effect, and Stream inputs all work.
|
|
197
|
+
return yield* h.strong([Stream.map(label.changes, (text) => text.toUpperCase())]);
|
|
198
|
+
});
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
`toSubscribable` is scoped: a `Stream` prop is pumped by a fiber that terminates with the component's instance scope, an `Effect` prop is memoized, an existing `Subscribable` is threaded through by reference, and a static value emits once. 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
|
+
|
|
203
|
+
## Composing components
|
|
204
|
+
|
|
205
|
+
Call component functions directly inside a children array:
|
|
206
|
+
|
|
207
|
+
```typescript
|
|
208
|
+
import { h } from "@weftui/core";
|
|
209
|
+
|
|
210
|
+
function App() {
|
|
211
|
+
return h.div({ class: "app" }, [
|
|
212
|
+
Header({ title: "My App" }),
|
|
213
|
+
h.main([Sidebar(), h.article([Content({ id: 1 })])]),
|
|
214
|
+
Footer(),
|
|
215
|
+
]);
|
|
216
|
+
}
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
Children arrays accumulate `E`/`R` from all their members. The parent node's type reflects the union of all children's channels.
|
|
220
|
+
|
|
221
|
+
## Components that require services
|
|
222
|
+
|
|
223
|
+
If a component's render function uses a service via `yield*`, that service appears in the component's `CompR` parameter:
|
|
224
|
+
|
|
225
|
+
```typescript
|
|
226
|
+
import { Component, h } from "@weftui/core";
|
|
227
|
+
|
|
228
|
+
const UserAvatar = Component.gen(function* (props: { userId: string }) {
|
|
229
|
+
const userService = yield* UserService;
|
|
230
|
+
const user = yield* userService.getUser(props.userId);
|
|
231
|
+
return yield* h.img({ src: user.avatarUrl, alt: user.name });
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
// Node<never, UserService> — regardless of what the caller passes
|
|
235
|
+
const avatar = UserAvatar({ userId: "123" });
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
Provide the service at the mount boundary:
|
|
239
|
+
|
|
240
|
+
```typescript
|
|
241
|
+
void Effect.runPromise(
|
|
242
|
+
mount(App(), document.getElementById("root")!).pipe(Effect.provide(UserServiceLive)),
|
|
243
|
+
);
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
## Returning fragments
|
|
247
|
+
|
|
248
|
+
When a component needs to return multiple sibling elements without a wrapper, use `h.fragment`:
|
|
249
|
+
|
|
250
|
+
```typescript
|
|
251
|
+
import { h } from "@weftui/core";
|
|
252
|
+
|
|
253
|
+
const TableCells = ({ row }: { row: Row }) =>
|
|
254
|
+
h.fragment([h.td(row.name), h.td(row.value), h.td(row.status)]);
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
`h.fragment` returns a `Node<E, R>` that accumulates channels from all its children.
|
|
258
|
+
|
|
259
|
+
## See also
|
|
260
|
+
|
|
261
|
+
- [The Combinator API](https://weftui.dev/docs/explanation/combinator-api) — `h`, `h.fragment`, and how `E`/`R` accumulate
|
|
262
|
+
- [Reactive Primitives](https://weftui.dev/docs/explanation/reactive-primitives) — the `Source` vocabulary props accept
|
|
263
|
+
- [Add Routing](https://weftui.dev/docs/how-to/add-routing) — route components are `Component` slots
|
|
264
|
+
- [`@weftui/core` reference](https://weftui.dev/docs/reference/core) — `Component`, `Source`, and the full surface
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Handle Forms
|
|
3
|
+
order: 8
|
|
4
|
+
section: how-to
|
|
5
|
+
description: Build a controlled form with SubscriptionRef field state, reactive Schema validation, and an Effect-returning submit handler.
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Handle Forms
|
|
9
|
+
|
|
10
|
+
**Goal:** a controlled form whose inputs drive `SubscriptionRef` state, whose errors update reactively as the user types, and whose submit runs an Effect.
|
|
11
|
+
|
|
12
|
+
Each field is a `SubscriptionRef`. Bind it with `oninput`, derive validation from its `.changes` stream, and return an `Effect` from `onsubmit` (after `preventDefault`).
|
|
13
|
+
|
|
14
|
+
```typescript
|
|
15
|
+
import { h } from "@weftui/core";
|
|
16
|
+
import { Effect, Either, Schema, Stream, SubscriptionRef } from "effect";
|
|
17
|
+
|
|
18
|
+
const Email = Schema.String.pipe(
|
|
19
|
+
Schema.filter((s) => s.includes("@"), { message: () => "Must contain @" }),
|
|
20
|
+
Schema.filter((s) => s.includes("."), { message: () => "Must contain a domain" }),
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
const LoginForm = () =>
|
|
24
|
+
Effect.gen(function* () {
|
|
25
|
+
const email = yield* SubscriptionRef.make("");
|
|
26
|
+
const status = yield* SubscriptionRef.make<string | null>(null);
|
|
27
|
+
|
|
28
|
+
// Validation is a stream derived from the field — it re-runs as the user types.
|
|
29
|
+
const error = Stream.map(email.changes, (value) => {
|
|
30
|
+
if (value.length === 0) return null; // don't nag an empty field
|
|
31
|
+
return Either.match(Schema.decodeUnknownEither(Email)(value), {
|
|
32
|
+
onLeft: (e) => e.message.split(":").pop()?.trim() ?? "Invalid",
|
|
33
|
+
onRight: () => null,
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
return yield* h.form(
|
|
38
|
+
{
|
|
39
|
+
onsubmit: (e) => {
|
|
40
|
+
e.preventDefault();
|
|
41
|
+
return Effect.gen(function* () {
|
|
42
|
+
yield* SubscriptionRef.set(status, "Submitting…");
|
|
43
|
+
yield* Effect.sleep("1500 millis");
|
|
44
|
+
yield* SubscriptionRef.set(status, "Login successful!");
|
|
45
|
+
});
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
[
|
|
49
|
+
h.input({
|
|
50
|
+
type: "email",
|
|
51
|
+
oninput: (e) => SubscriptionRef.set(email, (e.target as HTMLInputElement).value),
|
|
52
|
+
}),
|
|
53
|
+
Stream.map(error, (err) => (err ? h.span({ class: "error-text" }, err) : null)),
|
|
54
|
+
h.button({ type: "submit" }, "Login"),
|
|
55
|
+
h.div([Stream.map(status.changes, (s) => (s ? h.span(s) : null))]),
|
|
56
|
+
],
|
|
57
|
+
);
|
|
58
|
+
});
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## How it works
|
|
62
|
+
|
|
63
|
+
- **Field state** is a `SubscriptionRef.make("")`; `oninput` writes the current value with `SubscriptionRef.set`. Because the input is driven by the ref, it is a controlled input.
|
|
64
|
+
- **Validation is reactive**, not on-blur or on-submit only: `Stream.map(email.changes, …)` produces an error string (or `null`) on every keystroke. Use [`Schema`](https://effect.website/docs/schema/introduction) to decode — `Schema.decodeUnknownEither(schema)(value)` returns an `Either`, and `Either.match` turns it into UI. A node or `null` in a child slot renders the error or nothing.
|
|
65
|
+
- **Submit returns an Effect.** `onsubmit` calls `e.preventDefault()` and then **returns** an `Effect` (it is not `yield*`-ed inline) — the renderer runs it in a detached fiber, so it can `SubscriptionRef.set`, `Effect.sleep`, read fields with `SubscriptionRef.get`, or call a service.
|
|
66
|
+
|
|
67
|
+
## Variations
|
|
68
|
+
|
|
69
|
+
- **Cross-field rules** (e.g. "passwords match") combine two fields with `Stream.zipLatestWith` before mapping to an error.
|
|
70
|
+
- **Read a field imperatively** inside the submit handler with `yield* SubscriptionRef.get(field)` rather than threading it through.
|
|
71
|
+
|
|
72
|
+
## See also
|
|
73
|
+
|
|
74
|
+
- [Reactive Primitives](https://weftui.dev/docs/explanation/reactive-primitives) — `SubscriptionRef.changes` and stream-shaped children
|
|
75
|
+
- [Author Components](https://weftui.dev/docs/how-to/author-components) — Effect-returning and service-aware handlers
|
|
76
|
+
- [examples/form-handling](https://github.com/stefvw93/weft/tree/main/examples/form-handling) — a runnable multi-field form with Schema validation and an async submit
|