@weftui/router 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 +57 -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 +20 -6
package/README.md
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
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
|
+
## Example
|
|
32
|
+
|
|
33
|
+
```typescript
|
|
34
|
+
import { h } from "@weftui/core";
|
|
35
|
+
import { Router, href } from "@weftui/router";
|
|
36
|
+
import { Schema } from "effect";
|
|
37
|
+
|
|
38
|
+
const userRoute = Router.route("users/:id", {
|
|
39
|
+
path: { id: Schema.NumberFromString },
|
|
40
|
+
component: ({ path }) => h.div(`User ${path.id}`),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const App = Router.router(userRoute, { notFound: () => h.div("Not found") });
|
|
44
|
+
|
|
45
|
+
href(userRoute, { path: { id: 42 } }); // "/users/42"
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Documentation
|
|
49
|
+
|
|
50
|
+
- Full docs: **https://weftui.dev**
|
|
51
|
+
- `@weftui/router` API reference: **https://weftui.dev/docs/reference/router**
|
|
52
|
+
- Routing guide: **https://weftui.dev/docs/how-to/add-routing**
|
|
53
|
+
- 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.
|
|
54
|
+
|
|
55
|
+
## License
|
|
56
|
+
|
|
57
|
+
MIT © Stef van Wijchen
|
|
@@ -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`
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Services and Context
|
|
3
|
+
order: 5
|
|
4
|
+
section: explanation
|
|
5
|
+
description: How Effect services reach components — the requirement channel, discharging R at the mount, the router's render-time context seam, and ServerTag server-only brands.
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Services and Context
|
|
9
|
+
|
|
10
|
+
Weft has no separate dependency-injection system. It uses Effect's — a component that needs a service reads it with `yield* Service`, and because [a node is an Effect](https://weftui.dev/docs/explanation/rendering-model), that requirement rides the node's `R` channel up the tree to a single point where you provide it. This page explains how a service travels from where you provide it to where a component reads it, and the two seams that make that work across the server/client boundary.
|
|
11
|
+
|
|
12
|
+
## R accumulates, then discharges once
|
|
13
|
+
|
|
14
|
+
When a component does `yield* ThemeService`, `ThemeService` enters that node's requirement channel. It accumulates through every parent — a boundary, a layout, the app node — until the whole tree's `R` is the union of everything any component needs. You satisfy it in **one** place, at the edge:
|
|
15
|
+
|
|
16
|
+
```typescript
|
|
17
|
+
import { Effect } from "effect";
|
|
18
|
+
import { mount } from "@weftui/dom/client";
|
|
19
|
+
|
|
20
|
+
const handle = pipe(
|
|
21
|
+
mount(App(), document.getElementById("root")!),
|
|
22
|
+
Effect.provide(ThemeServiceLive),
|
|
23
|
+
);
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Provide too little and it is a compile error at the mount call — the type of `App()` names exactly which service is missing. This is the same discipline as any Effect program: `R` is a promise the type checker holds you to, discharged at the program's boundary, not sprinkled through the tree.
|
|
27
|
+
|
|
28
|
+
Services flow **down** from that provide point to every reader, including across reactive boundaries: a stream woven into a prop carries its own `R`, and a handler that reads a service resolves it from the same context. There is no prop-drilling and no context-provider component — the requirement channel _is_ the wiring.
|
|
29
|
+
|
|
30
|
+
## Layer lifetime at the mount
|
|
31
|
+
|
|
32
|
+
The `ThemeServiceLive` example above works because `mount`'s effect and the service's lifetime coincide by accident: `ThemeServiceLive` is a plain value layer with nothing to release, so it makes no difference whether it is "alive" for one tick or the whole session. That accident stops holding the moment the layer is **scoped** — built with `Layer.scoped`, backed by an `acquireRelease` — because `mount`'s effect resolves right after the tree's **initial render**, not when the app stops running. Streams, event handlers, and forked work all keep running on the mount's runtime long after that Effect has settled.
|
|
33
|
+
|
|
34
|
+
`Effect.provide(scopedLayer)` is `acquireUseRelease` sugar: acquire, run the wrapped effect, then release **when that effect completes**. Wrap it directly around `mount`, and the release runs at mount-resolve — while the mounted tree is still reading from the now-disposed service:
|
|
35
|
+
|
|
36
|
+
```typescript
|
|
37
|
+
// ❌ the layer's finalizers run the instant runPromise settles, while the
|
|
38
|
+
// mounted tree keeps running — every subscription now reads a disposed service
|
|
39
|
+
Effect.runPromise(mount(App(), root).pipe(Effect.provide(SomeScopedLayer)));
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
This is exactly what happened with effect-atom's `Registry.layer` in the [`effect-atom` example](https://github.com/stefvw93/weft/tree/main/examples/effect-atom) (issue #122): every atom-driven region rendered empty, with no error, because the registry the streams read from had already been disposed.
|
|
43
|
+
|
|
44
|
+
The fix is to give the scoped layer a lifetime that matches the app, not the initial render: provide it **outside** a scoped region that stays open for as long as the app should run, and mount inside that region with `mountScoped` (which ties `unmount` to the region's scope instead of to the resolution of the mount effect). An `Effect.never` (or `Deferred.await` on a shutdown signal) keeps the region — and therefore the layer — alive until something explicitly closes it. See [Provide Services](https://weftui.dev/docs/how-to/provide-services) for the recipe, including the `ManagedRuntime` alternative when a scoped region isn't a good fit.
|
|
45
|
+
|
|
46
|
+
## The router's render-time context seam
|
|
47
|
+
|
|
48
|
+
A plain `mount`/`hydrate` discharges `R` at the call site. But under `@weftui/router`, the tree does not render in the context of the effect that called `render` — each request dispatches through platform's HTTP layer in its own managed context, and the reactive outlet drains in the top render context, not in any intermediate node's. Providing a service _ambiently_ around the render would be lost before it reached a route component.
|
|
49
|
+
|
|
50
|
+
So the router exposes an explicit **`context` seam** — a `Layer` threaded to the document shell and every route, layout, and leaf:
|
|
51
|
+
|
|
52
|
+
```typescript
|
|
53
|
+
class Greeting extends Context.Tag("Greeting")<Greeting, { text: string }>() {}
|
|
54
|
+
|
|
55
|
+
// server entry
|
|
56
|
+
RouterServer.render(App, { document, url, context: Layer.succeed(Greeting, { text: "hi" }) });
|
|
57
|
+
|
|
58
|
+
// client entry — same seam, so the hydrated tree reads the same services
|
|
59
|
+
RouterLive(App, { context: DocsLive });
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
The seam is **symmetric** (same shape on both sides) and **type-tracked**: the def's aggregate residual `R` is discharged here, so a missing provide is a compile error rather than a runtime 500. The residual is `AppServices<R>` — the def's `R` minus what the router itself threads (`Router`, `Router.Outlet`, `AppRpcClientTag`). An app with no app-services needs no `context`; a loosely-typed `RouterDef<any, any>` may omit it. This is how the website provides its `Docs` service to every page — see [Add Routing](https://weftui.dev/docs/how-to/add-routing).
|
|
63
|
+
|
|
64
|
+
## Server-only services: `ServerTag`
|
|
65
|
+
|
|
66
|
+
Some services must _never_ run in the browser — a database handle, a private credential, an rpc handler's backing store. Declare those with [`ServerTag`](https://weftui.dev/docs/reference/core#servertag) instead of `Context.Tag`. It behaves exactly like `Context.Tag`, but its identifier carries a **server-only brand**.
|
|
67
|
+
|
|
68
|
+
The brand's job is to turn a leak into a **compile error at the `hydrate` call site**. A `Boundary.rpc` handler legitimately reads server-only services on the server, but they must not survive into client code: since `render` only ever touches the _decoded result_ (never the service), a correctly-written boundary keeps its output `R` free of the brand. If a branded tag ever leaks into `render` and reaches the client requirement channel, `hydrate`'s `AssertNoServerOnly` resolves `R` to a compile-error sentinel — you learn at build time, not from a runtime defect.
|
|
69
|
+
|
|
70
|
+
```typescript
|
|
71
|
+
import { ServerTag } from "@weftui/core";
|
|
72
|
+
|
|
73
|
+
// Only ever provided on the server; a leak into client code fails to compile.
|
|
74
|
+
class Db extends ServerTag("Db")<Db, { query: (sql: string) => Effect.Effect<Row[]> }>() {}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## The whole picture
|
|
78
|
+
|
|
79
|
+
- A component reads a service with `yield* Service`; the requirement enters `R`.
|
|
80
|
+
- `R` accumulates through the tree and is discharged **once** — at `mount`/`hydrate`, or through the router's `context` seam.
|
|
81
|
+
- The same services flow to the same components on the server and the client, because it is the same tree.
|
|
82
|
+
- `ServerTag` brands the services that must stay server-side, enforced at the `hydrate` boundary.
|
|
83
|
+
|
|
84
|
+
## See also
|
|
85
|
+
|
|
86
|
+
- [The Rendering Model](https://weftui.dev/docs/explanation/rendering-model) — why services flow through the tree at all
|
|
87
|
+
- [The Combinator API](https://weftui.dev/docs/explanation/combinator-api) — how `R` accumulates from children and reactive props
|
|
88
|
+
- [Provide Services](https://weftui.dev/docs/how-to/provide-services) — recipes for value layers, scoped layers with `mountScoped`, and `ManagedRuntime`
|
|
89
|
+
- [Add Routing](https://weftui.dev/docs/how-to/add-routing) — providing app services through the router `context` seam
|
|
90
|
+
- [Load Data with RPC](https://weftui.dev/docs/how-to/load-data-with-rpc) — where `ServerTag` and the rpc handler Layer meet
|
|
91
|
+
- [`ServerTag` API reference](https://weftui.dev/docs/reference/core#servertag)
|