@weftui/core 0.27.1 → 0.29.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -10
- package/dist/{index-DMNuAQXj.d.ts → index-4cTlhojA.d.ts} +47 -35
- package/dist/index.d.ts +77 -135
- package/dist/types/index.d.ts +1 -1
- package/docs/explanation/boundaries-and-suspense.md +37 -18
- package/docs/explanation/combinator-api.md +14 -12
- package/docs/explanation/reactive-primitives.md +15 -15
- package/docs/explanation/rendering-model.md +20 -18
- package/docs/explanation/services-and-context.md +39 -29
- package/docs/how-to/add-routing.md +76 -52
- package/docs/how-to/author-components.md +46 -32
- package/docs/how-to/compose-behavior-and-markup.md +144 -0
- package/docs/how-to/handle-forms.md +6 -6
- package/docs/how-to/load-async-data.md +15 -13
- package/docs/how-to/load-data-with-rpc.md +34 -30
- package/docs/how-to/provide-services.md +54 -74
- package/docs/how-to/render-keyed-lists.md +10 -8
- package/docs/how-to/render-on-the-server.md +19 -14
- package/docs/how-to/show-navigation-progress.md +10 -8
- package/docs/how-to/split-routes-lazily.md +16 -14
- package/docs/how-to/style-reactively.md +13 -13
- package/docs/how-to/use-element-refs.md +10 -8
- package/docs/index.md +20 -18
- package/docs/reference/core.md +44 -40
- package/docs/reference/dom.md +395 -58
- package/docs/reference/router.md +71 -49
- package/docs/tutorial/01-your-first-app.md +10 -11
- package/docs/tutorial/02-reactivity.md +11 -8
- package/docs/tutorial/03-services-and-async.md +19 -13
- package/docs/tutorial/04-errors-and-server.md +17 -7
- package/package.json +8 -7
|
@@ -25,7 +25,7 @@ const LoginForm = () =>
|
|
|
25
25
|
const email = yield* SubscriptionRef.make("");
|
|
26
26
|
const status = yield* SubscriptionRef.make<string | null>(null);
|
|
27
27
|
|
|
28
|
-
// Validation is a stream derived from the field
|
|
28
|
+
// Validation is a stream derived from the field: it re-runs as the user types.
|
|
29
29
|
const error = Stream.map(SubscriptionRef.changes(email), (value) => {
|
|
30
30
|
if (value.length === 0) return null; // don't nag an empty field
|
|
31
31
|
return Result.match(Schema.decodeUnknownResult(Email)(value), {
|
|
@@ -61,8 +61,8 @@ const LoginForm = () =>
|
|
|
61
61
|
## How it works
|
|
62
62
|
|
|
63
63
|
- **Field state** is a `SubscriptionRef.make("")`; `oninput` writes the current value with `SubscriptionRef.set`. Because the input is driven by the ref, it is a controlled input.
|
|
64
|
-
- **Validation is reactive**, not on-blur or on-submit only: `Stream.map(SubscriptionRef.changes(email), …)` produces an error string (or `null`) on every keystroke. Use [`Schema`](https://effect.website/docs/schema/introduction) to decode
|
|
65
|
-
- **Submit returns an Effect.** `onsubmit` calls `e.preventDefault()` and then **returns** an `Effect` (it is not `yield*`-ed inline)
|
|
64
|
+
- **Validation is reactive**, not on-blur or on-submit only: `Stream.map(SubscriptionRef.changes(email), …)` produces an error string (or `null`) on every keystroke. Use [`Schema`](https://effect.website/docs/schema/introduction) to decode: `Schema.decodeUnknownResult(schema)(value)` returns a `Result`, and `Result.match` turns it into UI. A node or `null` in a child slot renders the error or nothing.
|
|
65
|
+
- **Submit returns an Effect.** `onsubmit` calls `e.preventDefault()` and then **returns** an `Effect` (it is not `yield*`-ed inline). The renderer runs it in a detached fiber, so it can `SubscriptionRef.set`, `Effect.sleep`, read fields with `SubscriptionRef.get`, or call a service.
|
|
66
66
|
|
|
67
67
|
## Variations
|
|
68
68
|
|
|
@@ -71,6 +71,6 @@ const LoginForm = () =>
|
|
|
71
71
|
|
|
72
72
|
## See also
|
|
73
73
|
|
|
74
|
-
- [Reactive Primitives](https://weftui.dev/docs/explanation/reactive-primitives)
|
|
75
|
-
- [Author Components](https://weftui.dev/docs/how-to/author-components)
|
|
76
|
-
- [examples/form-handling](https://github.com/stefvw93/weft/tree/main/examples/form-handling)
|
|
74
|
+
- [Reactive Primitives](https://weftui.dev/docs/explanation/reactive-primitives): `SubscriptionRef.changes` and stream-shaped children
|
|
75
|
+
- [Author Components](https://weftui.dev/docs/how-to/author-components): Effect-returning and service-aware handlers
|
|
76
|
+
- [examples/form-handling](https://github.com/stefvw93/weft/tree/main/examples/form-handling): a runnable multi-field form with Schema validation and an async submit
|
|
@@ -2,12 +2,12 @@
|
|
|
2
2
|
title: Load Async Data
|
|
3
3
|
order: 9
|
|
4
4
|
section: how-to
|
|
5
|
-
description: Show a loading state then resolved content with Stream.concat, and turn a failed fetch into a fallback node with Effect.catch
|
|
5
|
+
description: Show a loading state then resolved content with Stream.concat, and turn a failed fetch into a fallback node with Effect.catch, all client-side.
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# Load Async Data
|
|
9
9
|
|
|
10
|
-
**Goal:** render a loading placeholder, then the fetched content, and a fallback if the fetch fails
|
|
10
|
+
**Goal:** render a loading placeholder, then the fetched content, and a fallback if the fetch fails, for data that loads **on the client**.
|
|
11
11
|
|
|
12
12
|
Return a `Stream<Node>` that emits the loading node first and the resolved node second, sequenced with `Stream.concat`. Handle failure inside the effect with `Effect.catch`, which maps the error to a fallback node.
|
|
13
13
|
|
|
@@ -43,28 +43,30 @@ const UserCard = ({ id }: { id: number }) =>
|
|
|
43
43
|
## How it works
|
|
44
44
|
|
|
45
45
|
- **`Stream.concat`** sequences two streams: `Stream.make(loadingNode)` emits once immediately, then `Stream.fromEffect(effect)` emits the resolved node when the effect completes. The renderer swaps the DOM in place on the second emission.
|
|
46
|
-
- **`Effect.flatMap((data) => h.div(...))`** builds the content node from the data
|
|
46
|
+
- **`Effect.flatMap((data) => h.div(...))`** builds the content node from the data. `h.*` returns a `Node`, which is an `Effect`, so it composes directly in the pipeline.
|
|
47
47
|
- **`Effect.catch((error) => node)`** converts the error channel into a fallback node, so the stream always yields something renderable. The failure never escapes to the mount.
|
|
48
|
-
- **Parallel loading is automatic:** place several async components as siblings and their fetches run concurrently
|
|
48
|
+
- **Parallel loading is automatic:** place several async components as siblings and their fetches run concurrently, with no orchestration needed.
|
|
49
49
|
|
|
50
50
|
## When to reach for a boundary instead
|
|
51
51
|
|
|
52
|
-
This is the raw, per-region pattern. When you need **one fallback for several async siblings** (all-or-nothing), use [`Boundary.suspend`](https://weftui.dev/docs/explanation/boundaries-and-suspense). When the data must be resolved on the **server** and replayed on hydrate without a second request, use [`Boundary.rpc`](https://weftui.dev/docs/how-to/load-data-with-rpc) instead
|
|
52
|
+
This is the raw, per-region pattern. When you need **one fallback for several async siblings** (all-or-nothing), use [`Boundary.suspend`](https://weftui.dev/docs/explanation/boundaries-and-suspense). When the data must be resolved on the **server** and replayed on hydrate without a second request, use [`Boundary.rpc`](https://weftui.dev/docs/how-to/load-data-with-rpc) instead. This recipe is purely client-side.
|
|
53
53
|
|
|
54
54
|
## Blocking on navigation vs streaming in place
|
|
55
55
|
|
|
56
|
-
The `Stream.concat` placeholder above lives on a **child** node, so it always streams in after mount
|
|
56
|
+
The `Stream.concat` placeholder above lives on a **child** node, so it always streams in after mount and never delays a navigation commit. If the component above is a route's leaf, moving the `fetchUser` call into the **body** instead changes that:
|
|
57
57
|
|
|
58
|
-
- **Await in the leaf's own body**
|
|
59
|
-
- **The `Stream.concat` placeholder pattern above, kept as a child**
|
|
58
|
+
- **Await in the leaf's own body** is commit-blocking. Navigating to the route pre-runs its component effect to completion before the URL commits: the previous page stays mounted for the fetch, and [`Router.navigating`](https://weftui.dev/docs/reference/router#routernavigating) reports the window.
|
|
59
|
+
- **The `Stream.concat` placeholder pattern above, kept as a child** is streaming. The leaf commits immediately and the region fills in place once the effect resolves.
|
|
60
60
|
|
|
61
|
-
Choose blocking for **primary route content the page is meaningless without** (an article body, a user's profile)
|
|
61
|
+
Choose blocking for **primary route content the page is meaningless without** (an article body, a user's profile). The old page stays visible with no blank or skeleton.
|
|
62
|
+
|
|
63
|
+
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.
|
|
62
64
|
|
|
63
65
|
See [Show Navigation Progress](https://weftui.dev/docs/how-to/show-navigation-progress) for rendering pending UI during the blocking window, and the router reference's [Blocking vs streaming data](https://weftui.dev/docs/reference/router#blocking-vs-streaming-data) for the full model.
|
|
64
66
|
|
|
65
67
|
## See also
|
|
66
68
|
|
|
67
|
-
- [Boundaries and Suspense](https://weftui.dev/docs/explanation/boundaries-and-suspense)
|
|
68
|
-
- [Reactive Primitives](https://weftui.dev/docs/explanation/reactive-primitives)
|
|
69
|
-
- [Show Navigation Progress](https://weftui.dev/docs/how-to/show-navigation-progress)
|
|
70
|
-
- [examples/async-data-loading](https://github.com/stefvw93/weft/tree/main/examples/async-data-loading)
|
|
69
|
+
- [Boundaries and Suspense](https://weftui.dev/docs/explanation/boundaries-and-suspense): coordinating multiple async regions
|
|
70
|
+
- [Reactive Primitives](https://weftui.dev/docs/explanation/reactive-primitives): `Stream`/`Effect` as node-producing children
|
|
71
|
+
- [Show Navigation Progress](https://weftui.dev/docs/how-to/show-navigation-progress): the pending signal for the commit-blocking window
|
|
72
|
+
- [examples/async-data-loading](https://github.com/stefvw93/weft/tree/main/examples/async-data-loading): loading states, retry, parallel and sequential loads with error boundaries
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
title: RPC Data Boundaries
|
|
3
3
|
order: 3
|
|
4
4
|
section: how-to
|
|
5
|
-
description: Boundary.rpc
|
|
5
|
+
description: Boundary.rpc, server-resolved and client-refreshable data; the contract/handler split and the Resource handle's four lifecycles.
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# RPC Data Boundaries
|
|
@@ -11,7 +11,7 @@ description: Boundary.rpc — server-resolved, client-refreshable data; the cont
|
|
|
11
11
|
|
|
12
12
|
## Overview
|
|
13
13
|
|
|
14
|
-
A `Boundary.rpc` is a **thin consumer**. It carries an rpc, a payload thunk, and a `render` that receives a reactive [`Resource`](https://weftui.dev/docs/reference/core#resourcea)
|
|
14
|
+
A `Boundary.rpc` is a **thin consumer**. It carries an rpc, a payload thunk, and a `render` that receives a reactive [`Resource`](https://weftui.dev/docs/reference/core#resourcea). The renderer resolves the rpc through the ambient [`AppRpcClientTag`](https://weftui.dev/docs/reference/core#apprpcclienttag) seam (provided by `@weftui/router`). The same rpc serves every lifecycle, so SSR-replay, refetch, and client-first mount are one mechanism, not three.
|
|
15
15
|
|
|
16
16
|
```typescript
|
|
17
17
|
import { Boundary, h } from "@weftui/core";
|
|
@@ -19,10 +19,10 @@ import { Stream } from "effect";
|
|
|
19
19
|
import { GetStock } from "./data/inventory";
|
|
20
20
|
|
|
21
21
|
Boundary.rpc(
|
|
22
|
-
GetStock, // the rpc
|
|
23
|
-
() => ({ id: product.id }), // payload thunk
|
|
22
|
+
GetStock, // the rpc: its _tag + schemas drive the boundary
|
|
23
|
+
() => ({ id: product.id }), // payload thunk: a fresh typed input per call
|
|
24
24
|
(
|
|
25
|
-
resource, // render
|
|
25
|
+
resource, // render: receives a reactive Resource, not a bare value
|
|
26
26
|
) =>
|
|
27
27
|
h.p([
|
|
28
28
|
"in stock: ",
|
|
@@ -35,7 +35,7 @@ Boundary.rpc(
|
|
|
35
35
|
|
|
36
36
|
## The contract / handler split
|
|
37
37
|
|
|
38
|
-
The rpc **contract** (pure Schema) is shared with the client. The rpc **handler
|
|
38
|
+
The rpc **contract** (pure Schema) is shared with the client. The rpc **handler**, the only code that touches server-only services, lives in a Layer the client never imports. Tree-shaking keeps it and its transitive imports out of the browser bundle. The split is enforced structurally by which files each entry imports, not by a bundler plugin.
|
|
39
39
|
|
|
40
40
|
```typescript
|
|
41
41
|
// data/inventory.ts
|
|
@@ -49,7 +49,7 @@ export const StockKey = Schema.Struct({ id: Schema.Number });
|
|
|
49
49
|
// `_tag` ("GetStock") = the stable boundary id; payload schema = the typed input.
|
|
50
50
|
export const GetStock = Rpc.make("GetStock", { payload: StockKey, success: Stock });
|
|
51
51
|
|
|
52
|
-
// The app's merged RpcGroup
|
|
52
|
+
// The app's merged RpcGroup: shared by both the client and server router wiring.
|
|
53
53
|
export const StockRpcs = RpcGroup.make(GetStock);
|
|
54
54
|
|
|
55
55
|
// --- Handler (server-only; the client never imports this) ---
|
|
@@ -68,14 +68,14 @@ export const StockLive = StockRpcs.toLayer({
|
|
|
68
68
|
}).pipe(Layer.provide(InventoryLive));
|
|
69
69
|
```
|
|
70
70
|
|
|
71
|
-
Declare server-only services with [`ServerTag`](https://weftui.dev/docs/reference/core#servertag) (not `Context.Service`) when they might be referenced from universal code
|
|
71
|
+
Declare server-only services with [`ServerTag`](https://weftui.dev/docs/reference/core#servertag) (not `Context.Service`) when they might be referenced from universal code. The brand makes a leak into `render` a compile error at the `hydrate` call site rather than a runtime surprise.
|
|
72
72
|
|
|
73
73
|
## Wiring the router
|
|
74
74
|
|
|
75
75
|
`Boundary.rpc` resolves through the ambient `AppRpcClientTag` seam, which `@weftui/router` provides on both sides. Pass the **merged group** to both, plus the **handler Layer** on the server.
|
|
76
76
|
|
|
77
77
|
```typescript
|
|
78
|
-
// entry-server.ts
|
|
78
|
+
// entry-server.ts: in-process client over the handlers + POST /_eui/rpc endpoint
|
|
79
79
|
import { RouterServer } from "@weftui/router/server";
|
|
80
80
|
import { StockLive, StockRpcs } from "./data/inventory";
|
|
81
81
|
|
|
@@ -87,15 +87,17 @@ export const render = (url: string) =>
|
|
|
87
87
|
```
|
|
88
88
|
|
|
89
89
|
```typescript
|
|
90
|
-
// entry-client.ts
|
|
90
|
+
// entry-client.ts: network client posting to /_eui/rpc
|
|
91
|
+
import { WeftApp } from "@weftui/dom/client";
|
|
91
92
|
import { RouterApp, RouterLive } from "@weftui/router/client";
|
|
93
|
+
import { Effect } from "effect";
|
|
92
94
|
import { StockRpcs } from "./data/inventory";
|
|
93
95
|
|
|
94
|
-
const
|
|
95
|
-
void
|
|
96
|
+
const app = WeftApp.make(RouterLive(App, { rpc: { group: StockRpcs } }));
|
|
97
|
+
void Effect.runPromise(WeftApp.hydrate(app, RouterApp(App), root));
|
|
96
98
|
```
|
|
97
99
|
|
|
98
|
-
- **Server** ([`RouterServer`](https://weftui.dev/docs/reference/router#routerserver)) mounts the handler Layer at `POST /_eui/rpc
|
|
100
|
+
- **Server** ([`RouterServer`](https://weftui.dev/docs/reference/router#routerserver)) mounts the handler Layer at `POST /_eui/rpc`, so a client refetch re-runs it on the server. It also exposes an in-process client over the same handlers for SSR resolution, never a network hop.
|
|
99
101
|
- **Client** ([`RouterLive`](https://weftui.dev/docs/reference/router#routerlive)) provides a network flat rpc client over the merged group, posting to `<origin>/_eui/rpc`.
|
|
100
102
|
|
|
101
103
|
In a **router-less mount** there is no `AppRpcClientTag`, so a `Boundary.rpc` resolves to a typed, descriptive "needs router/rpc" error (not a defect).
|
|
@@ -109,18 +111,18 @@ In a **router-less mount** there is no `AppRpcClientTag`, so a `Boundary.rpc` re
|
|
|
109
111
|
| **Refetch** | `resource.refetch` | Call the rpc again over `POST /_eui/rpc` (re-runs the handler on the server), patch the subtree in place (stale-on-error). |
|
|
110
112
|
| **Client-first mount** | SPA nav into a boundary with no payload | Render `options.fallback`, fork the rpc call, swap in `render(resource)` once it resolves. |
|
|
111
113
|
|
|
112
|
-
Because the SSR path seeds `value` await-first (it emits the seed immediately), the SSR HTML and the adopted DOM are byte-identical
|
|
114
|
+
Because the SSR path seeds `value` await-first (it emits the seed immediately), the SSR HTML and the adopted DOM are byte-identical. There is **no fallback flash** on the SSR/hydrate path. `fallback` shows only on a client-first mount.
|
|
113
115
|
|
|
114
116
|
## The `Resource` handle
|
|
115
117
|
|
|
116
118
|
`render` receives a [`Resource<A>`](https://weftui.dev/docs/reference/core#resourcea) (`A` = the rpc's decoded success), not a bare value. After hydrate the region is live:
|
|
117
119
|
|
|
118
|
-
| Field | What it gives you
|
|
119
|
-
| --------- |
|
|
120
|
-
| `value` | A `Subscribable` of the current data
|
|
121
|
-
| `refetch` | An `Effect<void>` that re-resolves the rpc with a fresh `payload()` and pushes the new `value`.
|
|
122
|
-
| `pending` | A `Subscribable<boolean
|
|
123
|
-
| `error` | A `Subscribable<Option<unknown
|
|
120
|
+
| Field | What it gives you |
|
|
121
|
+
| --------- | --------------------------------------------------------------------------------------------------- |
|
|
122
|
+
| `value` | A `Subscribable` of the current data: seeded with the SSR payload, updated on a successful refetch. |
|
|
123
|
+
| `refetch` | An `Effect<void>` that re-resolves the rpc with a fresh `payload()` and pushes the new `value`. |
|
|
124
|
+
| `pending` | A `Subscribable<boolean>`: `true` while a refetch is in flight. |
|
|
125
|
+
| `error` | A `Subscribable<Option<unknown>>`: `Some` with the last refetch error (stale-on-error). |
|
|
124
126
|
|
|
125
127
|
```typescript
|
|
126
128
|
(resource) =>
|
|
@@ -131,7 +133,7 @@ Because the SSR path seeds `value` await-first (it emits the seed immediately),
|
|
|
131
133
|
]);
|
|
132
134
|
```
|
|
133
135
|
|
|
134
|
-
Wire `refetch` to an event with `onclick: () => resource.refetch
|
|
136
|
+
Wire `refetch` to an event with `onclick: () => resource.refetch`. The handler returns the Effect, which the renderer runs in a detached fiber. A failed refetch leaves the previous `value` intact (stale-on-error); it does **not** unmount the subtree or raise into a failure `Boundary`.
|
|
135
137
|
|
|
136
138
|
### Channel algebra
|
|
137
139
|
|
|
@@ -145,11 +147,13 @@ Boundary.rpc<R extends Rpc.Any, C extends Node<any, any>>(
|
|
|
145
147
|
```
|
|
146
148
|
|
|
147
149
|
- **Error** = `render`'s error union plus the rpc's typed `Rpc.Error<R>` (`never` for an rpc with no `error` schema).
|
|
148
|
-
- **Requirement** = exactly `render`'s `R`, **untouched**. There is no `provide`/`RServer` to discharge (the handler lives in the rpc Layer) and no `Exclude` is applied
|
|
150
|
+
- **Requirement** = exactly `render`'s `R`, **untouched**. There is no `provide`/`RServer` to discharge (the handler lives in the rpc Layer) and no `Exclude` is applied. A server-only tag leaked into `render` stays in `R`, where `hydrate`'s `AssertNoServerOnly` rejects it.
|
|
149
151
|
|
|
150
152
|
## Typed-failure replay
|
|
151
153
|
|
|
152
|
-
If the rpc declares an `error` schema, a resolved rpc **error** on the SSR pass is `errorSchema`-encoded into an inline failure payload
|
|
154
|
+
If the rpc declares an `error` schema, a resolved rpc **error** on the SSR pass is `errorSchema`-encoded into an inline failure payload. The nearest enclosing **failure `Boundary`** renders its fallback.
|
|
155
|
+
|
|
156
|
+
On the client, `hydrate` decodes that payload and re-raises the same error into the same boundary. The identical fallback DOM is reproduced, flash-free and without re-resolving the rpc (replay, never retry).
|
|
153
157
|
|
|
154
158
|
```typescript
|
|
155
159
|
Boundary.catchTag({ tag: "OutOfStock", fallback: (e) => h.p({ class: "error" }, e.reason) }, [
|
|
@@ -157,16 +161,16 @@ Boundary.catchTag({ tag: "OutOfStock", fallback: (e) => h.p({ class: "error" },
|
|
|
157
161
|
]);
|
|
158
162
|
```
|
|
159
163
|
|
|
160
|
-
A transport **defect** (no `Cause.findErrorOption`), or an rpc with no `error` schema, is **not** replayed; it propagates
|
|
164
|
+
A transport **defect** (no `Cause.findErrorOption`), or an rpc with no `error` schema, is **not** replayed; it propagates: a server-side fallback and a client mismatch.
|
|
161
165
|
|
|
162
166
|
## When to use
|
|
163
167
|
|
|
164
|
-
- **`Boundary.rpc
|
|
165
|
-
- **`Boundary.suspend
|
|
168
|
+
- **`Boundary.rpc`**: data resolved on the server (behind a server-only service, credential, or private network) and rendered into the initial HTML. It stays **refreshable** on the client over the same rpc.
|
|
169
|
+
- **`Boundary.suspend`**: async data that loads purely on the client; see the [Boundary API](https://weftui.dev/docs/reference/core#boundarysuspend).
|
|
166
170
|
|
|
167
171
|
## See also
|
|
168
172
|
|
|
169
|
-
- [`Boundary.rpc` API reference](https://weftui.dev/docs/reference/core#boundaryrpc)
|
|
170
|
-
- [Server-Side Rendering](https://weftui.dev/docs/how-to/render-on-the-server)
|
|
171
|
-
- [Routing](https://weftui.dev/docs/how-to/add-routing)
|
|
172
|
-
- [examples/router-ssr](https://github.com/stefvw93/weft/tree/main/examples/router-ssr)
|
|
173
|
+
- [`Boundary.rpc` API reference](https://weftui.dev/docs/reference/core#boundaryrpc): signature, `Resource`, `RpcOptions`, `AppRpcClientTag`
|
|
174
|
+
- [Server-Side Rendering](https://weftui.dev/docs/how-to/render-on-the-server): the SSR + hydration model this builds on
|
|
175
|
+
- [Routing](https://weftui.dev/docs/how-to/add-routing): `@weftui/router`, which provides the `AppRpcClientTag` seam
|
|
176
|
+
- [examples/router-ssr](https://github.com/stefvw93/weft/tree/main/examples/router-ssr): a runnable shop with an SSR-replayed, refetchable live-stock `Boundary.rpc`
|
|
@@ -2,123 +2,103 @@
|
|
|
2
2
|
title: Provide Services
|
|
3
3
|
order: 12
|
|
4
4
|
section: how-to
|
|
5
|
-
description: Provide plain and scoped Layers to a
|
|
5
|
+
description: "Provide plain and scoped Layers to a WeftApp: app layers for the common case, scoped layers that just work, memoMap sharing, and binding an app's lifetime to a scope."
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# Provide Services
|
|
9
9
|
|
|
10
|
-
**Goal:** provide a `Layer` to
|
|
10
|
+
**Goal:** provide a `Layer` to a `WeftApp` so its components can read services with `yield* Service`.
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
## Recipe 1: app layers
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
Provide the layer directly around `mount` and run with `runPromise`. This is the common case and needs nothing else.
|
|
14
|
+
Pass the layer to `WeftApp.make`. This is the common case and needs nothing else. The layer builds lazily on first mount, and every component, event handler, and stream subscription in every root mounted from `app` can read it.
|
|
17
15
|
|
|
18
16
|
```typescript
|
|
19
|
-
import {
|
|
20
|
-
import { Effect
|
|
17
|
+
import { WeftApp } from "@weftui/dom/client";
|
|
18
|
+
import { Effect } from "effect";
|
|
21
19
|
import { App } from "./app";
|
|
22
20
|
import { ThemeServiceLive } from "./theme-service";
|
|
23
21
|
|
|
24
22
|
const root = document.getElementById("root")!;
|
|
25
23
|
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
Effect.runPromise(program);
|
|
24
|
+
const app = WeftApp.make(ThemeServiceLive);
|
|
25
|
+
void Effect.runPromise(WeftApp.mount(app, App(), root));
|
|
29
26
|
```
|
|
30
27
|
|
|
31
|
-
## Recipe 2
|
|
28
|
+
## Recipe 2: scoped layers just work
|
|
32
29
|
|
|
33
|
-
|
|
30
|
+
A **scoped** layer (`Layer.effect` backed by `acquireRelease`, or anything else that owns a subscription, listener, or registry) needs nothing different from Recipe 1. The app owns one lazy `ManagedRuntime`. The layer builds on first mount and releases only at `WeftApp.dispose(app)`, not when any individual mount's render effect resolves.
|
|
34
31
|
|
|
35
|
-
|
|
36
|
-
import { mountScoped } from "@weftui/dom/client";
|
|
37
|
-
import { Deferred, Effect, Fiber, pipe } from "effect";
|
|
38
|
-
import { App } from "./app";
|
|
39
|
-
import { AppLive } from "./app-live";
|
|
32
|
+
There is no `mountScoped`, no `Effect.never`, no manual scope threading.
|
|
40
33
|
|
|
41
|
-
|
|
34
|
+
`AtomRegistry.layer` (from `effect/unstable/reactivity`) is a real scoped layer. Its atom subscriptions are fibers forked for the app's whole lifetime:
|
|
42
35
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
}),
|
|
49
|
-
),
|
|
50
|
-
Effect.provide(AppLive), // OUTSIDE the scoped region: outlives initial render
|
|
51
|
-
);
|
|
52
|
-
|
|
53
|
-
const fiber = Effect.runFork(program);
|
|
36
|
+
```typescript
|
|
37
|
+
import { WeftApp } from "@weftui/dom/client";
|
|
38
|
+
import { Effect } from "effect";
|
|
39
|
+
import { AtomRegistry } from "effect/unstable/reactivity";
|
|
40
|
+
import { App } from "./app";
|
|
54
41
|
|
|
55
|
-
|
|
56
|
-
|
|
42
|
+
const app = WeftApp.make(AtomRegistry.layer);
|
|
43
|
+
void Effect.runPromise(WeftApp.mount(app, App(), document.getElementById("root")!));
|
|
57
44
|
```
|
|
58
45
|
|
|
59
|
-
|
|
46
|
+
`RouterLive` (from `@weftui/router/client`) is another. It owns the `popstate` listener and the same-origin link-click interceptor for as long as the app runs:
|
|
60
47
|
|
|
61
48
|
```typescript
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
const program = pipe(
|
|
65
|
-
Effect.scoped(
|
|
66
|
-
Effect.gen(function* () {
|
|
67
|
-
yield* mountScoped(App(), root);
|
|
68
|
-
yield* Deferred.await(shutdown); // resolves when shutdown is signalled
|
|
69
|
-
}),
|
|
70
|
-
),
|
|
71
|
-
Effect.provide(AppLive),
|
|
72
|
-
);
|
|
73
|
-
Effect.runFork(program);
|
|
74
|
-
|
|
75
|
-
// elsewhere, to request shutdown:
|
|
76
|
-
// await Effect.runPromise(Deferred.succeed(shutdown, undefined));
|
|
49
|
+
const app = WeftApp.make(RouterLive(App, { rpc: { group: StockRpcs } }));
|
|
50
|
+
void Effect.runPromise(WeftApp.hydrate(app, RouterApp(App), root));
|
|
77
51
|
```
|
|
78
52
|
|
|
79
|
-
|
|
53
|
+
Both examples are runnable in full at [examples/effect-atom](https://github.com/stefvw93/weft/tree/main/examples/effect-atom) and [examples/router-ssr](https://github.com/stefvw93/weft/tree/main/examples/router-ssr).
|
|
80
54
|
|
|
81
|
-
## Recipe 3
|
|
55
|
+
## Recipe 3: sharing layer memoization with `memoMap`
|
|
82
56
|
|
|
83
|
-
|
|
57
|
+
`WeftApp.make(layer, { memoMap })` accepts an explicit `Layer.MemoMap`, so multiple `WeftApp` instances can share layer construction. For example, build one app per test case while reusing an expensive shared dependency's memoized build across them:
|
|
84
58
|
|
|
85
59
|
```typescript
|
|
86
|
-
import {
|
|
87
|
-
import {
|
|
88
|
-
import { App } from "./app";
|
|
89
|
-
import { AppLive } from "./app-live";
|
|
90
|
-
|
|
91
|
-
const root = document.getElementById("root")!;
|
|
92
|
-
const runtime = ManagedRuntime.make(AppLive);
|
|
60
|
+
import { WeftApp } from "@weftui/dom/client";
|
|
61
|
+
import { Layer } from "effect";
|
|
93
62
|
|
|
94
|
-
|
|
63
|
+
const memoMap = Layer.makeMemoMap();
|
|
95
64
|
|
|
96
|
-
|
|
97
|
-
|
|
65
|
+
const appA = WeftApp.make(SharedLive, { memoMap });
|
|
66
|
+
const appB = WeftApp.make(SharedLive, { memoMap });
|
|
98
67
|
```
|
|
99
68
|
|
|
100
|
-
|
|
69
|
+
Most apps have exactly one `WeftApp` and never need this option.
|
|
101
70
|
|
|
102
|
-
##
|
|
71
|
+
## Recipe 4: binding an app's lifetime to a scope
|
|
103
72
|
|
|
104
|
-
|
|
73
|
+
There is deliberately no `makeScoped`. To tie an app's disposal to a `Scope` you already manage (a framework integration or a test harness that owns one), compose it yourself with `Effect.acquireRelease`:
|
|
105
74
|
|
|
106
75
|
```typescript
|
|
107
|
-
|
|
108
|
-
|
|
76
|
+
import { Effect } from "effect";
|
|
77
|
+
import { WeftApp } from "@weftui/dom/client";
|
|
78
|
+
import { AppLive } from "./app-live";
|
|
79
|
+
|
|
80
|
+
const acquireApp = Effect.acquireRelease(
|
|
81
|
+
Effect.sync(() => WeftApp.make(AppLive)),
|
|
82
|
+
(app) => WeftApp.dispose(app),
|
|
83
|
+
);
|
|
109
84
|
```
|
|
110
85
|
|
|
86
|
+
`acquireApp` yields a `WeftApp` and registers `WeftApp.dispose` as a finalizer on whatever scope the surrounding effect runs in. Closing that scope tears the app down the same way `WeftApp.dispose` normally would (roots, then layers, then the error hub).
|
|
87
|
+
|
|
88
|
+
## Anti-pattern: `Effect.provide` around the mount call
|
|
89
|
+
|
|
111
90
|
```typescript
|
|
112
|
-
// ❌
|
|
113
|
-
//
|
|
114
|
-
|
|
91
|
+
// ❌ does nothing useful: WeftApp.mount's R is always `never`, and services
|
|
92
|
+
// come exclusively from the app layer: a wrapped Effect.provide never
|
|
93
|
+
// reaches components, handlers, or stream subscriptions
|
|
94
|
+
Effect.runPromise(pipe(WeftApp.mount(app, App(), root), Effect.provide(SomeLayer)));
|
|
115
95
|
```
|
|
116
96
|
|
|
117
|
-
|
|
97
|
+
`WeftApp.mount`/`WeftApp.hydrate` return an effect whose requirement channel is always `never`, so there is no `R` left for `Effect.provide` to discharge. Any service a component needs must be in the layer passed to `WeftApp.make`.
|
|
118
98
|
|
|
119
99
|
## See also
|
|
120
100
|
|
|
121
|
-
- [
|
|
122
|
-
- [
|
|
123
|
-
- [
|
|
124
|
-
- [examples/
|
|
101
|
+
- [Services and Context](https://weftui.dev/docs/explanation/services-and-context): how `R` accumulates and discharges at `WeftApp.make`, and why scoped layers no longer need special handling
|
|
102
|
+
- [`WeftApp` reference](https://weftui.dev/docs/reference/dom): full signatures for `make`, `mount`, `hydrate`, `dispose`
|
|
103
|
+
- [examples/effect-atom](https://github.com/stefvw93/weft/tree/main/examples/effect-atom): a real scoped layer (`AtomRegistry.layer`)
|
|
104
|
+
- [examples/shared-state-islands](https://github.com/stefvw93/weft/tree/main/examples/shared-state-islands): one app layer shared by reference across multiple mounted roots
|
|
@@ -9,7 +9,7 @@ description: Render a reactive collection with List.each so reordering, insertin
|
|
|
9
9
|
|
|
10
10
|
**Goal:** render a list whose items reorder, insert, or remove over time, without rebuilding the whole region (which would lose focus, scroll, and input state in the surviving rows).
|
|
11
11
|
|
|
12
|
-
Use [`List.each`](https://weftui.dev/docs/reference/core#listeach), the keyed-list combinator. It renders each item **once per key** and reconciles across emissions
|
|
12
|
+
Use [`List.each`](https://weftui.dev/docs/reference/core#listeach), the keyed-list combinator. It renders each item **once per key** and reconciles across emissions. A reorder _moves_ existing DOM nodes, an insert adds one, a remove drops one, and untouched rows are left entirely alone.
|
|
13
13
|
|
|
14
14
|
```typescript
|
|
15
15
|
import { h, List } from "@weftui/core";
|
|
@@ -25,16 +25,18 @@ h.ul([
|
|
|
25
25
|
]);
|
|
26
26
|
```
|
|
27
27
|
|
|
28
|
-
- **`of`**
|
|
29
|
-
- **`by`**
|
|
28
|
+
- **`of`** is the list source: any `Stream`, `Effect`, or `Subscribable` of an `Iterable`. Each emission is materialized to an array to fix order, then reconciled by key.
|
|
29
|
+
- **`by`** projects each item to its reconciliation key, compared via Effect's `Equal`/`Hash`. Omit it and the item itself is the key (structural for `Data`, by reference otherwise).
|
|
30
30
|
|
|
31
31
|
## Why not `map`?
|
|
32
32
|
|
|
33
|
-
Mapping items by hand
|
|
33
|
+
Mapping items by hand (`Stream.map(rows.changes, (rs) => rs.map(r => h.li(r.name)))`) produces a **new children array on every emission**. The renderer then rebuilds the whole region: every row's DOM node is recreated even if only one item moved.
|
|
34
|
+
|
|
35
|
+
`List.each` reconciles by key instead, so DOM identity (and the focus/scroll/typed-input state attached to it) survives across updates.
|
|
34
36
|
|
|
35
37
|
## Refresh a row's content
|
|
36
38
|
|
|
37
|
-
Because `render` runs **exactly once per key**, reconciliation never re-runs it for a kept row
|
|
39
|
+
Because `render` runs **exactly once per key**, reconciliation never re-runs it for a kept row, so it never refreshes that row's content on its own. To make a row's content reactive, thread a `Stream` **inside** the row rather than expecting a re-render:
|
|
38
40
|
|
|
39
41
|
```typescript
|
|
40
42
|
List.each({ of: rows.changes, by: (row) => row.id }, (row) =>
|
|
@@ -46,6 +48,6 @@ List.each({ of: rows.changes, by: (row) => row.id }, (row) =>
|
|
|
46
48
|
|
|
47
49
|
## See also
|
|
48
50
|
|
|
49
|
-
- [`List.each` API reference](https://weftui.dev/docs/reference/core#listeach)
|
|
50
|
-
- [Reactive Primitives](https://weftui.dev/docs/explanation/reactive-primitives)
|
|
51
|
-
- [examples/keyed-list](https://github.com/stefvw93/weft/tree/main/examples/keyed-list)
|
|
51
|
+
- [`List.each` API reference](https://weftui.dev/docs/reference/core#listeach): full signature, `List.Options`, and the descriptor shape
|
|
52
|
+
- [Reactive Primitives](https://weftui.dev/docs/explanation/reactive-primitives): the stream-shaped sources `of` accepts
|
|
53
|
+
- [examples/keyed-list](https://github.com/stefvw93/weft/tree/main/examples/keyed-list): a runnable keyed list with reordering and a browser test
|
|
@@ -7,12 +7,14 @@ description: renderToString / renderToStringHydratable / streaming variants, hyd
|
|
|
7
7
|
|
|
8
8
|
# Server-Side Rendering
|
|
9
9
|
|
|
10
|
-
Weft renders on the server and **hydrates** on the client
|
|
10
|
+
Weft renders on the server and **hydrates** on the client. The server produces HTML plus inline data, and the browser adopts that existing DOM in place rather than re-creating it.
|
|
11
|
+
|
|
12
|
+
[`Boundary.rpc`](https://weftui.dev/docs/reference/core#boundaryrpc) extends this to **rpc-backed server data**: resolve an rpc on the server, serialize its result into the HTML, and replay it on the client without a second request. The region then stays live for refetch.
|
|
11
13
|
|
|
12
14
|
## The two halves
|
|
13
15
|
|
|
14
|
-
- **Server
|
|
15
|
-
- **Client
|
|
16
|
+
- **Server**: `@weftui/dom/server` renders an app node to an HTML string (or stream). The _hydratable_ variants also emit the inline data each reactive region and `Boundary.rpc` needs to resume on the client.
|
|
17
|
+
- **Client**: `@weftui/dom/client`'s `WeftApp.hydrate` walks the server DOM, adopts it, wires up reactivity and event handlers, and resumes from the inline data. It does **not** re-render from scratch.
|
|
16
18
|
|
|
17
19
|
```typescript
|
|
18
20
|
// server entry
|
|
@@ -25,15 +27,16 @@ export const render = (): Promise<string> => Effect.runPromise(renderToStringHyd
|
|
|
25
27
|
|
|
26
28
|
```typescript
|
|
27
29
|
// client entry
|
|
28
|
-
import {
|
|
30
|
+
import { WeftApp } from "@weftui/dom/client";
|
|
29
31
|
import { Effect } from "effect";
|
|
30
32
|
import { App } from "./app";
|
|
31
33
|
|
|
32
34
|
const root = document.getElementById("root")!;
|
|
33
|
-
|
|
35
|
+
const app = WeftApp.make();
|
|
36
|
+
void Effect.runPromise(WeftApp.hydrate(app, App(), root));
|
|
34
37
|
```
|
|
35
38
|
|
|
36
|
-
|
|
39
|
+
Both entries import the same side-effect-free `App`. Splice the server HTML into your template's outlet, ship it, and let the client entry hydrate it.
|
|
37
40
|
|
|
38
41
|
`@weftui/dom/server` exports four renderers:
|
|
39
42
|
|
|
@@ -46,7 +49,9 @@ Use a hydratable renderer whenever the client will call `hydrate`. The plain ren
|
|
|
46
49
|
|
|
47
50
|
## Loading server data with `Boundary.rpc`
|
|
48
51
|
|
|
49
|
-
|
|
52
|
+
`Boundary.rpc` resolves an rpc **on the server**, serializes the result into the same HTML this page produces, and replays it on the client during `hydrate`. There is no second request and no fallback flash, and the region stays live for `refetch`.
|
|
53
|
+
|
|
54
|
+
It follows the same server/client split: the rpc **contract** (pure Schema) is shared, while its **handler** lives in a server-only Layer the client never imports.
|
|
50
55
|
|
|
51
56
|
```typescript
|
|
52
57
|
import { Boundary, h } from "@weftui/core";
|
|
@@ -67,20 +72,20 @@ const StockPanel = (productId: number) =>
|
|
|
67
72
|
);
|
|
68
73
|
```
|
|
69
74
|
|
|
70
|
-
Under SSR the server resolves the rpc in-process, `successSchema`-encodes the result inline as `<script type="application/json">`, and renders in place; `hydrate` reads that payload positionally, seeds the `Resource`, and adopts the DOM **without re-calling the rpc** (replay, never retry). The full model
|
|
75
|
+
Under SSR the server resolves the rpc in-process, `successSchema`-encodes the result inline as `<script type="application/json">`, and renders in place; `hydrate` reads that payload positionally, seeds the `Resource`, and adopts the DOM **without re-calling the rpc** (replay, never retry). The full model lives in one place, the [RPC Data Boundaries guide](https://weftui.dev/docs/how-to/load-data-with-rpc): the contract/handler split, router wiring, the four lifecycles, the `Resource` handle, and typed-failure replay. This page does not repeat it.
|
|
71
76
|
|
|
72
77
|
> **Note.** `Boundary.rpc` resolves through the ambient [`AppRpcClientTag`](https://weftui.dev/docs/reference/core#apprpcclienttag) seam, which `@weftui/router` provides on both sides. In a router-less mount there is no seam, so the boundary resolves to a descriptive "needs router/rpc" error (not a defect).
|
|
73
78
|
|
|
74
79
|
## When to use
|
|
75
80
|
|
|
76
|
-
- **`Boundary.rpc
|
|
77
|
-
- **`Boundary.suspend
|
|
81
|
+
- **`Boundary.rpc`**: data that must be resolved on the server (behind a server-only service, credential, or private network) and rendered into the initial HTML, then **refreshable** on the client (refetch / client-first SPA mount) over the same rpc.
|
|
82
|
+
- **`Boundary.suspend`**: async data that loads on the client (or streams the shell then fills); see the [Boundary API](https://weftui.dev/docs/reference/core#boundarysuspend).
|
|
78
83
|
|
|
79
84
|
## See also
|
|
80
85
|
|
|
81
|
-
- [rpc data boundaries guide](https://weftui.dev/docs/how-to/load-data-with-rpc)
|
|
82
|
-
- [Routing](https://weftui.dev/docs/how-to/add-routing)
|
|
86
|
+
- [rpc data boundaries guide](https://weftui.dev/docs/how-to/load-data-with-rpc): the full `Boundary.rpc` walkthrough, covering the contract/handler split, router wiring, the four lifecycles, and typed-failure replay
|
|
87
|
+
- [Routing](https://weftui.dev/docs/how-to/add-routing): `@weftui/router` builds on this SSR + hydration model for full-page nested routing
|
|
83
88
|
- [`Boundary.rpc` API reference](https://weftui.dev/docs/reference/core#boundaryrpc)
|
|
84
89
|
- [`ServerTag` API reference](https://weftui.dev/docs/reference/core#servertag)
|
|
85
|
-
- [examples/router-ssr](https://github.com/stefvw93/weft/tree/main/examples/router-ssr)
|
|
86
|
-
- [examples/ssr-hydration](https://github.com/stefvw93/weft/tree/main/examples/ssr-hydration)
|
|
90
|
+
- [examples/router-ssr](https://github.com/stefvw93/weft/tree/main/examples/router-ssr): a runnable shop with an SSR-replayed, refetchable live-stock `Boundary.rpc`
|
|
91
|
+
- [examples/ssr-hydration](https://github.com/stefvw93/weft/tree/main/examples/ssr-hydration): SSR + hydration without server data loading
|