@weftui/router 0.30.0 → 0.31.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.
@@ -32,15 +32,34 @@ const AutoFocusInput = () =>
32
32
  });
33
33
  ```
34
34
 
35
- ## How it works
35
+ ## The `ref` prop
36
36
 
37
- - **The `ref` prop** takes a `SubscriptionRef<Option<T>>`. The renderer sets it to `Option.some(element)` **once**, when the element is created. The ref is therefore an `Option`: `None` until mount, `Some(el)` after.
38
- - **React to mount** by observing `SubscriptionRef.changes(ref)`. `Stream.filter(Option.isSome)` waits for the element, `Stream.take(1)` takes just the first appearance, and `Stream.runForEach` does the imperative work. This is the equivalent of a mount effect.
39
- - **Use `Effect.forkScoped`, not `Effect.forkChild`.** `forkScoped` ties the observer fiber to the component's **instance scope** (the ambient `Scope` the renderer provides). It lives as long as the component is mounted. A bare `Effect.forkChild` binds to the transient component-body fiber and is interrupted the instant the generator returns, so the observer would never fire.
37
+ `ref` accepts a `SubscriptionRef<Option<T>>`, and nothing else: a plain `Ref` doesn't match the prop's type, and the renderer only recognizes a `SubscriptionRef`. The renderer sets it to `Option.some(element)` **once**, when the element is created:
38
+
39
+ ```typescript
40
+ ref?:
41
+ | SubscriptionRef.SubscriptionRef<Option.Option<T>>
42
+ | ReadonlyArray<SubscriptionRef.SubscriptionRef<Option.Option<any>>>;
43
+ ```
44
+
45
+ The ref is an `Option` because of this timing: `None` until mount, `Some(el)` after. It stays `Some` after unmount too; nothing clears it.
46
+
47
+ ## Fork the observer with `Effect.forkScoped`
48
+
49
+ `Stream.filter(Option.isSome)` waits for the element, `Stream.take(1)` takes just its first appearance, and `Stream.runForEach` runs the imperative work once. Fork that pipeline with `Effect.forkScoped`, never `Effect.forkChild`:
50
+
51
+ ```typescript
52
+ declare const observer: Effect.Effect<void>; // the filter/take/runForEach pipeline
53
+
54
+ yield * Effect.forkScoped(observer); // ties the fiber to the component's instance scope
55
+ yield * Effect.forkChild(observer); // wrong: dies the instant the component body returns
56
+ ```
57
+
58
+ `forkScoped` ties the fiber to the component's **instance scope**, the ambient `Scope` the renderer provides per component. It lives as long as the component is mounted. `forkChild` binds to the transient component-body fiber instead, which is interrupted the instant the generator returns, so the observer would never fire.
40
59
 
41
60
  ## Read a ref imperatively
42
61
 
43
- When you only need the element later (e.g. in a click handler), skip the observer and read the ref on demand:
62
+ When you only need the element later (e.g. in a click handler), skip the observer and read the ref on demand with `SubscriptionRef.get`:
44
63
 
45
64
  ```typescript
46
65
  const scroll = () =>
@@ -50,11 +69,110 @@ const scroll = () =>
50
69
  });
