@weftui/dom 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.
@@ -0,0 +1,348 @@
1
+ ---
2
+ title: "@weftui/router"
3
+ order: 3
4
+ section: reference
5
+ description: Full API surface for @weftui/router — universal nested routing, type-safe href, layouts, and SSR entry points.
6
+ ---
7
+
8
+ # @weftui/router API Reference
9
+
10
+ Universal nested router for Weft. See the [Routing guide](https://weftui.dev/docs/how-to/add-routing) for a narrative walkthrough.
11
+
12
+ Three entry points mirror `@weftui/dom`:
13
+
14
+ | Import | Use |
15
+ | ----------------------- | ----------------------------------------------------------------------------------- |
16
+ | `@weftui/router` | Shared authoring + universal nodes (`Router`, `href`, `RouterApp`, errors). |
17
+ | `@weftui/router/client` | Client runtime (`RouterLive`, programmatic `navigate`/`push`/…, re-exported nodes). |
18
+ | `@weftui/router/server` | Server rendering (`RouterServer`). |
19
+
20
+ ## `Router`
21
+
22
+ `Router` is both an Effect `Context.Tag` and the authoring namespace; the two roles merge by declaration. `yield* Router` reads the per-render service; `Router.route(…)` authors a tree.
23
+
24
+ The service value carries:
25
+
26
+ - **`currentMatch`** — the current match as a hot `Subscribable<RouteMatch>`; drives the outlet.
27
+ - **`navigate(to, options?)`** — navigates to a path (with optional query). `options.replace` swaps `pushState` for `replaceState`. On the client this updates History state and re-renders the affected outlet; on the server it is a no-op.
28
+ - **`httpApiClient`** — the derived [`RouterHttpApiClient`](#types) as an `Option`: `Option.some` on the client (`RouterLive`) for network work (route prefetch, future loaders), `Option.none` on the server (it is itself the origin). SPA URL→leaf resolution does **not** use this — it stays local via the shared matcher.
29
+
30
+ ### `Router.route`
31
+
32
+ ```typescript
33
+ Router.route<Path, Query, S>(
34
+ segment: string,
35
+ config: { path?: Path; query?: Query; component: S },
36
+ ): RouteNode<Path, Query, E, R>;
37
+ ```
38
+
39
+ Declares a leaf page. `segment` is relative to the parent and may contain `:name` placeholders. `component` is a [`ComponentSlot`](#types) — its `E`/`R` channels are recovered and propagate up the tree. The returned `RouteNode` is also the reference passed to [`href`](#href).
40
+
41
+ A leaf `component` reads the live match in **either** of two forms:
42
+
43
+ - **Handler-arg props** — declare `(props: `[`RouteHandlerProps<Path, Query>`](#types)`)` and the router passes the decoded `{ path, query }` straight in (`path`/`query` inferred from the route's `path`/`query` fields). A plain zero-arg thunk works too, ignoring the props.
44
+ - **Dependency injection** — a `Component.make` / `Component.gen` component reading the live match via [`Router.params`](#routerparams--routerquery) / `Router.query`. Required for layouts/deep nodes, which can't take handler args.
45
+
46
+ ```typescript
47
+ // Handler-arg props — decoded { path, query } passed in directly.
48
+ const userRoute = Router.route("users/:id", {
49
+ path: { id: Schema.NumberFromString },
50
+ query: { tab: Schema.optional(Schema.String) },
51
+ component: ({ path, query }) => h.div(`User ${path.id} (${query.tab ?? "info"})`),
52
+ });
53
+
54
+ // Dependency injection — read the match anywhere via Router.params / Router.query.
55
+ const userRouteDI = Router.route("users/:id", {
56
+ path: { id: Schema.NumberFromString },
57
+ component: Component.gen(function* () {
58
+ const { id } = yield* Router.params({ id: Schema.NumberFromString });
59
+ return yield* h.div(`User ${id}`);
60
+ }),
61
+ });
62
+ ```
63
+
64
+ ### `Router.layout`
65
+
66
+ ```typescript
67
+ Router.layout<C, S>(config: { component: S }, children: C): LayoutNode<E, R>;
68
+ ```
69
+
70
+ Declares a layout — purely UI nesting, owning **no path or segment**. `component` splices the injected outlet via `yield* Router.Outlet`. `Router.Outlet` is excluded from the layout's aggregate requirement channel (the router discharges it per render); the subtree's real channels are unioned in.
71
+
72
+ ### `Router.router`
73
+
74
+ ```typescript
75
+ Router.router<T, NF>(root: T, options: { notFound: () => NF }): RouterDef<E, R>;
76
+ ```
77
+
78
+ Seals a route tree into a [`RouterDef`](#types), compiling it eagerly (so leaf references are stamped for `href`) and capturing the app-level not-found page. The tree's aggregate channels (plus the not-found page's) ride on the returned `RouterDef`'s phantom `E`/`R`.
79
+
80
+ ### `Router.lazy`
81
+
82
+ ```typescript
83
+ Router.lazy<S extends ComponentSlot>(
84
+ load: () => Promise<S>,
85
+ ): () => Node<Node.Error<SlotNode<S>>, Node.Context<SlotNode<S>>>;
86
+ ```
87
+
88
+ Wraps a dynamic-import loader as a component slot, so a route's **component** is code-split into its own chunk while the **descriptor** (segment + param schemas) stays eager. `load` returns a `Promise` resolving the component — typically `() => import("./page").then((m) => m.Page)`. Drops directly into `Router.route({ component })` and `Router.layout({ component })`.
89
+
90
+ - **Channels preserved.** The returned slot's `E`/`R` equal the resolved component's, so a lazy route has the identical type to declaring it eagerly (an unmet requirement is still a compile error at `Router.router`).
91
+ - **Only the matched branch loads**, on the server during render and on the client on navigation. Client navigation is **deferred-commit**: the chunk **and the leaf component's own effect** resolve _before_ the URL commits (see [`Router.navigating`](#routernavigating) and [Blocking vs streaming data](#blocking-vs-streaming-data)), so the previous page stays mounted through both the fetch and any data the leaf awaits, and the swap is blank-free. The load `Promise` is memoized per slot, so revisits are synchronous.
92
+ - **A rejected `load` is a defect** (`Effect.promise` dies) — a deploy-skew/offline condition surfaced through normal defect handling, kept off the `E` channel; the rejection is memoized (no silent retry).
93
+ - The resolved value should be a `Component` (`Component.make`/`Component.gen`); a bare `() => Node` thunk loses its channels through the loader `Promise` — wrap it in `Component.make`.
94
+
95
+ See the [Split Routes Lazily](https://weftui.dev/docs/how-to/split-routes-lazily) how-to and `packages/router/src/lazy-component.specs.md`.
96
+
97
+ ### `Router.Outlet`
98
+
99
+ A `Context.Tag` whose value is the node to splice for the next level down. A layout (or the server document shell) reads it with `const outlet = yield* Router.Outlet`. Typed **opaque** as `Node<never, never>`, and discharged by the router at render time, so it never appears in a reader's aggregate requirement channel.
100
+
101
+ ### `Router.params` / `Router.query`
102
+
103
+ ```typescript
104
+ Router.params<F extends Fields>(fields: F): Effect<FieldsType<F>, RouterParamsError, Router>;
105
+ Router.query<F extends Fields>(fields: F): Effect<FieldsType<F>, RouterParamsError, Router>;
106
+ ```
107
+
108
+ Snapshot accessors that read the **live match** (`currentMatch.get`) 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). 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.
109
+
110
+ ### `Router.paramsStream` / `Router.queryStream`
111
+
112
+ ```typescript
113
+ Router.paramsStream<F extends Fields>(fields: F): Effect<Subscribable<FieldsType<F>>, never, Router>;
114
+ Router.queryStream<F extends Fields>(fields: F): Effect<Subscribable<FieldsType<F>>, never, Router>;
115
+ ```
116
+
117
+ The **reactive** counterparts. Each resolves a `Subscribable<FieldsType<F>>` derived from `currentMatch.changes`, so a component can render `[(yield* Router.queryStream(fields)).changes]` and update **in place** even when the outlet keeps the same leaf mounted — exactly the query-only case (`setQuery` / `patchQuery`) a snapshot `Router.query` would miss. Resilient across navigations: a `NotFound` match yields the empty subset rather than failing, so the stream stays live.
118
+
119
+ ### `Router.navigating`
120
+
121
+ ```typescript
122
+ type NavState =
123
+ | { readonly _tag: "Idle" }
124
+ | { readonly _tag: "Navigating"; readonly to: string };
125
+
126
+ // on the Router service:
127
+ readonly navigating: Subscribable.Subscribable<NavState>;
128
+
129
+ // accessor, mirroring paramsStream/queryStream:
130
+ Router.navigatingStream: Effect<Subscribable<NavState>, never, Router>;
131
+ ```
132
+
133
+ The reactive navigation-state signal, for rendering pending UI (a top progress bar, dimmed outlet) during a **deferred-commit** navigation. It transitions `Idle → Navigating{ to }` while the router resolves the target branch's [`Router.lazy`](#routerlazy) chunk **and** the matched leaf's own component effect, and back to `Idle` on commit — one signal covering both the code and data windows. `NavState` is exported from `@weftui/router` and `@weftui/router/client`.
134
+
135
+ - **Eager navigations never flip it** — a branch with no lazy node and a synchronously-resolving leaf commits synchronously and `navigating` stays `Idle`, so reading it costs nothing in an eager app.
136
+ - **Synchronous resolutions never emit `Navigating`** — a revisit with both the chunk and the leaf's effect memoized (or an eager leaf with no async work) still commits in the same tick; the signal only flips when a resolution is genuinely async.
137
+ - **Latest-wins** across rapid navigations (a superseded navigation never resets it and its pre-run is interrupted); **popstate** (back/forward) into a lazy or data-fetching route also reports; a **rejected chunk load or a failing leaf pre-run** (typed error or defect) resets it to `Idle` before the failure surfaces through the normal render error path.
138
+ - **Server-side it is a constant `Idle`** (server render is buffered), so a component reading it type-checks and renders on both sides.
139
+
140
+ See the [Show Navigation Progress](https://weftui.dev/docs/how-to/show-navigation-progress) how-to, the [Blocking vs streaming data](#blocking-vs-streaming-data) section below, and `packages/router/src/resolve-before-commit.specs.md`.
141
+
142
+ ### Blocking vs streaming data
143
+
144
+ A leaf `component` is already an `Effect` — there is no separate loader — so where you put an await decides whether it blocks the navigation commit or streams in afterward:
145
+
146
+ - **`yield*` in the component body** → commit-blocking. The pre-run executes the leaf's effect to completion before the URL commits; the previous page stays mounted for the whole window, and `Router.navigating` reports `Navigating{ to }` for its duration.
147
+ - **An `Effect`/`Stream` placed as a child node** → streaming. The commit is not delayed; the leaf mounts immediately and the child region fills in place once its own effect resolves.
148
+
149
+ ```typescript
150
+ // Blocking — the await is in the leaf's own body; navigation waits for it.
151
+ const DocPage = Component.gen(function* () {
152
+ const { category, slug } = yield* Router.params({ category: Schema.String, slug: Schema.String });
153
+ const docs = yield* Docs;
154
+ const doc = yield* docs.load(category, slug);
155
+ return yield* h.article([h.h1(doc.title), h.div({ innerHTML: doc.html })]);
156
+ });
157
+
158
+ // Streaming — the await lives on a child; the leaf commits immediately.
159
+ const DocPage = Component.gen(function* () {
160
+ const { category, slug } = yield* Router.params({ category: Schema.String, slug: Schema.String });
161
+ const docs = yield* Docs;
162
+ return yield* h.article([
163
+ h.h1("Docs"),
164
+ Stream.concat(
165
+ Stream.make(h.p({ class: "loading" }, "Loading…")),
166
+ Stream.fromEffect(
167
+ Effect.map(docs.load(category, slug), (doc) => h.div({ innerHTML: doc.html })),
168
+ ),
169
+ ),
170
+ ]);
171
+ });
172
+ ```
173
+
174
+ Choose blocking for primary content the page is meaningless without (an article body, a product's price) — the old page stays visible with no blank or skeleton. Choose streaming for secondary or slow regions where partial content is still useful (a comments panel, a "related" rail) — the commit isn't held hostage by one slow fetch.
175
+
176
+ Only the matched **leaf** is pre-run this way; layout components in the branch get their chunks preloaded but their bodies still run at render, post-commit (unchanged layouts don't re-render across navigations, so this rarely matters in practice). Pre-run failures — `notFound()`, a typed error, or a defect — still commit the URL and replay through the normal render error path (the nearest `Boundary`, or the router's 404 boundary) without re-running the component.
177
+
178
+ See [Load Async Data](https://weftui.dev/docs/how-to/load-async-data#blocking-on-navigation-vs-streaming-in-place) and [RPC Data Boundaries](https://weftui.dev/docs/how-to/load-data-with-rpc) for the streaming patterns in full.
179
+
180
+ ## `href`
181
+
182
+ ```typescript
183
+ href<Path, Query>(ref: RouteNode<Path, Query>, args?: HrefArgs<Path, Query>): string;
184
+ ```
185
+
186
+ Builds a type-safe URL for a leaf route reference. Path params encode into the pattern; query values encode through the query schema into a key-sorted search string. Round-trips with the matcher.
187
+
188
+ - `path` is **required** when its decoded type has required keys; `query` is optional when every query field is optional (`HrefArgs`).
189
+ - Throws if the leaf belongs to a tree that has not been sealed with `Router.router()`.
190
+
191
+ ```typescript
192
+ href(userRoute, { path: { id: 42 } }); // "/users/42"
193
+ href(postsRoute, { path: { id: 1 }, query: { sort: "new" } }); // "/users/1/posts?sort=new"
194
+ ```
195
+
196
+ ## Universal nodes
197
+
198
+ ### `RouterApp`
199
+
200
+ ```typescript
201
+ RouterApp<E, R>(def: RouterDef<E, R>): Node<Exclude<E, RouterNotFound>, R | Router>;
202
+ ```
203
+
204
+ The universal router root node — render this on both server and client. Wraps the nested outlet in the router's internal not-found boundary, so a `RouterNotFound` raised by a page renders the configured `notFound` page in place. Server dispatch runs through `HttpApiBuilder`: a page-raised `RouterNotFound` and a no-match surface their 404 through the platform request pipeline.
205
+
206
+ `RouterApp` requires `Router` in its environment — provide it via `RouterLive` (client) or `RouterServer` (server), not `Effect.provide` at the node level (that would release the scoped layer immediately).
207
+
208
+ ### `outletNode` (a.k.a. `RouterOutlet`)
209
+
210
+ ```typescript
211
+ outletNode<E, R>(def: RouterDef<E, R>): Node<E | RouterNotFound, R | Router>;
212
+ ```
213
+
214
+ The bare nested-outlet node without the internal not-found boundary — for callers placing their own not-found handling. Re-exported from `@weftui/router/client` as `RouterOutlet`.
215
+
216
+ ## Client — `@weftui/router/client`
217
+
218
+ ### `RouterLive`
219
+
220
+ ```typescript
221
+ RouterLive(
222
+ def: RouterDef,
223
+ options: { rpc: { group: RpcGroup<any> }; baseUrl?: string | URL },
224
+ ): Layer.Layer<Router | AppRpcClientTag>;
225
+ ```
226
+
227
+ The client `Router` layer, backed by the History API. Seeds a `SubscriptionRef` from `window.location`, listens for `popstate`, installs the same-origin link-click interceptor, and derives the `HttpApiClient` exposed as `Router.httpApiClient` (over `FetchHttpClient`; `baseUrl` defaults to same-origin). Alongside `Router` it also provides the core [`AppRpcClientTag`](https://weftui.dev/docs/reference/core#apprpcclienttag) seam — a **network** flat rpc client over the app's merged `RpcGroup` (`RpcClient.make` → `POST /_eui/rpc`) — so `@weftui/dom` can resolve a [`Boundary.rpc`](https://weftui.dev/docs/reference/core#boundaryrpc) (hydrated refetch and client-first SPA mount) without depending on this package or `@effect/rpc`. Pass the same merged `group` the server wires into [`RouterServer`](#routerserver). **Scoped** — it must outlive the mount, so provide it through a `ManagedRuntime`:
228
+
229
+ ```typescript
230
+ const runtime = ManagedRuntime.make(RouterLive(App, { rpc: { group: StockRpcs } }));
231
+ void runtime.runPromise(hydrate(RouterApp(App), root));
232
+ ```
233
+
234
+ ### Programmatic navigation
235
+
236
+ ```typescript
237
+ navigate<Path, Query>(ref: RouteNode<Path, Query>, ...args): Effect<void, never, Router>;
238
+ push(to: string): Effect<void, never, Router>;
239
+ replace(to: string): Effect<void, never, Router>;
240
+ back(): Effect<void>;
241
+ forward(): Effect<void>;
242
+ setQuery(query: Record<string, unknown>, options?: NavigateOptions): Effect<void, never, Router>;
243
+ patchQuery(partial: Record<string, unknown>, options?: NavigateOptions): Effect<void, never, Router>;
244
+ ```
245
+
246
+ Typed programmatic navigation, all run within the `RouterLive` layer (except `back`/`forward`, which only touch `window.history`):
247
+
248
+ - **`navigate(ref, args)`** — go to a leaf route `ref` with typed `{ path, query }`, building the URL via [`href`](#href) (so it round-trips with `match`) and pushing — or, with `options.replace`, replacing — the History entry. Same requiredness rules as `href`: `path` required when the route has path params, `query` optional when every field is optional.
249
+ - **`push` / `replace`** — go to a raw `path + search` string, pushing or replacing the History entry.
250
+ - **`back` / `forward`** — step through History (`history.go(±1)`); the `popstate` handler resyncs.
251
+ - **`setQuery` / `patchQuery`** — change the current route's query in place, re-encoding through the matched leaf's `querySchema` (the path is kept, so the leaf stays mounted and reactive `queryStream` readers update). `setQuery` replaces the query; `patchQuery` merges. No-op when no route is matched.
252
+
253
+ ```typescript
254
+ import { navigate, patchQuery, push } from "@weftui/router/client";
255
+
256
+ yield * navigate(userRoute, { path: { id: 42 }, query: { tab: "posts" } });
257
+ yield * push("/users/1/posts?sort=new");
258
+ yield * patchQuery({ sort: "old" }); // keeps the current path + other query fields
259
+ ```
260
+
261
+ ### `installLinkInterceptor`
262
+
263
+ ```typescript
264
+ installLinkInterceptor(def: RouterDef, navigate: (to: string) => Effect<void>): Effect<void, never, Scope>;
265
+ ```
266
+
267
+ The delegated click interceptor `RouterLive` installs for you. Exposed for advanced/manual wiring. Intercepts plain same-origin clicks whose href resolves to a route (resolved against `def`); leaves modified clicks, `target=_blank`, `download`, external origins, same-document navigations, and non-matching hrefs to the browser.
268
+
269
+ ## Server — `@weftui/router/server`
270
+
271
+ ### `RouterServer`
272
+
273
+ A namespace for server-side rendering of a `RouterDef`.
274
+
275
+ ```typescript
276
+ RouterServer.render(def, options: { document; rpc; url }): Effect<{ html; status }, Error>;
277
+ RouterServer.toWebHandler(def, options: { document; rpc }): (request: Request) => Promise<Response>;
278
+ ```
279
+
280
+ Dispatch runs through the `def.httpApi` spine via `HttpApiBuilder`: platform owns request→leaf matching and path/query decode, then each leaf handler builds a fixed-match server `Router` and renders the universal outlet to hydratable HTML.
281
+
282
+ - **`toWebHandler`** returns a Web `fetch`-style handler `(Request) => Promise<Response>` that dispatches through `HttpApiBuilder.toWebHandler` and replies `text/html`. Suitable for bridging into a dev server (Vite) or any Web-platform server.
283
+ - **`render`** drives that handler for a single `url`, returning `{ html, status }` with `<!DOCTYPE html>` prepended. `status` is sourced from the platform pipeline — `200`, or `404` for a no-match / a page-raised `RouterNotFound`.
284
+ - **`document`** is a [`ComponentSlot`](#types) that splices the app via `yield* Router.Outlet`; the router provides both `Router.Outlet` (the app, per request) and `Router`.
285
+ - **`rpc`** is the app's [`Boundary.rpc`](https://weftui.dev/docs/reference/core#boundaryrpc) data foundation: `{ group: RpcGroup<any>; handlers: Layer<any, never, never> }` — the merged `RpcGroup` (shared with the client) plus its server-only handler Layer (`group.toLayer(...)` ⊕ its dependencies). `toWebHandler` serves the handlers at `POST /_eui/rpc` (so a client refetch / client-first mount re-runs them on the server), and an in-process client over the same handlers (backing the [`AppRpcClientTag`](https://weftui.dev/docs/reference/core#apprpcclienttag) seam) resolves SSR boundaries in-process — never over the network.
286
+
287
+ > The `HttpApi` is not generated on the server — it is built once when the tree is sealed (`buildHttpApi` during `Router.router`) and lives on `def.httpApi` as the single source of truth both the server dispatch and the client matcher / derived `HttpApiClient` read from.
288
+
289
+ ## Errors
290
+
291
+ ### `RouterNotFound`
292
+
293
+ `Schema.TaggedError` with an optional `path: string`. Raised by [`notFound`](#notfound) or when no route matches. Caught by the router's internal not-found boundary; export it to place your own `Boundary.catchTag("RouterNotFound", …)` (a nearer user boundary wins).
294
+
295
+ ### `RouterParamsError`
296
+
297
+ `Schema.TaggedError` with `source: "path" | "query"` and `keys: readonly string[]`. Raised by `Router.params` / `Router.query` when the live match doesn't satisfy the requested fields. Bubbles into the tree's aggregate error channel.
298
+
299
+ ### `notFound`
300
+
301
+ ```typescript
302
+ notFound(path?: string): Effect<never, RouterNotFound>;
303
+ ```
304
+
305
+ Short-circuits the current page render with a `RouterNotFound`. Callable from any page or layout `component`; the server responds with HTTP 404.
306
+
307
+ ### `isRouterNotFound`
308
+
309
+ ```typescript
310
+ isRouterNotFound(u: unknown): u is RouterNotFound;
311
+ ```
312
+
313
+ Type guard recognising a `RouterNotFound` value regardless of its prototype.
314
+
315
+ ## Compilation & matching (advanced)
316
+
317
+ These power the runtime and are exported for tooling/tests; most apps never touch them directly.
318
+
319
+ | Export | Description |
320
+ | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
321
+ | `compile(def)` | Walks a tree into flat `CompiledLeaf`s with merged path/query schemas and layout chains. |
322
+ | `buildHttpApi(leaves)` | Builds the authoritative `HttpApi` (one `"pages"` group, a GET endpoint per leaf with `setPath`/`setUrlParams` + 404). Called by `Router.router`; the result is `def.httpApi`. |
323
+ | `leafRegistry` | `WeakMap<RouteNode, CompiledLeaf>` read by `href` to resolve a leaf's pattern/schemas. |
324
+ | `match(compiled, url)` | Resolves a URL to a `RouteMatch` (`Matched` with decoded `path`/`query`, or `NotFound`). |
325
+ | `compileMatchers(compiled)` | Precompiles per-leaf regex matchers. |
326
+
327
+ ## Types
328
+
329
+ | Type | Description |
330
+ | ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
331
+ | `RouterDef<E, R>` | A sealed, compiled router. The unit passed to client and server; phantom `E`/`R` carry the tree's aggregate channels. |
332
+ | `RouteNode<Path, Query, E, R>` / `LayoutNode<E, R>` / `TreeNode` | Authored tree nodes. |
333
+ | `ComponentSlot<N>` | A `(props: any) => N` callable producing a `Node`; accepts a plain thunk or a `Component.make` / `Component.gen` component. |
334
+ | `RouteHandlerProps<Path, Query>` | The `{ path, query }` decoded match a leaf `component` may declare as handler-arg props. |
335
+ | `RouteMatch` | `{ _tag: "Matched"; leaf; path; query; url }` or `{ _tag: "NotFound"; url }`. |
336
+ | `HrefArgs<Path, Query>` | The `href` argument object; `path`/`query` become optional when their decoded type has no required keys. |
337
+ | `NavigateOptions` | `{ replace?: boolean }` — for `navigate` / `setQuery` / `patchQuery` and `Router.navigate`. |
338
+ | `RouterHttpApiClient` | The platform `HttpApiClient` derived from a router's `HttpApi` spine (carried opaquely on `Router.httpApiClient`). |
339
+ | `Fields` / `FieldsType<F>` | `Schema.Struct.Fields` and the `Type` side of its `Schema.Struct`. |
340
+ | `Compiled` / `CompiledLeaf` / `CompiledLayout` | The compiled tree shapes. |
341
+ | `RouterOptions` | Options for `Router.router` (`notFound`). |
342
+
343
+ ## See also
344
+
345
+ - [Add Routing](https://weftui.dev/docs/how-to/add-routing) — the narrative guide to authoring a route tree
346
+ - [Split Routes Lazily](https://weftui.dev/docs/how-to/split-routes-lazily) · [Show Navigation Progress](https://weftui.dev/docs/how-to/show-navigation-progress) — `Router.lazy` and `Router.navigating`
347
+ - [`@weftui/core` reference](https://weftui.dev/docs/reference/core) · [`@weftui/dom` reference](https://weftui.dev/docs/reference/dom)
348
+ - [`packages/router/router.specs.md`](https://github.com/stefvw93/weft/blob/main/packages/router/router.specs.md) — the full specification
@@ -0,0 +1,48 @@
1
+ ---
2
+ title: Your First App
3
+ order: 1
4
+ section: tutorial
5
+ description: Install Weft, build a component with the h namespace, and mount it — the smallest possible Weft app.
6
+ ---
7
+
8
+ # Your First App
9
+
10
+ This is the first step of a four-part tutorial that builds up a Weft app from a static component to a server-rendered, error-handled one. By the end you will have touched every core idea; each step adds exactly one.
11
+
12
+ We assume you know [Effect](https://effect.website/docs/getting-started/introduction) fundamentals — Weft is Effect for the UI, and we will not re-explain `Effect.gen`, services, or streams from scratch.
13
+
14
+ ## Install
15
+
16
+ ```bash
17
+ npm install @weftui/core @weftui/dom effect
18
+ ```
19
+
20
+ `@weftui/core` gives you the element builders and combinators; `@weftui/dom` renders them (its `./client` entry mounts in the browser). `effect` is the peer everything is built on.
21
+
22
+ ## Build a component
23
+
24
+ A **component is a plain function you call** — there is no JSX and no `<Component/>` deferral. It returns a `Node`, which is just an `Effect` that produces a DOM node:
25
+
26
+ ```typescript
27
+ import { h } from "@weftui/core";
28
+ import { mount } from "@weftui/dom/client";
29
+ import { Effect } from "effect";
30
+
31
+ function App() {
32
+ return h.div({ class: "app" }, [h.h1("Hello, Weft"), h.p("A minimal app.")]);
33
+ }
34
+
35
+ void Effect.runPromise(mount(App(), document.getElementById("root")!));
36
+ ```
37
+
38
+ The `h` namespace is the entry point: every property (`h.div`, `h.h1`, `h.button`, …) is a builder for that HTML tag. A builder takes optional props and children, and returns a `Node`.
39
+
40
+ ## What just happened
41
+
42
+ - `App()` returns a **`Node<never, never>`** — the two type parameters are the error channel (`E`) and the requirement channel (`R`), both `never` here because this component neither fails nor needs a service. As your app grows, those channels accumulate what it can fail with and what it depends on. That is the whole point of Weft's types — see [The Rendering Model](https://weftui.dev/docs/explanation/rendering-model).
43
+ - `mount(node, target)` renders the node into `target`, building real DOM and starting any reactive streams. It returns an `Effect<MountHandle>`; run it with your Effect runtime (`Effect.runPromise` is fine for a script).
44
+ - The component function runs **once**. Nothing here re-runs on a timer or a state change — because there is no state yet. That comes next.
45
+
46
+ ## Next
47
+
48
+ - [Reactivity →](https://weftui.dev/docs/tutorial/02-reactivity) — make the UI change over time with `SubscriptionRef` and streams
@@ -0,0 +1,57 @@
1
+ ---
2
+ title: Reactivity
3
+ order: 2
4
+ section: tutorial
5
+ description: Add component-local state with SubscriptionRef and weave its stream of changes into the tree so the DOM updates in place.
6
+ ---
7
+
8
+ # Reactivity
9
+
10
+ [Previously](https://weftui.dev/docs/tutorial/01-your-first-app) we mounted a static component. Now we make it change over time — the defining move in Weft: **weave a stream through the tree, and only that point updates.**
11
+
12
+ ## Local state with `SubscriptionRef`
13
+
14
+ Use Effect's `SubscriptionRef` for component-local state. Its `.changes` property is a `Stream` that emits the current value and then every update. Pass that stream as a child or prop and the DOM at that spot becomes live:
15
+
16
+ ```typescript
17
+ import { h } from "@weftui/core";
18
+ import { mount } from "@weftui/dom/client";
19
+ import { Effect, SubscriptionRef } from "effect";
20
+
21
+ const Counter = () =>
22
+ Effect.gen(function* () {
23
+ const count = yield* SubscriptionRef.make(0);
24
+
25
+ return yield* h.div([
26
+ h.span([count.changes]),
27
+ h.button({ onclick: () => SubscriptionRef.update(count, (n) => n + 1) }, "+"),
28
+ h.button({ onclick: () => SubscriptionRef.update(count, (n) => n - 1) }, "-"),
29
+ ]);
30
+ });
31
+
32
+ void Effect.runPromise(mount(Counter(), document.getElementById("root")!));
33
+ ```
34
+
35
+ `Effect.gen` lets you `yield*` the `SubscriptionRef` to set up state **before** building the tree. Because a `Node` is an `Effect`, the component body is an ordinary generator — no hooks, no dependency arrays.
36
+
37
+ ## The key idea: the body runs once
38
+
39
+ The `Counter` function runs **exactly once**. It creates the ref, builds the tree, and returns. After that, nothing re-invokes it — the only thing that changes the DOM is the `count.changes` stream woven into the `h.span`. When you click `+`, `SubscriptionRef.update` pushes a new value, the stream emits, and the renderer patches _just that span's text_ in place. No diff, no re-render, no sibling touched.
40
+
41
+ This is what "streams are the weft" means in practice: reactivity is local to exactly where you thread a stream. Everything else is static. The full model is [The Rendering Model](https://weftui.dev/docs/explanation/rendering-model); the vocabulary of stream-shaped values is [Reactive Primitives](https://weftui.dev/docs/explanation/reactive-primitives).
42
+
43
+ > **Note.** `[count.changes]` — the stream is passed as a child array. Static values (`"Hello"`, `5`) work in the same position and simply never change. The rule is uniform: a plain value is static, a stream-shaped value is reactive.
44
+
45
+ ## Deriving values
46
+
47
+ Because `.changes` is a `Stream`, you shape reactive text with ordinary stream operators:
48
+
49
+ ```typescript
50
+ h.span([Stream.map(count.changes, (n) => `Count: ${n}`)]);
51
+ ```
52
+
53
+ Anywhere you would compute a derived value, map the stream instead — the derivation stays reactive.
54
+
55
+ ## Next
56
+
57
+ - [Services and Async →](https://weftui.dev/docs/tutorial/03-services-and-async) — pull dependencies from the environment and render async loading states
@@ -0,0 +1,77 @@
1
+ ---
2
+ title: Services and Async
3
+ order: 3
4
+ section: tutorial
5
+ description: Give handlers access to services from the environment, and render async loading states by returning a Stream of nodes.
6
+ ---
7
+
8
+ # Services and Async
9
+
10
+ [So far](https://weftui.dev/docs/tutorial/02-reactivity) our state has been self-contained. Real apps talk to services and wait on async work. Both fall out of the same fact — a `Node` is an `Effect` — so both use plain Effect.
11
+
12
+ ## Handlers that use services
13
+
14
+ An event handler can **return an Effect**, and that Effect runs in the component's environment — so it can read any service you provide at the mount boundary:
15
+
16
+ ```typescript
17
+ import { h } from "@weftui/core";
18
+ import { mount } from "@weftui/dom/client";
19
+ import { Context, Effect, Layer, pipe } from "effect";
20
+
21
+ class Logger extends Context.Tag("Logger")<
22
+ Logger,
23
+ { log: (message: string) => Effect.Effect<void> }
24
+ >() {}
25
+
26
+ const LoggerLive = Layer.succeed(Logger, {
27
+ log: (message) => Effect.sync(() => console.log(message)),
28
+ });
29
+
30
+ const LogButton = () =>
31
+ h.button(
32
+ {
33
+ onclick: () =>
34
+ Effect.gen(function* () {
35
+ const logger = yield* Logger;
36
+ yield* logger.log("Button clicked");
37
+ }),
38
+ },
39
+ "Log",
40
+ );
41
+
42
+ // Provide the layer at mount — every handler in the tree can now read Logger.
43
+ void Effect.runPromise(
44
+ pipe(mount(LogButton(), document.getElementById("root")!), Effect.provide(LoggerLive)),
45
+ );
46
+ ```
47
+
48
+ `Logger` entered the tree's requirement channel the moment `LogButton` read it, and you discharged it **once**, at `mount`, with `Effect.provide`. Provide too little and it is a compile error at the mount call. This is Weft's entire dependency-injection story — it is just Effect's. The deeper treatment is [Services and Context](https://weftui.dev/docs/explanation/services-and-context).
49
+
50
+ ## Async loading states
51
+
52
+ A component can return a **`Stream<Node>`** to show different content over time. Sequence a loading placeholder before the resolved content with `Stream.concat`:
53
+
54
+ ```typescript
55
+ import { h } from "@weftui/core";
56
+ import { mount } from "@weftui/dom/client";
57
+ import { Effect, Stream } from "effect";
58
+
59
+ const AsyncGreeting = ({ name }: { name: string }) =>
60
+ Stream.concat(
61
+ Stream.make(h.span("Loading…")),
62
+ Stream.fromEffect(
63
+ Effect.gen(function* () {
64
+ yield* Effect.sleep("1 second");
65
+ return yield* h.span(`Hello, ${name}!`);
66
+ }),
67
+ ),
68
+ );
69
+
70
+ void Effect.runPromise(mount(AsyncGreeting({ name: "World" }), document.getElementById("root")!));
71
+ ```
72
+
73
+ The stream emits the loading node first, then the resolved node — the renderer swaps the DOM in place on the second emission. This is the raw mechanism; for coordinating _several_ async regions with a single fallback, reach for [`Boundary.suspend`](https://weftui.dev/docs/explanation/boundaries-and-suspense), which you will meet in the next step.
74
+
75
+ ## Next
76
+
77
+ - [Errors and Server Rendering →](https://weftui.dev/docs/tutorial/04-errors-and-server) — catch failures with boundaries and render on the server
@@ -0,0 +1,61 @@
1
+ ---
2
+ title: Errors and Server Rendering
3
+ order: 4
4
+ section: tutorial
5
+ description: Catch rendering-path failures with Boundary, then render on the server and hydrate — the last step of the tutorial.
6
+ ---
7
+
8
+ # Errors and Server Rendering
9
+
10
+ The final step. [We can now](https://weftui.dev/docs/tutorial/03-services-and-async) use services and async; here we handle what happens when async work **fails**, and how the same tree renders on the server.
11
+
12
+ ## Error boundaries
13
+
14
+ A component's failures accumulate on its `E` channel. Wrap a subtree in a `Boundary.*` variant to intercept them and render a fallback instead of failing the mount:
15
+
16
+ ```typescript
17
+ import { Boundary, h } from "@weftui/core";
18
+ import { Data, Effect } from "effect";
19
+
20
+ class ApiError extends Data.TaggedError("ApiError")<{ status: number }> {}
21
+
22
+ const SafeWidget = () =>
23
+ Boundary.catchAll({ fallback: (e) => h.div({ class: "error" }, `Request failed: ${e.status}`) }, [
24
+ Effect.fail(new ApiError({ status: 503 })),
25
+ ]);
26
+ ```
27
+
28
+ There are six failure-catch variants — `catchAll`, `catchAllCause`, `catchTag`, `catchTags`, `catchSome`, `catchIf` — mirroring Effect's own error operators. A failure that a boundary does not match re-raises to the **nearest enclosing** boundary; if none catches it, the mount fails. The conceptual model (and why the boundary's type reflects exactly which failures are handled) is [Boundaries and Suspense](https://weftui.dev/docs/explanation/boundaries-and-suspense).
29
+
30
+ ## Render on the server
31
+
32
+ The same component tree renders to HTML on the server and **hydrates in place** on the client — no re-render, no flash. The server produces markup (plus inline data), and `hydrate` adopts that existing DOM and resumes reactivity:
33
+
34
+ ```typescript
35
+ // server entry
36
+ import { renderToStringHydratable } from "@weftui/dom/server";
37
+ import { Effect } from "effect";
38
+ import { App } from "./app";
39
+
40
+ export const render = () => Effect.runPromise(renderToStringHydratable(App()));
41
+ ```
42
+
43
+ ```typescript
44
+ // client entry
45
+ import { hydrate } from "@weftui/dom/client";
46
+ import { Effect } from "effect";
47
+ import { App } from "./app";
48
+
49
+ void Effect.runPromise(hydrate(App(), document.getElementById("root")!));
50
+ ```
51
+
52
+ The same side-effect-free `App` is imported by both entries. For server-resolved data that replays into the client without a second request, `Boundary.rpc` extends this model — resolve an rpc on the server, serialize its result into the HTML, replay it on hydrate, then keep the region live for refetch.
53
+
54
+ ## You're done
55
+
56
+ You have built up every core idea: components and `h`, reactive state and streams, services and async, boundaries and SSR. Where to go next depends on what you are doing:
57
+
58
+ - **Understand the model** → [The Rendering Model](https://weftui.dev/docs/explanation/rendering-model), [The Combinator API](https://weftui.dev/docs/explanation/combinator-api), [Reactive Primitives](https://weftui.dev/docs/explanation/reactive-primitives)
59
+ - **Get a task done** → [Author Components](https://weftui.dev/docs/how-to/author-components), [Render on the Server](https://weftui.dev/docs/how-to/render-on-the-server), [Load Data with RPC](https://weftui.dev/docs/how-to/load-data-with-rpc), [Add Routing](https://weftui.dev/docs/how-to/add-routing)
60
+ - **Look up an API** → [`@weftui/core`](https://weftui.dev/docs/reference/core), [`@weftui/dom`](https://weftui.dev/docs/reference/dom), [`@weftui/router`](https://weftui.dev/docs/reference/router)
61
+ - **Read runnable code** → [examples/](https://github.com/stefvw93/weft/tree/main/examples)
package/package.json CHANGED
@@ -1,15 +1,29 @@
1
1
  {
2
2
  "name": "@weftui/dom",
3
- "version": "0.26.0",
4
- "description": "Client-side DOM renderer for Weft",
3
+ "version": "0.26.2",
4
+ "description": "DOM renderer for Weft — mount and hydrate in the browser, render to string or stream on the server",
5
+ "keywords": [
6
+ "dom",
7
+ "effect",
8
+ "hydration",
9
+ "renderer",
10
+ "ssr",
11
+ "weft"
12
+ ],
13
+ "homepage": "https://weftui.dev",
14
+ "bugs": {
15
+ "url": "https://github.com/stefvw93/weft/issues"
16
+ },
5
17
  "license": "MIT",
6
18
  "author": "Stef van Wijchen",
7
19
  "repository": {
8
20
  "type": "git",
9
- "url": "https://github.com/stefvw93/weft"
21
+ "url": "https://github.com/stefvw93/weft",
22
+ "directory": "packages/dom"
10
23
  },
11
24
  "files": [
12
- "dist"
25
+ "dist",
26
+ "docs"
13
27
  ],
14
28
  "type": "module",
15
29
  "exports": {
@@ -30,7 +44,7 @@
30
44
  "access": "public"
31
45
  },
32
46
  "dependencies": {
33
- "@weftui/core": "0.26.0"
47
+ "@weftui/core": "0.26.2"
34
48
  },
35
49
  "devDependencies": {
36
50
  "@effect/rpc": "^0.75.1",