@weftui/router 0.26.1 → 0.26.3
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 +59 -0
- package/dist/client/index.js +6 -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 +306 -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 +352 -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 +21 -7
package/README.md
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# @weftui/router
|
|
2
|
+
|
|
3
|
+
> Universal nested router for [Weft](https://weftui.dev) — one route tree, rendered on the server and the client, with type-safe params and `href`s.
|
|
4
|
+
|
|
5
|
+
Maps a URL to a nested page tree that renders identically on the server (`@weftui/router/server`) and the client (`@weftui/router/client`). Route params and query are decoded through [Effect Schema](https://effect.website/docs/schema/introduction/), `href` builds type-safe URLs that round-trip with the matcher, layouts persist across navigations, and `Router.lazy` code-splits a branch while keeping its descriptor eager.
|
|
6
|
+
|
|
7
|
+
Three entry points mirror `@weftui/dom`: `@weftui/router` (authoring + universal nodes), `@weftui/router/client` (History-backed runtime), `@weftui/router/server` (SSR dispatch).
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm install @weftui/core @weftui/dom @weftui/router effect
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
`effect` is a peer dependency; `@weftui/core` and `@weftui/dom` provide the tree and renderer.
|
|
16
|
+
|
|
17
|
+
## Key exports
|
|
18
|
+
|
|
19
|
+
| Export | What it does |
|
|
20
|
+
| -------------------------------- | --------------------------------------------------------------------------------------- |
|
|
21
|
+
| `Router.route` / `Router.layout` | Author a leaf page (with `:param` segments + schemas) or a UI-only nesting layout. |
|
|
22
|
+
| `Router.router` | Seals a route tree into a `RouterDef` and captures the app-level not-found page. |
|
|
23
|
+
| `Router.lazy` | Code-splits a route's component into its own chunk; only the matched branch loads. |
|
|
24
|
+
| `Router.params` / `Router.query` | Read the live match (snapshot); `…Stream` variants for reactive query-in-place updates. |
|
|
25
|
+
| `Router.navigating` | Reactive `Idle`/`Navigating` signal for pending UI during deferred-commit navigation. |
|
|
26
|
+
| `href(ref, args)` | Builds a type-safe URL for a leaf route reference. |
|
|
27
|
+
| `RouterApp` / `RouterOutlet` | The universal router root node — render on both server and client. |
|
|
28
|
+
| `RouterLive` (client) | History-backed `Router` layer; also provides the `AppRpcClientTag` seam. |
|
|
29
|
+
| `RouterServer` (server) | `RouterServer.render` / `RouterServer.toWebHandler` for SSR dispatch. |
|
|
30
|
+
|
|
31
|
+
Client-side navigation to a different path resets the window scroll to the top at commit; query-only navigations and browser back/forward preserve it (the latter via the browser's native `history.scrollRestoration`).
|
|
32
|
+
|
|
33
|
+
## Example
|
|
34
|
+
|
|
35
|
+
```typescript
|
|
36
|
+
import { h } from "@weftui/core";
|
|
37
|
+
import { Router, href } from "@weftui/router";
|
|
38
|
+
import { Schema } from "effect";
|
|
39
|
+
|
|
40
|
+
const userRoute = Router.route("users/:id", {
|
|
41
|
+
path: { id: Schema.NumberFromString },
|
|
42
|
+
component: ({ path }) => h.div(`User ${path.id}`),
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const App = Router.router(userRoute, { notFound: () => h.div("Not found") });
|
|
46
|
+
|
|
47
|
+
href(userRoute, { path: { id: 42 } }); // "/users/42"
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Documentation
|
|
51
|
+
|
|
52
|
+
- Full docs: **https://weftui.dev**
|
|
53
|
+
- `@weftui/router` API reference: **https://weftui.dev/docs/reference/router**
|
|
54
|
+
- Routing guide: **https://weftui.dev/docs/how-to/add-routing**
|
|
55
|
+
- Bundled with this package: see the [`./docs`](./docs) directory in `node_modules/@weftui/router/docs` — the complete tutorial, how-to, explanation, and reference tree ships on disk for offline and agent use.
|
|
56
|
+
|
|
57
|
+
## License
|
|
58
|
+
|
|
59
|
+
MIT © Stef van Wijchen
|
package/dist/client/index.js
CHANGED
|
@@ -86,6 +86,10 @@ function RouterLive(def, options = {}) {
|
|
|
86
86
|
let committedScope;
|
|
87
87
|
let router;
|
|
88
88
|
const collectPreloads = (m) => m._tag !== "Matched" ? [] : [m.leaf.component, ...m.leaf.layoutChain.map((l) => l.component)].map(getPreload).filter((p) => p !== void 0);
|
|
89
|
+
const pathOf = (url) => {
|
|
90
|
+
const qIndex = url.indexOf("?");
|
|
91
|
+
return qIndex === -1 ? url : url.slice(0, qIndex);
|
|
92
|
+
};
|
|
89
93
|
const commitUrl = (normalized, replace) => Effect.sync(() => {
|
|
90
94
|
if (replace) window.history.replaceState(null, "", normalized);
|
|
91
95
|
else window.history.pushState(null, "", normalized);
|
|
@@ -137,8 +141,10 @@ function RouterLive(def, options = {}) {
|
|
|
137
141
|
url: normalized,
|
|
138
142
|
exit
|
|
139
143
|
});
|
|
144
|
+
const previousUrl = yield* SubscriptionRef.get(urlRef);
|
|
140
145
|
if (pushUrl) yield* commitUrl(normalized, replace);
|
|
141
146
|
yield* SubscriptionRef.set(urlRef, normalized);
|
|
147
|
+
if (pushUrl && pathOf(previousUrl) !== pathOf(normalized)) yield* Effect.sync(() => window.scrollTo(0, 0));
|
|
142
148
|
if (emitted) yield* SubscriptionRef.set(navRef, { _tag: "Idle" });
|
|
143
149
|
});
|
|
144
150
|
const navigate = (to, options) => commitTo(normalizeTo(to), true, options?.replace === true);
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Boundaries and Suspense
|
|
3
|
+
order: 4
|
|
4
|
+
section: explanation
|
|
5
|
+
description: How Weft models failure, async, and server data as boundary nodes in the same tree — failure-catch variants, Boundary.suspend, and Boundary.rpc, and how their E/R channels behave.
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Boundaries and Suspense
|
|
9
|
+
|
|
10
|
+
A **boundary** is a node that intercepts something flowing through the tree — an error, a pending async child, or a server-resolved value — and decides what the DOM shows in its place. Because a boundary is itself a `Node<E, R>` ([nodes are Effects](https://weftui.dev/docs/explanation/rendering-model)), it composes exactly like any other element: you nest it, and its children's channels flow through it under a transformation the boundary defines.
|
|
11
|
+
|
|
12
|
+
The `Boundary` namespace has three kinds. This page is the conceptual map; the [core reference](https://weftui.dev/docs/reference/core#boundary-namespace) has the full signatures.
|
|
13
|
+
|
|
14
|
+
## Failure boundaries
|
|
15
|
+
|
|
16
|
+
A component's `E` channel accumulates up the tree. A **failure boundary** is where you _discharge_ some of that `E`: it wraps children and, if one of them fails, renders a fallback instead of letting the failure propagate to the mount.
|
|
17
|
+
|
|
18
|
+
```typescript
|
|
19
|
+
import { Boundary, h } from "@weftui/core";
|
|
20
|
+
|
|
21
|
+
Boundary.catchAll({ fallback: (e) => h.div({ class: "error" }, `Failed: ${e.message}`) }, [
|
|
22
|
+
RiskyWidget(),
|
|
23
|
+
]);
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
There are six failure-catch variants, mirroring Effect's own error operators so the mental model transfers directly:
|
|
27
|
+
|
|
28
|
+
| Variant | Catches |
|
|
29
|
+
| ------------------------ | ------------------------------------------ |
|
|
30
|
+
| `catchAll` | every failure in `E` |
|
|
31
|
+
| `catchAllCause` | the full `Cause` (defects included) |
|
|
32
|
+
| `catchTag` / `catchTags` | one / several tagged errors by `_tag` |
|
|
33
|
+
| `catchSome` / `catchIf` | a selected subset, by `Option` / predicate |
|
|
34
|
+
|
|
35
|
+
The channel algebra is the whole reason they exist: `catchTag("Foo", …)` removes `Foo` from the children's `E` and adds whatever the fallback needs — so the type of the boundary node reflects exactly which failures are still live and which were handled. An unhandled failure re-raises to the **nearest enclosing** boundary; if none catches it at **mount time**, mounting fails. Boundaries nest, so an inner `catchTag` can handle a specific case while an outer `catchAll` sweeps the rest.
|
|
36
|
+
|
|
37
|
+
### Post-mount failures with no enclosing boundary
|
|
38
|
+
|
|
39
|
+
The routing above describes what happens while a node is being built. Once mounted, a reactive region — an attribute, child, or list stream, or a hydrated equivalent — keeps running for the lifetime of its scope, and it can still fail later: a `Stream` backing a `Boundary.rpc` resource might raise `RouterNotFound` after a client-side navigation, for instance. If a `BoundaryContext` encloses the region, the failure routes to it exactly as above, and the boundary's fallback swaps in.
|
|
40
|
+
|
|
41
|
+
If no boundary encloses it, there is nothing to swap to. Weft does not synthesize one: the region's DOM keeps its last rendered content, and the subscription fiber's failure exit is left **unobserved**. The Effect runtime itself then reports it — `"Fiber terminated with an unhandled error"` — because Weft raises that fiber's `FiberRef.unhandledErrorLogLevel` from the ambient default (`Debug`) to `LogLevel.Error` and annotates the log with `weft.region`, identifying the failing region by kind and identity (e.g. `attribute:class`, `child:stream-3`, `list:stream-2`, `hydrate:stream-1 (/products/42)`). This fires for typed failures and defects alike, in both dev and prod, exactly once per failing region. Interruption — the ordinary case of unmount tearing down the region's scope — is never reported; only genuine failures are.
|
|
42
|
+
|
|
43
|
+
This is deliberate: rather than a Weft-specific error-reporting config, visibility is controlled by the same knobs any Effect program uses — `Logger.withMinimumLogLevel` to filter it, `Effect.withUnhandledErrorLogLevel` to change how loudly (or quietly) unhandled fiber exits are reported elsewhere in your program. A stream that can fail and has no enclosing boundary is a stream whose failures you've chosen not to route into the UI — the log is what tells you that decision has consequences at runtime.
|
|
44
|
+
|
|
45
|
+
## Suspense boundaries
|
|
46
|
+
|
|
47
|
+
`Boundary.suspend` wraps async children and shows a `fallback` until **all** of them have emitted their first value, then swaps atomically — either everything is visible or nothing is. This prevents partial flicker when sibling async regions resolve at different times.
|
|
48
|
+
|
|
49
|
+
```typescript
|
|
50
|
+
import { Boundary, h } from "@weftui/core";
|
|
51
|
+
|
|
52
|
+
Boundary.suspend({ fallback: h.div({ class: "spinner" }, "Loading…") }, [
|
|
53
|
+
AsyncCard({ id: 1 }),
|
|
54
|
+
AsyncCard({ id: 2 }),
|
|
55
|
+
]);
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
A suspense boundary is transparent to the type channels: its node is `Node<ChildrenE, ChildrenR>` — the children's `E`/`R` pass straight through, exactly as they would for a plain `h.*` parent. It changes _timing_ (when the children become visible), not _types_.
|
|
59
|
+
|
|
60
|
+
On the server, `renderToStreamHydratable` emits the fallback inline and appends patch scripts as children resolve; on the client, `hydrate` sees through the boundary and adopts the already-resolved DOM directly.
|
|
61
|
+
|
|
62
|
+
> **Note.** There is no `Suspense` export — the API is `Boundary.suspend(props, children)`. Reach for it for async that loads **on the client**; for data that must resolve on the **server** and hydrate without a second request, use `Boundary.rpc` (below).
|
|
63
|
+
|
|
64
|
+
## The rpc boundary
|
|
65
|
+
|
|
66
|
+
`Boundary.rpc` is the server-data boundary: it resolves one `Rpc` on the server, serializes the result into the HTML, replays it on the client during `hydrate` (no second request, no flash), and then keeps the region live for `refetch`. Conceptually it is the same idea as the other boundaries — a node that decides what renders in a subtree — but the thing it intercepts is a **round-trip to a server handler**, and instead of a children array it takes a `render` function that receives a reactive [`Resource`](https://weftui.dev/docs/reference/core#resourcea).
|
|
67
|
+
|
|
68
|
+
```typescript
|
|
69
|
+
import { Boundary, h } from "@weftui/core";
|
|
70
|
+
import { Stream } from "effect";
|
|
71
|
+
|
|
72
|
+
Boundary.rpc(
|
|
73
|
+
GetStock,
|
|
74
|
+
() => ({ id: productId }),
|
|
75
|
+
(resource) => h.span([Stream.map(resource.value.changes, (s) => String(s.units))]),
|
|
76
|
+
{ fallback: h.p("loading…") },
|
|
77
|
+
);
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Unlike the failure and suspense boundaries, `Boundary.rpc` is not self-contained: it resolves through the ambient [`AppRpcClientTag`](https://weftui.dev/docs/reference/core#apprpcclienttag) seam that `@weftui/router` provides on both sides. Its channel behavior is also distinct — the rpc's typed `error` schema joins the node's `E` (replayable through an enclosing failure boundary), while `render`'s `R` passes through untouched. The full model — the contract/handler split, the four lifecycles, typed-failure replay — is a **how-to**, not repeated here: [Load Data with RPC](https://weftui.dev/docs/how-to/load-data-with-rpc).
|
|
81
|
+
|
|
82
|
+
## One tree, three interceptors
|
|
83
|
+
|
|
84
|
+
The unifying idea: failure, async pending state, and server data are not three separate subsystems bolted onto the renderer. They are three **boundary nodes** in the one tree, each intercepting a different thing flowing through it, each with channel behavior you can read off its type. That is why they nest freely — a `Boundary.catchTag` can wrap a `Boundary.rpc` to catch its typed failure, and a `Boundary.suspend` can wrap async siblings that themselves contain rpc boundaries.
|
|
85
|
+
|
|
86
|
+
## See also
|
|
87
|
+
|
|
88
|
+
- [The Rendering Model](https://weftui.dev/docs/explanation/rendering-model) — why a boundary is just a node in a static tree
|
|
89
|
+
- [`Boundary` API reference](https://weftui.dev/docs/reference/core#boundary-namespace) — every variant's signature and channel algebra
|
|
90
|
+
- [Load Data with RPC](https://weftui.dev/docs/how-to/load-data-with-rpc) — the full `Boundary.rpc` walkthrough and its four lifecycles
|
|
91
|
+
- [Render on the Server](https://weftui.dev/docs/how-to/render-on-the-server) — how suspense and rpc boundaries stream and hydrate
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: The Combinator API
|
|
3
|
+
order: 2
|
|
4
|
+
section: explanation
|
|
5
|
+
description: How h, h.fragment, and Component.gen / Component.make work; why Node is an Effect; how E and R accumulate through a tree.
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# The Combinator API
|
|
9
|
+
|
|
10
|
+
Weft builds UI trees by calling builder functions. Because component return types stay as generic `Effect.Effect<ElementDescriptor, E, R>`, the error channel (`E`) and requirements channel (`R`) propagate through the entire tree — visible to the type checker, satisfiable at the mount boundary. JSX collapses every component's return type to an opaque `JSX.Element`, erasing both channels; the combinator API exists specifically to keep them intact.
|
|
11
|
+
|
|
12
|
+
## Nodes are Effects
|
|
13
|
+
|
|
14
|
+
`Node<E, R>` is defined as:
|
|
15
|
+
|
|
16
|
+
```typescript
|
|
17
|
+
type Node<E = never, R = never> = Effect.Effect<ElementDescriptor, E, R>;
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Nodes are first-class Effects. Everything in the Effect ecosystem works on them directly:
|
|
21
|
+
|
|
22
|
+
```typescript
|
|
23
|
+
import { h } from "@weftui/core";
|
|
24
|
+
import { Effect } from "effect";
|
|
25
|
+
|
|
26
|
+
// yield* in Effect.gen — R propagates into the generator's context
|
|
27
|
+
const node = yield * h.div({ class: "container" }, "Hello");
|
|
28
|
+
|
|
29
|
+
// pipe — chain Effect operators directly
|
|
30
|
+
const provided = pipe(h.div(userStream), Effect.provide(UserServiceLive));
|
|
31
|
+
|
|
32
|
+
// Effect.flatMap — sequence node creation with async logic
|
|
33
|
+
const card = pipe(
|
|
34
|
+
fetchCard(id),
|
|
35
|
+
Effect.flatMap((data) => h.div({ class: "card" }, data.title)),
|
|
36
|
+
);
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## The `h` namespace
|
|
40
|
+
|
|
41
|
+
`h` is a proxy object where every property is an element builder. Access any HTML or SVG tag name as `h.tagName`:
|
|
42
|
+
|
|
43
|
+
```typescript
|
|
44
|
+
import { h } from "@weftui/core";
|
|
45
|
+
|
|
46
|
+
h.div({ class: "container" }, [h.span("Hello"), h.p("World")]);
|
|
47
|
+
h.input({ type: "text", placeholder: "Search..." });
|
|
48
|
+
h.button({ type: "button", onclick: () => handleClick() }, "Submit");
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Each builder accepts these call signatures:
|
|
52
|
+
|
|
53
|
+
```typescript
|
|
54
|
+
// props + children array
|
|
55
|
+
h.div(props, children: Node[])
|
|
56
|
+
|
|
57
|
+
// props + single string or number child
|
|
58
|
+
h.div(props, child: string | number)
|
|
59
|
+
|
|
60
|
+
// props only
|
|
61
|
+
h.div(props)
|
|
62
|
+
|
|
63
|
+
// children only (no props)
|
|
64
|
+
h.div(children: Node[])
|
|
65
|
+
|
|
66
|
+
// single string or number child
|
|
67
|
+
h.div("five")
|
|
68
|
+
h.div(5)
|
|
69
|
+
|
|
70
|
+
// no props, no children
|
|
71
|
+
h.div()
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### How `E` and `R` accumulate
|
|
75
|
+
|
|
76
|
+
Reactive prop values (any `Stream`, `Effect`, or `Subscribable`) contribute their channels to the node:
|
|
77
|
+
|
|
78
|
+
```typescript
|
|
79
|
+
declare const colorStream: Stream.Stream<string, never, ThemeService>;
|
|
80
|
+
|
|
81
|
+
// Node<never, ThemeService> — R comes from the stream prop
|
|
82
|
+
const box = h.div({ style: { color: colorStream } }, "Hello");
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Children contribute their channels too, and siblings union their channels:
|
|
86
|
+
|
|
87
|
+
```typescript
|
|
88
|
+
declare const nodeA: Node<never, ServiceA>;
|
|
89
|
+
declare const nodeB: Node<never, ServiceB>;
|
|
90
|
+
|
|
91
|
+
// Node<never, ServiceA | ServiceB>
|
|
92
|
+
const parent = h.div([nodeA, nodeB]);
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Static values (strings, numbers, plain functions) contribute `never` to both channels.
|
|
96
|
+
|
|
97
|
+
## `h.fragment`
|
|
98
|
+
|
|
99
|
+
`h.fragment` groups children without emitting a wrapper element. Use it when a component needs to return multiple sibling nodes:
|
|
100
|
+
|
|
101
|
+
```typescript
|
|
102
|
+
import { h } from "@weftui/core";
|
|
103
|
+
|
|
104
|
+
// Renders as three adjacent <td> elements with no wrapping element
|
|
105
|
+
const TableRow = ({ user }: { user: User }) =>
|
|
106
|
+
h.fragment([h.td(user.name), h.td(user.role), h.td(user.status)]);
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Custom components with `Component.gen` / `Component.make`
|
|
110
|
+
|
|
111
|
+
Plain functions work fine for simple components, but the `Component` factories provide type-level wiring so the caller's reactive prop types contribute their `E`/`R` to the returned node. Pick `Component.make` for a plain-function body and `Component.gen` for a generator body (when you need `yield*` to set up local state or pull from services).
|
|
112
|
+
|
|
113
|
+
```typescript
|
|
114
|
+
import { Component, h } from "@weftui/core";
|
|
115
|
+
import { Stream } from "effect";
|
|
116
|
+
|
|
117
|
+
interface ButtonProps {
|
|
118
|
+
label: string | Stream.Stream<string>;
|
|
119
|
+
onclick?: () => void;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const Button = Component.make((props: ButtonProps) =>
|
|
123
|
+
h.button({ onclick: props.onclick }, [props.label]),
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
// When called with a stream prop, the stream's R flows into the node type:
|
|
127
|
+
declare const labelStream: Stream.Stream<string, never, I18nService>;
|
|
128
|
+
|
|
129
|
+
// Node<never, I18nService>
|
|
130
|
+
const btn = Button({ label: labelStream });
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Components also accept an optional `children` argument, either as `readonly Renderable[]` or as a `(input) => readonly Renderable[]` function (render-prop pattern). `E`/`R` from children — including the array returned by a function-children call — accumulate on the resulting node.
|
|
134
|
+
|
|
135
|
+
Without `Component`, a plain function's return type is fixed at definition time and does not reflect the caller's reactive prop types.
|
|
136
|
+
|
|
137
|
+
See [component-authoring.md](https://weftui.dev/docs/how-to/author-components) for a full walkthrough.
|
|
138
|
+
|
|
139
|
+
## Suspense boundaries
|
|
140
|
+
|
|
141
|
+
`Boundary.suspend` wraps async children and shows a fallback until all of them have emitted their first value:
|
|
142
|
+
|
|
143
|
+
```typescript
|
|
144
|
+
import { Boundary, h } from "@weftui/core";
|
|
145
|
+
|
|
146
|
+
Boundary.suspend({ fallback: h.div({ class: "spinner" }, "Loading...") }, [
|
|
147
|
+
AsyncCard({ id: 1 }),
|
|
148
|
+
AsyncCard({ id: 2 }),
|
|
149
|
+
]);
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
The fallback is replaced atomically — either all children are visible or none are. This prevents partial flicker when multiple async siblings resolve at different times. The boundary's node type is `Node<ChildrenE, ChildrenR>`: the children's `E`/`R` channels accumulate onto it, exactly as they would for a plain `h.*` parent.
|
|
153
|
+
|
|
154
|
+
On the server, `renderToStreamHydratable` emits the fallback inline and appends patch scripts as children resolve. On the client, `hydrate` sees through `Boundary.suspend` boundaries and adopts the already-resolved DOM directly.
|
|
155
|
+
|
|
156
|
+
`Boundary.suspend` is one of the boundary combinators — see the [core reference](https://weftui.dev/docs/reference/core#boundarysuspend) for the full `Boundary.*` surface, including the failure-catch variants and `Boundary.rpc`.
|
|
157
|
+
|
|
158
|
+
## See also
|
|
159
|
+
|
|
160
|
+
- [The Rendering Model](https://weftui.dev/docs/explanation/rendering-model) — why a `Node` is an `Effect` and how the tree renders
|
|
161
|
+
- [Reactive Primitives](https://weftui.dev/docs/explanation/reactive-primitives) — the `Source` vocabulary that reactive props and children accept
|
|
162
|
+
- [Boundaries and Suspense](https://weftui.dev/docs/explanation/boundaries-and-suspense) — the boundary combinators as tree nodes
|
|
163
|
+
- [Author Components](https://weftui.dev/docs/how-to/author-components) — `Component.gen` / `Component.make` in practice
|
|
164
|
+
- [`@weftui/core` reference](https://weftui.dev/docs/reference/core)
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Reactive Primitives
|
|
3
|
+
order: 3
|
|
4
|
+
section: explanation
|
|
5
|
+
description: The Source<A, E, R> vocabulary; Stream, Effect, and Subscribable as prop values and children; derived streams, reactive styles, and NoPropValue.
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Reactive Primitives
|
|
9
|
+
|
|
10
|
+
The unified `Source` vocabulary is what lets static values, Effects, Streams, and Subscribables be used interchangeably wherever reactivity is supported — props, children, and style values all accept the same type.
|
|
11
|
+
|
|
12
|
+
Weft accepts a `Source` for prop values and children. Any of these is valid wherever reactivity is supported:
|
|
13
|
+
|
|
14
|
+
- A plain static value (`string`, `number`, `boolean`, ...)
|
|
15
|
+
- An `Effect.Effect<A, E, R>` — runs once and resolves to a value
|
|
16
|
+
- A `Stream.Stream<A, E, R>` — each emission replaces the previous value
|
|
17
|
+
- A `Subscribable<A, E, R>` — like a hot stream; already has a "current value"
|
|
18
|
+
|
|
19
|
+
The `Source<A, E, R>` type captures this union:
|
|
20
|
+
|
|
21
|
+
```typescript
|
|
22
|
+
type Source<A, E, R> = A | Effect.Effect<A, E, R> | Stream.Stream<A, E, R> | Subscribable<A, E, R>;
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Static values
|
|
26
|
+
|
|
27
|
+
Static props behave exactly as you'd expect — set once and never updated:
|
|
28
|
+
|
|
29
|
+
```typescript
|
|
30
|
+
h.div({ class: "container", id: "root" }, "Hello");
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Effect props
|
|
34
|
+
|
|
35
|
+
When a prop value is an `Effect`, it runs once and the resulting value is applied:
|
|
36
|
+
|
|
37
|
+
```typescript
|
|
38
|
+
const username = Effect.map(fetchProfile(), (p) => p.name);
|
|
39
|
+
|
|
40
|
+
// Renders the username once it resolves
|
|
41
|
+
h.span([username]);
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
The `E` and `R` channels of the Effect flow into the node's own channels.
|
|
45
|
+
|
|
46
|
+
## Stream props and children
|
|
47
|
+
|
|
48
|
+
Streams are the primary reactive primitive. Each emission replaces the previous value in the DOM — no diffing, direct DOM update:
|
|
49
|
+
|
|
50
|
+
```typescript
|
|
51
|
+
import { SubscriptionRef, Stream } from "effect";
|
|
52
|
+
|
|
53
|
+
const count = yield * SubscriptionRef.make(0);
|
|
54
|
+
|
|
55
|
+
// count.changes is a Stream<number> — each new value updates the text node
|
|
56
|
+
h.span([count.changes]);
|
|
57
|
+
|
|
58
|
+
// Stream as a prop — each emission sets the attribute
|
|
59
|
+
const isDisabled = Stream.map(count.changes, (n) => n >= 10);
|
|
60
|
+
h.button({ disabled: isDisabled }, "Submit");
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Streams can also supply entire child arrays. Each emission replaces the previous set of children:
|
|
64
|
+
|
|
65
|
+
```typescript
|
|
66
|
+
const todos = yield * SubscriptionRef.make<string[]>([]);
|
|
67
|
+
|
|
68
|
+
h.ul([Stream.map(todos.changes, (list) => list.map((item) => h.li(item)))]);
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Derived streams
|
|
72
|
+
|
|
73
|
+
Because `.changes` is a plain `Stream`, the full Stream API applies:
|
|
74
|
+
|
|
75
|
+
```typescript
|
|
76
|
+
const count = yield * SubscriptionRef.make(0);
|
|
77
|
+
|
|
78
|
+
const doubled = Stream.map(count.changes, (n) => n * 2);
|
|
79
|
+
const formatted = Stream.map(count.changes, (n) => `Count: ${n}`);
|
|
80
|
+
const isHigh = Stream.map(count.changes, (n) => n > 10);
|
|
81
|
+
|
|
82
|
+
h.div([
|
|
83
|
+
h.p([count.changes]),
|
|
84
|
+
h.p([doubled]),
|
|
85
|
+
h.p([formatted]),
|
|
86
|
+
h.p({ style: { color: Stream.map(isHigh, (b) => (b ? "red" : "black")) } }, "Status"),
|
|
87
|
+
]);
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Multiple refs can be combined with `Stream.zipLatestWith`, `Stream.merge`, or other combinators:
|
|
91
|
+
|
|
92
|
+
```typescript
|
|
93
|
+
const firstName = yield * SubscriptionRef.make("");
|
|
94
|
+
const lastName = yield * SubscriptionRef.make("");
|
|
95
|
+
|
|
96
|
+
const fullName = Stream.zipLatestWith(firstName.changes, lastName.changes, (first, last) =>
|
|
97
|
+
`${first} ${last}`.trim(),
|
|
98
|
+
);
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Reactive styles
|
|
102
|
+
|
|
103
|
+
The `style` prop accepts the same `Source` vocabulary at any level:
|
|
104
|
+
|
|
105
|
+
```typescript
|
|
106
|
+
// Individual property as a stream
|
|
107
|
+
h.div({
|
|
108
|
+
style: {
|
|
109
|
+
color: colorStream, // Stream<string>
|
|
110
|
+
opacity: opacityStream, // Stream<number>
|
|
111
|
+
fontWeight: "bold", // static
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// Entire style object as a stream
|
|
116
|
+
h.div({ style: styleObjectStream });
|
|
117
|
+
|
|
118
|
+
// Combine a whole-object stream with a static property.
|
|
119
|
+
// A whole-object stream replaces every property on each emit, so fold the
|
|
120
|
+
// static value into each emitted object with Stream.map — you cannot spread the
|
|
121
|
+
// Stream itself into a style object (that copies the Stream's internals, not
|
|
122
|
+
// its emitted style keys).
|
|
123
|
+
h.div({
|
|
124
|
+
style: Stream.map(styleObjectStream, (s) => ({
|
|
125
|
+
...s, // reactive properties
|
|
126
|
+
transition: "all 0.3s", // static, applied on every emit
|
|
127
|
+
})),
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// For a mix of static and per-property reactive values, use per-property
|
|
131
|
+
// streams alongside static siblings instead:
|
|
132
|
+
h.div({
|
|
133
|
+
style: {
|
|
134
|
+
transform: transformStream, // reactive, per-property
|
|
135
|
+
transition: "all 0.3s", // static
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
## NoPropValue
|
|
141
|
+
|
|
142
|
+
When a `Stream` prop ends before emitting, the renderer raises a `NoPropValue` tagged error. This carries an optional `key` field identifying which prop triggered it:
|
|
143
|
+
|
|
144
|
+
```typescript
|
|
145
|
+
// Handle at the mount boundary if needed. `Effect.catchTag` matches the error
|
|
146
|
+
// by its string tag, so no `NoPropValue` import is required here.
|
|
147
|
+
pipe(
|
|
148
|
+
mount(App(), root),
|
|
149
|
+
Effect.catchTag("NoPropValue", (e) =>
|
|
150
|
+
Effect.logWarning(`Prop stream ended before emitting: ${e.key}`),
|
|
151
|
+
),
|
|
152
|
+
);
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
In practice you only encounter `NoPropValue` if you use a finite `Stream` as a prop and it ends before emitting — e.g., `Stream.empty` or `Stream.take(0, stream)`. Most usage with `SubscriptionRef.changes` or infinite streams never raises it.
|
|
156
|
+
|
|
157
|
+
## See also
|
|
158
|
+
|
|
159
|
+
- [The Rendering Model](https://weftui.dev/docs/explanation/rendering-model) — streams as the weft woven through a static tree
|
|
160
|
+
- [The Combinator API](https://weftui.dev/docs/explanation/combinator-api) — how reactive props and children contribute `E`/`R`
|
|
161
|
+
- [Style Reactively](https://weftui.dev/docs/how-to/style-reactively) and [Render Keyed Lists](https://weftui.dev/docs/how-to/render-keyed-lists) — reactive props and collections in practice
|
|
162
|
+
- [`Source` reference](https://weftui.dev/docs/reference/core#source-namespace) — the `Source` type and `Source.toSubscribable`
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: The Rendering Model
|
|
3
|
+
order: 1
|
|
4
|
+
section: explanation
|
|
5
|
+
description: Why Weft has no virtual DOM — nodes are Effects, streams are the live thread woven through a static tree, and hydration adopts server DOM in place.
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# The Rendering Model
|
|
9
|
+
|
|
10
|
+
Weft renders UI by **weaving streams through a static tree**. There is no virtual DOM, no diff, no reconciler comparing two trees each frame. This page explains the model that makes that work — and why it falls out of one definition.
|
|
11
|
+
|
|
12
|
+
## Nodes are Effects
|
|
13
|
+
|
|
14
|
+
The whole library rests on a single equation:
|
|
15
|
+
|
|
16
|
+
```typescript
|
|
17
|
+
type Node<E = never, R = never> = Effect.Effect<ElementDescriptor, E, R>;
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Every element in a Weft tree **is an Effect**. `h.div(...)`, a component's return value, a boundary — each is an `Effect` that, when run, produces an element descriptor. Two consequences follow immediately, and they shape everything else:
|
|
21
|
+
|
|
22
|
+
1. **The error (`E`) and requirement (`R`) channels accumulate through the tree.** A child that reads a service, or a prop backed by a failible stream, contributes its `R` and `E` to its parent, which contributes to _its_ parent, up to the mount boundary. The type of your app node is the exact union of everything it needs and everything it can fail with — visible to the type checker, satisfiable exactly once, at `mount`/`hydrate`. See [The Combinator API](https://weftui.dev/docs/explanation/combinator-api) for how the accumulation works mechanically.
|
|
23
|
+
2. **Every Effect combinator applies to a node directly.** `Effect.provide`, `Effect.flatMap`, `Effect.gen`, `Effect.catchAll` — none of them are special-cased for UI. A node is an ordinary Effect, so the entire Effect ecosystem composes with your view for free.
|
|
24
|
+
|
|
25
|
+
JSX collapses every component to an opaque `JSX.Element`, erasing both channels. Weft keeps them, and that is the point of the whole design. (There is [no JSX](https://weftui.dev/docs/explanation/combinator-api) here — components are plain functions you _call_.)
|
|
26
|
+
|
|
27
|
+
## Warp and weft
|
|
28
|
+
|
|
29
|
+
The name is the metaphor. On a loom, the **warp** is the set of fixed threads held under tension; the **weft** is the live thread drawn back and forth across them to form the cloth.
|
|
30
|
+
|
|
31
|
+
- Your **component tree is the warp** — the structure, fixed for the lifetime of a mounted region.
|
|
32
|
+
- **Streams are the weft** — the live values drawn across that structure. A `Stream`, `Effect`, or `Subscribable` used as a prop value or child is a thread woven through a specific point in the tree.
|
|
33
|
+
|
|
34
|
+
When a stream emits, only the DOM at _that_ point updates. Nothing above it re-runs; no sibling is touched; there is no tree to diff because the structure never changed — only a value threaded through one hole in it did. This is why Weft needs no virtual DOM: **the reactivity is local by construction.** The vocabulary of stream-shaped values (and how their channels flow) is [Reactive Primitives](https://weftui.dev/docs/explanation/reactive-primitives).
|
|
35
|
+
|
|
36
|
+
> **Note.** "Only that point updates" is the default, not a manual optimization. You do not memoize regions or declare dependencies — a value is reactive exactly where you thread a stream, and static everywhere else.
|
|
37
|
+
|
|
38
|
+
## Streams drive all updates
|
|
39
|
+
|
|
40
|
+
There is no `setState`, no render-triggering scheduler, no "re-render this component." A region of the DOM is live **if and only if** a stream is woven into it. To make something update, you thread a stream through it; to keep something static, you pass a plain value. The renderer subscribes to each woven stream and patches its target in place on every emission — reusing the existing DOM node, patching text and attributes rather than recreating elements (identity, focus, and typed input survive an update).
|
|
41
|
+
|
|
42
|
+
This also fixes the update _shape_. Because the structure is fixed, an update is always "new value into a known hole," never "reconcile these two trees." Even list rendering — where the number of children genuinely varies — is expressed as a keyed region ([`List.each`](https://weftui.dev/docs/how-to/render-keyed-lists)) that reconciles by key rather than by structural diff.
|
|
43
|
+
|
|
44
|
+
## One tree, two sides, hydrate in place
|
|
45
|
+
|
|
46
|
+
The same component tree renders on the server and the client:
|
|
47
|
+
|
|
48
|
+
- On the **server**, the tree renders to an HTML string (or a streaming response) via `@weftui/dom/server`. The _hydratable_ renderers additionally emit the inline data each reactive region needs to resume.
|
|
49
|
+
- On the **client**, `hydrate()` walks that server-rendered DOM and **adopts it in place** — it wires up reactivity and event handlers on the existing nodes rather than re-rendering. The first client production matches the adopted DOM exactly, so nothing is mutated and there is no flash.
|
|
50
|
+
|
|
51
|
+
Because the same `Node<E, R>` describes both passes, there is nothing to keep in sync: the server output and the client's first render are the _same tree_ run in two environments. Services flow from the mount (or the router's render-time context) through the tree to wherever a component reads them, on both sides. The mechanics of the two-sided render live in [Render on the Server](https://weftui.dev/docs/how-to/render-on-the-server); the service flow is [Services and Context](https://weftui.dev/docs/explanation/services-and-context).
|
|
52
|
+
|
|
53
|
+
## Why this matters
|
|
54
|
+
|
|
55
|
+
- **No diff cost.** Updates are O(changed value), not O(tree). There is no reconciliation pass to pay for.
|
|
56
|
+
- **Local reasoning.** A stream woven at one point cannot affect another. What is reactive is exactly what you made reactive.
|
|
57
|
+
- **Type-honest edges.** The app node's `E`/`R` is the whole app's error and dependency surface, checked at compile time and discharged once at the edge.
|
|
58
|
+
- **Flash-free SSR by construction.** Hydration adopts rather than replaces, because the tree is identical on both sides.
|
|
59
|
+
|
|
60
|
+
## See also
|
|
61
|
+
|
|
62
|
+
- [The Combinator API](https://weftui.dev/docs/explanation/combinator-api) — how `E`/`R` accumulate; why `Node` is an `Effect`; `h` and components
|
|
63
|
+
- [Reactive Primitives](https://weftui.dev/docs/explanation/reactive-primitives) — the stream-shaped values you weave through the tree
|
|
64
|
+
- [Boundaries and Suspense](https://weftui.dev/docs/explanation/boundaries-and-suspense) — how failure and async are modeled as nodes in the same tree
|
|
65
|
+
- [Render on the Server](https://weftui.dev/docs/how-to/render-on-the-server) — the server/client split and `hydrate`
|