51
70
  ```
52
71
 
72
+ ## Share a ref across behaviors
73
+
74
+ `ref` also accepts a `ReadonlyArray` of refs: every entry receives the element (fan-out). This is the single-ref contract that fan-out builds on, so a shared behavior's ref and your own can coexist on the same element:
75
+
76
+ ```typescript
77
+ h.div({ ref: [measureRef, focusRef] });
78
+ ```
79
+
80
+ `Props.merge` produces this array automatically when both prop bags being merged carry a `ref`, concatenating rather than overwriting. See [Compose Behavior and Markup](https://weftui.dev/docs/how-to/compose-behavior-and-markup) for the full merge rules.
81
+
82
+ ## Complete example
83
+
84
+ An auto-focusing input and a measured box, mounted with no other services. The whole file set:
85
+
86
+ ```html
87
+ <!-- index.html -->
88
+ <!doctype html>
89
+ <html lang="en">
90
+ <head>
91
+ <meta charset="UTF-8" />
92
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
93
+ <title>Weft element ref demo</title>
94
+ </head>
95
+ <body>
96
+ <div id="root"></div>
97
+ <script type="module" src="/src/main.ts"></script>
98
+ </body>
99
+ </html>
100
+ ```
101
+
102
+ ```typescript
103
+ // src/app.ts
104
+ /**
105
+ * Element ref demo: an auto-focusing input and a box that reports its own
106
+ * measured size after mount. Side-effect-free (no mount call), so `main.ts`
107
+ * and any test can import `App` directly.
108
+ */
109
+ import { h } from "@weftui/core";
110
+ import { Effect, Option, pipe, Stream, SubscriptionRef } from "effect";
111
+
112
+ const AutoFocusInput = () =>
113
+ Effect.gen(function* () {
114
+ const inputRef = yield* SubscriptionRef.make<Option.Option<HTMLInputElement>>(Option.none());
115
+
116
+ yield* pipe(
117
+ SubscriptionRef.changes(inputRef),
118
+ Stream.filter(Option.isSome),
119
+ Stream.take(1),
120
+ Stream.runForEach((el) => Effect.sync(() => el.value.focus())),
121
+ Effect.forkScoped,
122
+ );
123
+
124
+ return yield* h.input({ ref: inputRef, type: "text", placeholder: "I'm focused!" });
125
+ });
126
+
127
+ const MeasuredBox = () =>
128
+ Effect.gen(function* () {
129
+ const boxRef = yield* SubscriptionRef.make<Option.Option<HTMLDivElement>>(Option.none());
130
+ const size = yield* SubscriptionRef.make("measuring...");
131
+
132
+ yield* pipe(
133
+ SubscriptionRef.changes(boxRef),
134
+ Stream.filter(Option.isSome),
135
+ Stream.take(1),
136
+ Stream.runForEach((el) =>
137
+ Effect.gen(function* () {
138
+ const rect = el.value.getBoundingClientRect();
139
+ yield* SubscriptionRef.set(size, `${rect.width}x${rect.height}`);
140
+ }),
141
+ ),
142
+ Effect.forkScoped,
143
+ );
144
+
145
+ return yield* h.div([
146
+ h.div({ ref: boxRef, style: { width: "200px", height: "80px", border: "1px solid" } }),
147
+ h.p(["size: ", SubscriptionRef.changes(size)]),
148
+ ]);
149
+ });
150
+
151
+ export const App = () => h.div([AutoFocusInput(), MeasuredBox()]);
152
+ ```
153
+
154
+ ```typescript
155
+ // src/main.ts
156
+ /**
157
+ * Browser entry: mounts the demo into `#root`. No app layer is needed here,
158
+ * so `WeftApp.make()` takes no arguments.
159
+ */
160
+ import { WeftApp } from "@weftui/dom/client";
161
+ import { Effect } from "effect";
162
+ import { App } from "./app";
163
+
164
+ const root = document.getElementById("root");
165
+ if (root === null) {
166
+ throw new Error("#root not found");
167
+ }
168
+
169
+ const app = WeftApp.make();
170
+ void Effect.runPromise(WeftApp.mount(app, App(), root));
171
+ ```
172
+
53
173
  ## Notes
54
174
 
55
- - A plain `Ref` suffices if you **only** read the element imperatively; use `SubscriptionRef` when you need to **react** to it becoming available.
56
- - Refs are set once at element creation and are not cleared on unmount.
57
- - **Several refs can share one element.** `ref` also accepts an array, and every entry receives the element: `h.div({ ref: [measure, focus] })`. `Props.merge` produces such an array when both bags carry a `ref`, so a shared behavior's ref and your own can coexist. See [Compose Behavior and Markup](https://weftui.dev/docs/how-to/compose-behavior-and-markup).
175
+ - A component with local state, like the two above, is written as a plain `Effect.gen` function; see [Component Authoring](https://weftui.dev/docs/how-to/author-components#components-with-internal-state).
58
176
  - Coming from React: `SubscriptionRef.make<Option<T>>(Option.none())` ↔ `useRef<T>(null)`; the `Stream.filter(Option.isSome)` observer ↔ a `useEffect` mount guard.
59
177
 
60
178
  ## See also
@@ -421,6 +421,26 @@ type Source.Source<A, E, R> = A | Effect.Effect<A, E, R> | Stream.Stream<A, E, R
421
421
 
422
422
  Any prop or child that supports reactivity accepts a `Source.Source`. Static values, Effects, Streams, and Subscribables are all valid.
423
423
 
424
+ #### `Source.changes(source)`
425
+
426
+ Returns the change stream of any `Source`, without normalizing through
427
+ `Subscribable` first:
428
+
429
+ ```typescript
430
+ Source.changes<A, E, R>(source: Source.Source<A, E, R>): Stream.Stream<A, E, R>
431
+ ```
432
+
433
+ Variant mapping:
434
+
435
+ - **`Subscribable`** → its `changes` stream, by reference
436
+ - **`Stream`** → returned as-is (identity, no wrap)
437
+ - **`Effect`** → `Stream.fromEffect(source)`, emitting the resolved value once
438
+ - **Static value** → `Stream.make(value)`, emitting once
439
+
440
+ Unlike `Source.toSubscribable`, this allocates no `SubscriptionRef`, latch, or
441
+ pump fiber. Use it where only the change stream is needed, e.g. list
442
+ reconciliation reading a `Source<Iterable<A>>` directly.
443
+
424
444
  #### `Source.toSubscribable(source, key?)`
425
445
 
426
446
  Normalizes any `Source.Source` into a hot `Subscribable<A, E | NoPropValue, R>` scoped to the enclosing `Scope`:
@@ -156,6 +156,8 @@ any root. Examples: `app.runtime.runFork(trackPageviews)` (see
156
156
  interface RootHandle {
157
157
  readonly element: HTMLElement;
158
158
  unmount(): Effect.Effect<void>;
159
+ readonly awaitCommit: Effect.Effect<number>;
160
+ readonly commitGeneration: Effect.Effect<number>;
159
161
  }
160
162
  ```
161
163
 
@@ -166,6 +168,47 @@ subscriptions and any scoped work forked from its event handlers.
166
168
  It does **not** dispose the app runtime, touch other roots, or remove the rendered
167
169
  DOM nodes from `element`. Idempotent: teardown side effects fire once.
168
170
 
171
+ #### `RootHandle.awaitCommit`
172
+
173
+ ```ts
174
+ readonly awaitCommit: Effect.Effect<number>;
175
+ ```
176
+
177
+ Resolves when everything dirty at the time you run this effect has committed to
178
+ the DOM or been discarded, yielding the commit generation.
179
+
180
+ - **Immediate when idle.** If nothing is dirty, it resolves right away with the
181
+ current generation; there is no forced tick.
182
+ - **Quiescence-scoped, not future-scoped.** It covers only writes already
183
+ delivered to the Loom at call time, not values a descendant pump writes
184
+ later. Stream delivery from a `set` to its region's cell is itself
185
+ asynchronous, so give the pump a beat (or check the DOM, or compare
186
+ `commitGeneration`) before treating one `awaitCommit` as covering that
187
+ specific write.
188
+ - **Resolves across `WeftApp.dispose`.** Interrupting the app's flush fiber
189
+ resolves every outstanding barrier; no caller hangs across app disposal.
190
+ - **App-scoped, not root-scoped.** One Loom is shared by every root of a
191
+ `WeftApp`. With multiple mounted roots, `awaitCommit` may also wait on a
192
+ sibling root's pending commits (a documented superset of "this root's
193
+ commits"). Per-root filtering is not implemented.
194
+
195
+ ```ts
196
+ yield * SubscriptionRef.set(count, 1);
197
+ yield * Effect.sleep("10 millis"); // let the emission reach the region's cell
198
+ yield * handle.awaitCommit; // everything delivered so far is now in the DOM
199
+ ```
200
+
201
+ #### `RootHandle.commitGeneration`
202
+
203
+ ```ts
204
+ readonly commitGeneration: Effect.Effect<number>;
205
+ ```
206
+
207
+ The app's current commit generation: a monotonic counter, shared across every
208
+ root of the `WeftApp`, incremented once per flush pass that committed at least
209
+ one cell. Reading it does not wait for anything in flight; pair it with
210
+ `awaitCommit` to observe a specific commit rather than just the latest count.
211
+
169
212
  ### `UnhandledError`
170
213
 
171
214
  ```ts
@@ -9,6 +9,8 @@ description: "Install Weft, build a component with the h namespace, and mount it
9
9
 
10
10
  We assume you know [Effect](https://effect.website/docs/getting-started/introduction) fundamentals. Weft is Effect for the UI, so we will not re-explain `Effect.gen`, services, or streams from scratch.
11
11
 
12
+ Across this tutorial you build one app: a counter. This step renders its static shell.
13
+
12
14
  ## Install
13
15
 
14
16
  ```bash
@@ -17,33 +19,61 @@ npm install @weftui/core @weftui/dom effect@beta
17
19
 
18
20
  Weft tracks Effect 4's beta line. This release is built and tested against `effect@4.0.0-beta.98`; the peer range accepts newer 4.0 betas, which may contain upstream breaking changes.
19
21
 
20
- `@weftui/core` gives you the element builders and combinators; `@weftui/dom` renders them (its `./client` entry mounts in the browser). `effect` is the peer everything is built on.
21
-
22
- ## Build a component
22
+ ## Build and mount it
23
23
 
24
- A **component is a plain function you call**. There is no JSX and no `<Component/>` deferral. It returns a `Node`, which is just an `Effect` that produces a DOM node:
24
+ A **component is a plain function you call**. There is no JSX and no `<Component/>` deferral. `App()` returns a `Node`, and the `h` namespace builds one: every property (`h.div`, `h.h1`, `h.button`, …) is a builder for that HTML tag, taking optional props and children.
25
25
 
26
26
  ```typescript
27
+ // src/app.ts
27
28
  import { h } from "@weftui/core";
29
+
30
+ export function App() {
31
+ return h.div({ class: "app" }, [
32
+ h.h1("Weft Counter"),
33
+ h.p({ class: "count" }, "Count: 0"),
34
+ h.div({ class: "controls" }, [
35
+ h.button({ type: "button" }, "−"),
36
+ h.button({ type: "button" }, "+"),
37
+ ]),
38
+ ]);
39
+ }
40
+ ```
41
+
42
+ ```typescript
43
+ // src/main.ts
28
44
  import { WeftApp } from "@weftui/dom/client";
29
45
  import { Effect } from "effect";
46
+ import { App } from "./app";
30
47
 
31
- function App() {
32
- return h.div({ class: "app" }, [h.h1("Hello, Weft"), h.p("A minimal app.")]);
33
- }
48
+ const root = document.getElementById("root")!;
34
49
 
35
50
  const app = WeftApp.make();
36
- void Effect.runPromise(WeftApp.mount(app, App(), document.getElementById("root")!));
51
+ void Effect.runPromise(WeftApp.mount(app, App(), root));
52
+ ```
53
+
54
+ ```html
55
+ <!-- index.html -->
56
+ <!doctype html>
57
+ <html lang="en">
58
+ <head>
59
+ <meta charset="UTF-8" />
60
+ <title>Weft counter</title>
61
+ </head>
62
+ <body>
63
+ <div id="root"></div>
64
+ <script type="module" src="/src/main.ts"></script>
65
+ </body>
66
+ </html>
37
67
  ```
38
68
 
39
- The `h` namespace is the entry point: every property (`h.div`, `h.h1`, `h.button`, …) is a builder for that HTML tag. A builder takes optional props and children, and returns a `Node`.
69
+ Run it with `vite` (or any dev server that serves ES modules) and you get a heading, a static count, and two inert buttons. The buttons don't do anything yet: that's next.
40
70
 
41
71
  ## What just happened
42
72
 
43
- - `App()` returns a **`Node<never, never>`**. The two type parameters are the error channel (`E`) and the requirement channel (`R`), both `never` here because this component neither fails nor needs a service. As your app grows, those channels accumulate what it can fail with and what it depends on. That is the whole point of Weft's types: see [The Rendering Model](https://weftui.dev/docs/explanation/rendering-model).
44
- - `WeftApp.make()` creates a Weft app synchronously, with no layer to build yet. `WeftApp.mount(app, node, target)` renders the node into `target`, building real DOM and starting any reactive streams. It returns `Effect<RootHandle, …>` with `R = never`, so a bare `Effect.runPromise` runs it with no `Effect.provide` needed. You will give `WeftApp.make` a `Layer` once components need services: see [Services and Async](https://weftui.dev/docs/tutorial/03-services-and-async).
45
- - The component function runs **once**. Nothing here re-runs on a timer or a state change, because there is no state yet. That comes next.
73
+ - `App()` returns a **`Node<never, never>`**, an `Effect` that resolves to an element descriptor, not a DOM node yet. `E` and `R` are `never` because this component neither fails nor needs a service. As your app grows, those channels accumulate what it can fail with and what it depends on: see [The Rendering Model](https://weftui.dev/docs/explanation/rendering-model).
74
+ - `WeftApp.make()` creates a Weft app synchronously, with no layer to build yet. `WeftApp.mount(app, node, root)` renders `node` into `root`, building real DOM. It returns `Effect<RootHandle, …>` with `R = never`, so a bare `Effect.runPromise` runs it. You'll give `WeftApp.make` a `Layer` once components need services: see [Services and Async](https://weftui.dev/docs/tutorial/03-services-and-async).
75
+ - `App` runs **once**. Nothing re-invokes it, because there's no state yet.
46
76
 
47
77
  ## Next
48
78
 
49
- - [Reactivity →](https://weftui.dev/docs/tutorial/02-reactivity): make the UI change over time with `SubscriptionRef` and streams
79
+ - [Reactivity →](https://weftui.dev/docs/tutorial/02-reactivity): wire up the counter with `SubscriptionRef` and streams
@@ -7,54 +7,53 @@ description: Add component-local state with SubscriptionRef and weave its stream
7
7
 
8
8
  # Reactivity
9
9
 
10
- [Previously](https://weftui.dev/docs/tutorial/01-your-first-app) we mounted a static component. Now we make it change over time. This is the defining move in Weft: **weave a stream through the tree, and only that point updates.**
10
+ [Previously](https://weftui.dev/docs/tutorial/01-your-first-app) you mounted a static counter shell. Now wire it up. This is the defining move in Weft: **weave a stream through the tree, and only that point updates.**
11
11
 
12
- ## Local state with `SubscriptionRef`
12
+ ## Wire up the counter
13
13
 
14
- Use Effect's `SubscriptionRef` for component-local state. `SubscriptionRef.changes(ref)` returns a `Stream` that emits the current value and then every update. Pass that stream as a child or prop and the DOM at that spot becomes live:
14
+ Use Effect's `SubscriptionRef` for component-local state. `SubscriptionRef.changes(ref)` returns a `Stream` that emits the current value and then every update. Pass that stream (or a derived stream) as a child and the DOM at that spot becomes live. Replace `src/app.ts`:
15
15
 
16
16
  ```typescript
17
+ // src/app.ts
17
18
  import { h } from "@weftui/core";
18
- import { WeftApp } from "@weftui/dom/client";
19
- import { Effect, SubscriptionRef } from "effect";
19
+ import { Effect, Stream, SubscriptionRef } from "effect";
20
20
 
21
- const Counter = () =>
21
+ export const App = () =>
22
22
  Effect.gen(function* () {
23
23
  const count = yield* SubscriptionRef.make(0);
24
-
25
- return yield* h.div([
26
- h.span([SubscriptionRef.changes(count)]),
27
- h.button({ onclick: () => SubscriptionRef.update(count, (n) => n + 1) }, "+"),
28
- h.button({ onclick: () => SubscriptionRef.update(count, (n) => n - 1) }, "-"),
24
+ const label = Stream.map(SubscriptionRef.changes(count), (n) => `Count: ${n}`);
25
+
26
+ return yield* h.div({ class: "app" }, [
27
+ h.h1("Weft Counter"),
28
+ h.p({ class: "count" }, [label]),
29
+ h.div({ class: "controls" }, [
30
+ h.button(
31
+ { type: "button", onclick: () => SubscriptionRef.update(count, (n) => n - 1) },
32
+ "−",
33
+ ),
34
+ h.button(
35
+ { type: "button", onclick: () => SubscriptionRef.update(count, (n) => n + 1) },
36
+ "+",
37
+ ),
38
+ ]),
29
39
  ]);
30
40
  });
31
-
32
- const app = WeftApp.make();
33
- void Effect.runPromise(WeftApp.mount(app, Counter(), document.getElementById("root")!));
34
41
  ```
35
42
 
36
- `Effect.gen` lets you `yield*` the `SubscriptionRef` to set up state **before** building the tree. Because a `Node` is an `Effect`, the component body is an ordinary generator: no hooks, no dependency arrays.
37
-
38
- ## The key idea: the body runs once
39
-
40
- The `Counter` function runs **exactly once**. It creates the ref, builds the tree, and returns. After that, nothing re-invokes it. The only thing that changes the DOM is the `SubscriptionRef.changes(count)` stream woven into the `h.span`.
43
+ `main.ts` and `index.html` don't change. Reload and the buttons work.
41
44
 
42
- When you click `+`, `SubscriptionRef.update` pushes a new value, the stream emits, and the renderer patches _just that span's text_ in place. No diff, no re-render, no sibling touched.
45
+ ## Why this works
43
46
 
44
- This is what "streams are the weft" means in practice: reactivity is local to exactly where you thread a stream. Everything else is static. The full model is [The Rendering Model](https://weftui.dev/docs/explanation/rendering-model); the vocabulary of stream-shaped values is [Reactive Primitives](https://weftui.dev/docs/explanation/reactive-primitives).
47
+ `App`'s body runs **exactly once**: it creates the ref, builds the tree, and returns. Nothing re-invokes it afterward. The only thing that changes the DOM is the `label` stream woven into `h.p`.
45
48
 
46
- > **Note.** In `[SubscriptionRef.changes(count)]` the stream is passed as a child array. Static values (`"Hello"`, `5`) work in the same position and simply never change. The rule is uniform: a plain value is static, a stream-shaped value is reactive.
49
+ Click `+` and `SubscriptionRef.update` pushes a new value, `label` emits `"Count: 1"`, and the renderer patches _just that paragraph's text_ in place. No diff, no re-render, no sibling touched.
47
50
 
48
- ## Deriving values
51
+ `label` also shows **deriving values**: because `SubscriptionRef.changes(count)` is a `Stream`, you shape reactive text with ordinary stream operators (`Stream.map` here) instead of a templating syntax. Anywhere you'd compute a derived value, map the stream.
49
52
 
50
- Because `SubscriptionRef.changes(count)` returns a `Stream`, you shape reactive text with ordinary stream operators:
51
-
52
- ```typescript
53
- h.span([Stream.map(SubscriptionRef.changes(count), (n) => `Count: ${n}`)]);
54
- ```
53
+ > **Note.** A stream-shaped child or prop is reactive; a static value (`"Hello"`, `5`) is not and never changes. `h.h1("Weft Counter")` above is static for exactly that reason. The rule is uniform across the whole tree.
55
54
 
56
- Anywhere you would compute a derived value, map the stream instead. The derivation stays reactive.
55
+ The full model is [The Rendering Model](https://weftui.dev/docs/explanation/rendering-model); the vocabulary of stream-shaped values is [Reactive Primitives](https://weftui.dev/docs/explanation/reactive-primitives).
57
56
 
58
57
  ## Next
59
58
 
60
- - [Services and Async →](https://weftui.dev/docs/tutorial/03-services-and-async): pull dependencies from the environment and render async loading states
59
+ - [Services and Async →](https://weftui.dev/docs/tutorial/03-services-and-async): read services from a button handler and load data asynchronously
@@ -7,15 +7,13 @@ description: Give handlers access to services from the environment, and render a
7
7
 
8
8
  # Services and Async
9
9
 
10
- [So far](https://weftui.dev/docs/tutorial/02-reactivity) our state has been self-contained. Real apps talk to services and wait on async work. Both fall out of the same fact (a `Node` is an `Effect`), so both use plain Effect.
10
+ [So far](https://weftui.dev/docs/tutorial/02-reactivity) the counter's state has been self-contained. Real apps talk to services and wait on async work. Both fall out of the same fact (a `Node` is an `Effect`), so both use plain Effect. This step adds a logging service to the buttons and an async fact panel below the counter.
11
11
 
12
12
  ## Handlers that use services
13
13
 
14
14
  An event handler can **return an Effect**. That Effect runs in the app's environment, so it can read any service the app's layer provides:
15
15
 
16
16
  ```typescript
17
- import { h } from "@weftui/core";
18
- import { WeftApp } from "@weftui/dom/client";
19
17
  import { Context, Effect, Layer } from "effect";
20
18
 
21
19
  class Logger extends Context.Service<Logger, { log: (message: string) => Effect.Effect<void> }>()(
@@ -23,60 +21,113 @@ class Logger extends Context.Service<Logger, { log: (message: string) => Effect.
23
21
  ) {}
24
22
 
25
23
  const LoggerLive = Layer.succeed(Logger, {
26
- log: (message) => Effect.sync(() => console.log(message)),
24
+ log: (message) => Effect.log(message),
27
25
  });
26
+ ```
28
27
 
29
- const LogButton = () =>
30
- h.button(
31
- {
32
- onclick: () =>
33
- Effect.gen(function* () {
34
- const logger = yield* Logger;
35
- yield* logger.log("Button clicked");
36
- }),
37
- },
38
- "Log",
39
- );
28
+ Wire it into the counter's `step` handler, so every click logs before updating state:
40
29
 
41
- // Give the app the layer: every handler in every root can now read Logger.
42
- const app = WeftApp.make(LoggerLive);
43
- void Effect.runPromise(WeftApp.mount(app, LogButton(), document.getElementById("root")!));
30
+ ```typescript
31
+ const step = (delta: number) =>
32
+ Effect.gen(function* () {
33
+ const logger = yield* Logger;
34
+ yield* SubscriptionRef.update(count, (n) => n + delta);
35
+ yield* logger.log(`count changed by ${delta}`);
36
+ });
44
37
  ```
45
38
 
46
- `Logger` entered the tree's requirement channel the moment `LogButton` read it. You discharged it **once**, by passing `LoggerLive` to `WeftApp.make`. Provide too little and it is a compile error. The type of `app` (and so of `WeftApp.mount(app, LogButton(), …)`) names exactly which service is missing.
47
-
48
- Services come exclusively from the app's layer: an `Effect.provide` wrapped around the `mount` call does **not** reach components or handlers. This is Weft's entire dependency-injection story; it is just Effect's. The deeper treatment is [Services and Context](https://weftui.dev/docs/explanation/services-and-context).
39
+ `Logger` entered the tree's requirement channel the moment `step` read it. You'll discharge it **once**, by passing `LoggerLive` to `WeftApp.make`. Provide too little and it's a compile error. Services come exclusively from the app's layer: an `Effect.provide` wrapped around the `mount` call does **not** reach components or handlers. This is Weft's entire dependency-injection story; it's just Effect's. The deeper treatment is [Services and Context](https://weftui.dev/docs/explanation/services-and-context).
49
40
 
50
41
  ## Async loading states
51
42
 
52
43
  A component can return a **`Stream<Node>`** to show different content over time. Sequence a loading placeholder before the resolved content with `Stream.concat`:
53
44
 
54
45
  ```typescript
46
+ const fetchFact = (n: number) =>
47
+ Effect.gen(function* () {
48
+ yield* Effect.sleep("800 millis");
49
+ return `${n} is ${n % 2 === 0 ? "even" : "odd"}.`;
50
+ });
51
+
52
+ const NumberFact = ({ n }: { n: number }) =>
53
+ Stream.concat(
54
+ Stream.make(h.p({ class: "fact" }, "Loading a fact…")),
55
+ Stream.fromEffect(Effect.map(fetchFact(n), (fact) => h.p({ class: "fact" }, fact))),
56
+ );
57
+ ```
58
+
59
+ The stream emits the loading node first, then the resolved node. The renderer swaps the DOM in place on the second emission. This is the raw mechanism; to coordinate _several_ async regions with one fallback, reach for [`Boundary.suspend`](https://weftui.dev/docs/explanation/boundaries-and-suspense).
60
+
61
+ ## Put it together
62
+
63
+ Replace `src/app.ts`, adding both pieces as part of the same tree:
64
+
65
+ ```typescript
66
+ // src/app.ts
55
67
  import { h } from "@weftui/core";
56
- import { WeftApp } from "@weftui/dom/client";
57
- import { Effect, Stream } from "effect";
68
+ import { Context, Effect, Layer, Stream, SubscriptionRef } from "effect";
69
+
70
+ export class Logger extends Context.Service<
71
+ Logger,
72
+ { log: (message: string) => Effect.Effect<void> }
73
+ >()("Logger") {}
74
+
75
+ export const LoggerLive = Layer.succeed(Logger, {
76
+ log: (message) => Effect.log(message),
77
+ });
58
78
 
59
- const AsyncGreeting = ({ name }: { name: string }) =>
79
+ const fetchFact = (n: number) =>
80
+ Effect.gen(function* () {
81
+ yield* Effect.sleep("800 millis");
82
+ return `${n} is ${n % 2 === 0 ? "even" : "odd"}.`;
83
+ });
84
+
85
+ const NumberFact = ({ n }: { n: number }) =>
60
86
  Stream.concat(
61
- Stream.make(h.span("Loading…")),
62
- Stream.fromEffect(
63
- Effect.gen(function* () {
64
- yield* Effect.sleep("1 second");
65
- return yield* h.span(`Hello, ${name}!`);
66
- }),
67
- ),
87
+ Stream.make(h.p({ class: "fact" }, "Loading a fact…")),
88
+ Stream.fromEffect(Effect.map(fetchFact(n), (fact) => h.p({ class: "fact" }, fact))),
68
89
  );
69
90
 
70
- const app = WeftApp.make();
71
- void Effect.runPromise(
72
- WeftApp.mount(app, AsyncGreeting({ name: "World" }), document.getElementById("root")!),
73
- );
91
+ export const App = () =>
92
+ Effect.gen(function* () {
93
+ const count = yield* SubscriptionRef.make(0);
94
+ const label = Stream.map(SubscriptionRef.changes(count), (n) => `Count: ${n}`);
95
+
96
+ const step = (delta: number) =>
97
+ Effect.gen(function* () {
98
+ const logger = yield* Logger;
99
+ yield* SubscriptionRef.update(count, (n) => n + delta);
100
+ yield* logger.log(`count changed by ${delta}`);
101
+ });
102
+
103
+ return yield* h.div({ class: "app" }, [
104
+ h.h1("Weft Counter"),
105
+ h.p({ class: "count" }, [label]),
106
+ h.div({ class: "controls" }, [
107
+ h.button({ type: "button", onclick: () => step(-1) }, "−"),
108
+ h.button({ type: "button", onclick: () => step(1) }, "+"),
109
+ ]),
110
+ NumberFact({ n: 3 }),
111
+ ]);
112
+ });
74
113
  ```
75
114
 
76
- The stream emits the loading node first, then the resolved node. The renderer swaps the DOM in place on the second emission.
115
+ Give the app the layer in `src/main.ts`:
116
+
117
+ ```typescript
118
+ // src/main.ts
119
+ import { WeftApp } from "@weftui/dom/client";
120
+ import { Effect } from "effect";
121
+ import { App, LoggerLive } from "./app";
122
+
123
+ const root = document.getElementById("root")!;
124
+
125
+ const app = WeftApp.make(LoggerLive);
126
+ void Effect.runPromise(WeftApp.mount(app, App(), root));
127
+ ```
77
128
 
78
- This is the raw mechanism. To coordinate _several_ async regions with a single fallback, reach for [`Boundary.suspend`](https://weftui.dev/docs/explanation/boundaries-and-suspense), which you will meet in the next step.
129
+ Reload: the fact panel shows "Loading a fact…" then swaps in, and every click logs to the console.
79
130
 
80
131
  ## Next
81
132
 
82
- - [Errors and Server Rendering →](https://weftui.dev/docs/tutorial/04-errors-and-server): catch failures with boundaries and render on the server
133
+ - [Errors and Server Rendering →](https://weftui.dev/docs/tutorial/04-errors-and-server): catch the fact panel's failures with a boundary and render the whole app on the server