@weftui/core 0.30.0 → 0.31.1

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.
@@ -9,7 +9,7 @@ description: Code-split a route's component into its own chunk with Router.lazy,
9
9
 
10
10
  **Goal:** keep a heavy page's render code (and its dependencies) out of the initial bundle, loading it only when its route is actually rendered.
11
11
 
12
- Wrap the route's `component` in [`Router.lazy`](https://weftui.dev/docs/reference/router#routerlazy). The route **descriptor** (its segment and param schemas) stays eager, so the matcher, `href`, and the server's dispatch API still see it statically. Only the component body is split into its own chunk.
12
+ Wrap the route's `component` in [`Router.lazy`](https://weftui.dev/docs/reference/router#routerlazy):
13
13
 
14
14
  ```typescript
15
15
  import { Router } from "@weftui/router";
@@ -21,7 +21,7 @@ Router.route("docs/:category/:slug", {
21
21
  });
22
22
  ```
23
23
 
24
- The chunk loads on the server during render and on the client on navigation. Only the **matched branch's** chunks are ever fetched.
24
+ The route's **descriptor** (segment, `path`/`query` schemas) stays eager, so the matcher, `href`, and the server's dispatch API still see it statically. Only the component body is split into its own chunk, fetched on the server during render and on the client on navigation. Only the **matched branch's** chunks are ever loaded.
25
25
 
26
26
  `E`/`R` are preserved: a lazy route has the exact same channels as the same component declared eagerly. An unmet service requirement is still a compile error at `Router.router(...)`.
27
27
 
@@ -48,14 +48,117 @@ A descriptor file that still `import`s the impl statically gains nothing: the bu
48
48
 
49
49
  - **Flash-free hydration.** On a directly-loaded lazy route, the client re-invokes the same slot, awaits the chunk, and adopts the server DOM in place. The first production matches, so nothing is mutated.
50
50
  - **Blank-free navigation.** Client navigation is **deferred-commit**: the router resolves the target branch's chunk **and the matched leaf's own component effect** _before_ committing the URL. The previous page stays mounted through the fetch and any data the leaf awaits, and the swap is a single tick. See [Show Navigation Progress](https://weftui.dev/docs/how-to/show-navigation-progress) for the `Router.navigating` signal this exposes.
51
- - **Synchronous revisits.** `Router.lazy` memoizes its load per slot, so a second visit to a loaded route commits immediately.
51
+ - **Synchronous revisits.** `Router.lazy` memoizes its load per slot. The first render triggers the `import()`; every later render (including a revisit after navigating away) reuses the resolved module.
52
+
53
+ ```typescript
54
+ // One slot, created once. Its loader Promise resolves on first render and is
55
+ // reused on every later render, including back-navigation to this route.
56
+ const page = Router.lazy(() => import("./doc-page").then((m) => m.DocPage));
57
+
58
+ Router.route("docs/intro", { component: page });
59
+ ```
52
60
 
53
61
  ## Edge cases
54
62
 
55
63
  - **Lazy layouts.** A `Router.layout({ component: Router.lazy(...) })` splits too. Each lazy node in the matched branch is awaited; nodes outside it never load.
64
+
65
+ ```typescript
66
+ Router.layout(
67
+ { component: Router.lazy(() => import("./admin-shell").then((m) => m.AdminShell)) },
68
+ [settingsRoute, usersRoute],
69
+ );
70
+ ```
71
+
56
72
  - **Chunk-load failure is a defect.** If the `import()` rejects (offline, or a stale client requesting a chunk a new deploy removed), it dies as a defect and surfaces through normal defect handling. It never hangs or silently 404s. The rejection is memoized, so the route keeps failing until a reload (the deploy-skew case).
57
73
  - **Not a lazy _subtree_.** Only the component is lazy. You cannot defer a whole `RouteNode` behind an `import()`; the matcher needs every leaf's segment and param schema before anything loads.
58
74
 
75
+ ## Complete example
76
+
77
+ A client-only app with two routes: `Home` (eager) and `Lazy` (split into its own chunk). This is the whole file set, copy/paste runnable in a `vite` + `@weftui/router` project.
78
+
79
+ ```html
80
+ <!-- index.html -->
81
+ <!doctype html>
82
+ <html lang="en">
83
+ <head>
84
+ <meta charset="UTF-8" />
85
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
86
+ <title>Weft lazy routing demo</title>
87
+ </head>
88
+ <body>
89
+ <div id="root"></div>
90
+ <script type="module" src="/src/main.ts"></script>
91
+ </body>
92
+ </html>
93
+ ```
94
+
95
+ ```typescript
96
+ // src/lazy-page.ts
97
+ /**
98
+ * The lazily-loaded page body. Kept in its own module so the dynamic
99
+ * `import()` in `app.ts` is a real code-split point, not just a wrapper
100
+ * around a statically-imported value.
101
+ */
102
+ import { Component, h } from "@weftui/core";
103
+
104
+ export const LazyPage = Component.make(() =>
105
+ h.section({ id: "page" }, [h.h2("Lazy page"), h.p("Loaded on demand.")]),
106
+ );
107
+ ```
108
+
109
+ ```typescript
110
+ // src/app.ts
111
+ /**
112
+ * Client-only lazy-routing demo: a Home route declared eagerly, and a Lazy
113
+ * route whose component is code-split via `Router.lazy`. Side-effect-free (no
114
+ * mount call), so `main.ts` and any test can import `App` directly.
115
+ */
116
+ import { Component, h } from "@weftui/core";
117
+ import { href, Router } from "@weftui/router";
118
+
119
+ const homeRoute = Router.route("", {
120
+ component: Component.make(() => h.section({ id: "page" }, [h.h2("Home")])),
121
+ });
122
+
123
+ const lazyRoute = Router.route("lazy", {
124
+ component: Router.lazy(() => import("./lazy-page").then((m) => m.LazyPage)),
125
+ });
126
+
127
+ const Shell = Component.gen(function* () {
128
+ const outlet = yield* Router.Outlet;
129
+ return yield* h.div({ id: "app" }, [
130
+ h.nav([h.a({ href: href(homeRoute) }, "Home"), " · ", h.a({ href: href(lazyRoute) }, "Lazy")]),
131
+ h.main([outlet]),
132
+ ]);
133
+ });
134
+
135
+ export const App = Router.router(Router.layout({ component: Shell }, [homeRoute, lazyRoute]), {
136
+ notFound: () => h.section({ id: "page" }, [h.h2("404: page not found")]),
137
+ });
138
+ ```
139
+
140
+ ```typescript
141
+ // src/main.ts
142
+ /**
143
+ * Browser entry: mounts the lazy-routing demo into `#root`. No server render
144
+ * to hydrate, so this uses `WeftApp.mount`, not `hydrate`.
145
+ */
146
+ import { WeftApp } from "@weftui/dom/client";
147
+ import { RouterApp, RouterLive } from "@weftui/router/client";
148
+ import { Effect } from "effect";
149
+ import { App } from "./app";
150
+
151
+ const root = document.getElementById("root");
152
+ if (root === null) {
153
+ throw new Error("#root not found");
154
+ }
155
+
156
+ const app = WeftApp.make(RouterLive(App));
157
+ void Effect.runPromise(WeftApp.mount(app, RouterApp(App), root));
158
+ ```
159
+
160
+ Load `/`, open the network panel, then click "Lazy": `lazy-page`'s chunk fetches only on that click, not on initial load. Click "Home" then "Lazy" again and no second fetch fires: the slot's memo serves the resolved component.
161
+
59
162
  ## See also
60
163
 
61
164
  - [`Router.lazy` API reference](https://weftui.dev/docs/reference/router#routerlazy)
@@ -7,9 +7,9 @@ description: Drive inline styles from streams (a single property, or a whole sty
7
7
 
8
8
  # Style Reactively
9
9
 
10
- **Goal:** animate or react to state in an element's inline style without re-rendering. Drive a single CSS property, or a whole style object, from a stream.
10
+ **Goal:** animate or react to state in an element's inline style without re-rendering.
11
11
 
12
- The `style` prop accepts the [`Source`](https://weftui.dev/docs/explanation/reactive-primitives) vocabulary at any level. A property value can be a stream, and you can spread a stream of style objects. CSS `transition` composes naturally, because the renderer mutates the existing node in place.
12
+ The `style` prop accepts the [`Source`](https://weftui.dev/docs/explanation/reactive-primitives) vocabulary at either level: a single property's value, or the whole object. CSS `transition` composes naturally, because a stream emission patches the existing DOM node in place.
13
13
 
14
14
  ```typescript
15
15
  import { h } from "@weftui/core";
@@ -34,27 +34,155 @@ const AnimatedHue = () => {
34
34
  };
35
35
  ```
36
36
 
37
- ## Three modes
37
+ ## Per-property streams
38
38
 
39
- 1. **A single property as a stream.** As above: one key's value is a `Stream`, the others are static strings. Each stream property is subscribed independently.
40
- 2. **A static object.** An ordinary `style: { backgroundColor: "#667eea" }` with no streams; nothing updates.
41
- 3. **A whole style object as a stream.** Spread a stream that emits complete style objects, merged with static props:
39
+ Give one or more keys a `Stream` value and leave the rest as plain strings. Each reactive key subscribes independently, so two properties can animate off unrelated sources on the same element:
42
40
 
43
41
  ```typescript
44
- const pulse = Stream.make(1, 0.5).pipe(
45
- Stream.schedule(Schedule.spaced("800 millis")),
46
- Stream.forever,
42
+ const size = Stream.iterate(100, (s) => (s >= 150 ? 100 : s + 10)).pipe(
43
+ Stream.schedule(Schedule.spaced("200 millis")),
47
44
  );
48
45
 
49
- h.div({ style: { ...pulse, transition: "opacity 0.4s ease-in-out" } }, "Pulse");
46
+ h.div({
47
+ style: {
48
+ width: Stream.map(size, (s) => `${s}px`),
49
+ height: Stream.map(size, (s) => `${s}px`),
50
+ transition: "width 0.2s, height 0.2s",
51
+ },
52
+ });
53
+ ```
54
+
55
+ A key without a stream stays static: an ordinary `style: { backgroundColor: "#667eea" }` never updates.
56
+
57
+ ## Whole-object streams
58
+
59
+ Give `style` itself a `Stream` that emits complete style objects. Each emission replaces every property on the element, so it's the right shape for changes that move several properties together:
60
+
61
+ ```typescript
62
+ const theme = Stream.make(
63
+ { backgroundColor: "#667eea", transform: "scale(1)" },
64
+ { backgroundColor: "#764ba2", transform: "scale(1.1)" },
65
+ ).pipe(Stream.schedule(Schedule.spaced("1 second")), Stream.forever);
66
+
67
+ h.div({
68
+ // fold the static `transition` into every emitted object: the renderer
69
+ // clears and resets all style properties on each emission, so a sibling
70
+ // key can't survive alongside it. Spreading `theme` into a style object
71
+ // literal doesn't work either, since that copies the Stream's own fields,
72
+ // not the values it emits.
73
+ style: Stream.map(theme, (s) => ({ ...s, transition: "all 0.3s ease" })),
74
+ });
75
+ ```
76
+
77
+ ## Reactive classes
78
+
79
+ `Props.cx` (from `@weftui/dom`) builds a class string from static names and `{ className: condition }` records, where a condition may be a stream:
80
+
81
+ ```typescript
82
+ import { h } from "@weftui/core";
83
+ import { Props } from "@weftui/dom";
84
+
85
+ h.div({ class: Props.cx("demo-box", { "demo-box--active": isActiveStream }) });
50
86
  ```
51
87
 
88
+ See [Compose Behavior and Markup](https://weftui.dev/docs/how-to/compose-behavior-and-markup) for merging `class` across two prop bags.
89
+
52
90
  ## Notes
53
91
 
54
92
  - **Property names are camelCase** (`backgroundColor`, `boxShadow`), the same keys as the DOM `style` object.
55
- - **CSS transitions just work.** A stream emission patches the DOM node directly (no re-render), so the browser applies the `transition` as it would for any style mutation.
56
- - **Pace with `Schedule`.** The idiom for time-based style animation: `Stream.iterate`/`Stream.make` paced by `Stream.schedule(Schedule.spaced(…))` and looped with `Stream.forever`. Combine with any Effect timing you like.
57
- - **Classes have a reactive builder too.** `Props.cx` builds a class string from strings, falsy values, nested arrays, and `{ className: condition }` records, where a condition may be a stream. Merging two bags that both carry `class` concatenates them. See [Compose Behavior and Markup](https://weftui.dev/docs/how-to/compose-behavior-and-markup).
93
+ - **CSS transitions just work.** A stream emission patches the DOM node directly (no re-render), so the browser applies `transition` as it would for any style mutation.
94
+ - **Pace with `Schedule`.** `Stream.iterate`/`Stream.make`, paced by `Stream.schedule(Schedule.spaced(…))` and looped with `Stream.forever`, is the idiom for time-based style animation:
95
+
96
+ ```typescript
97
+ Stream.iterate(0, (n) => n + 1).pipe(
98
+ Stream.schedule(Schedule.spaced("100 millis")),
99
+ Stream.forever,
100
+ );
101
+ ```
102
+
103
+ Combine with any Effect timing you like.
104
+
105
+ ## Complete example
106
+
107
+ A page with one per-property demo (hue cycling) and one whole-object demo (theme switch), mounted into an empty `#root`. This is the whole file set, copy/paste runnable in a `vite` + `@weftui/core` project.
108
+
109
+ ```html
110
+ <!-- index.html -->
111
+ <!doctype html>
112
+ <html lang="en">
113
+ <head>
114
+ <meta charset="UTF-8" />
115
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
116
+ <title>Weft reactive styles demo</title>
117
+ </head>
118
+ <body>
119
+ <div id="root"></div>
120
+ <script type="module" src="/src/main.ts"></script>
121
+ </body>
122
+ </html>
123
+ ```
124
+
125
+ ```typescript
126
+ // src/app.ts
127
+ /**
128
+ * Reactive styles demo: a per-property hue cycle and a whole-object theme
129
+ * switch. Side-effect-free (no mount call), so `main.ts` and any test can
130
+ * import `App` directly.
131
+ */
132
+ import { h } from "@weftui/core";
133
+ import { Schedule, Stream } from "effect";
134
+
135
+ const AnimatedHue = () => {
136
+ const hue = Stream.iterate(0, (h) => (h + 2) % 360).pipe(
137
+ Stream.schedule(Schedule.spaced("50 millis")),
138
+ );
139
+
140
+ return h.div(
141
+ {
142
+ style: {
143
+ backgroundColor: Stream.map(hue, (h) => `hsl(${h}, 70%, 60%)`),
144
+ transition: "background-color 0.05s",
145
+ padding: "1rem",
146
+ },
147
+ },
148
+ "Hue",
149
+ );
150
+ };
151
+
152
+ const ThemeSwitch = () => {
153
+ const theme = Stream.make(
154
+ { backgroundColor: "#667eea", transform: "scale(1)" },
155
+ { backgroundColor: "#764ba2", transform: "scale(1.1)" },
156
+ ).pipe(Stream.schedule(Schedule.spaced("1 second")), Stream.forever);
157
+
158
+ return h.div(
159
+ {
160
+ style: Stream.map(theme, (s) => ({ ...s, transition: "all 0.3s ease", padding: "1rem" })),
161
+ },
162
+ "Theme",
163
+ );
164
+ };
165
+
166
+ export const App = () => h.div([AnimatedHue(), ThemeSwitch()]);
167
+ ```
168
+
169
+ ```typescript
170
+ // src/main.ts
171
+ /**
172
+ * Browser entry: mounts the reactive styles demo into `#root`.
173
+ */
174
+ import { WeftApp } from "@weftui/dom/client";
175
+ import { Effect } from "effect";
176
+ import { App } from "./app";
177
+
178
+ const root = document.getElementById("root");
179
+ if (root === null) {
180
+ throw new Error("#root not found");
181
+ }
182
+
183
+ const app = WeftApp.make();
184
+ void Effect.runPromise(WeftApp.mount(app, App(), root));
185
+ ```
58
186
 
59
187
  ## See also
60
188
 
@@ -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
@@ -316,11 +316,11 @@ Dispatch runs through the `def.httpApi` spine via `HttpApiBuilder`: platform own
316
316
 
317
317
  ### `RouterNotFound`
318
318
 
319
- `Schema.TaggedErrorClass` with an optional `path: string`. Raised by [`notFound`](#notfound) or when no route matches. Caught by the router's internal not-found boundary; export it to place your own `Boundary.catchTag("RouterNotFound", …)` (a nearer user boundary wins).
319
+ `Schema.TaggedError` with an optional `path: string`. Raised by [`notFound`](#notfound) or when no route matches. Caught by the router's internal not-found boundary; export it to place your own `Boundary.catchTag("RouterNotFound", …)` (a nearer user boundary wins).
320
320
 
321
321
  ### `RouterParamsError`
322
322
 
323
- `Schema.TaggedErrorClass` with `source: "path" | "query"` and `keys: readonly string[]`. Raised by `Router.params` / `Router.query` when the live match doesn't satisfy the requested fields. Bubbles into the tree's aggregate error channel.
323
+ `Schema.TaggedError` with `source: "path" | "query"` and `keys: readonly string[]`. Raised by `Router.params` / `Router.query` when the live match doesn't satisfy the requested fields. Bubbles into the tree's aggregate error channel.
324
324
 
325
325
  ### `notFound`
326
326
 
@@ -9,41 +9,71 @@ 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
15
- npm install @weftui/core @weftui/dom effect@beta
17
+ npm install @weftui/core @weftui/dom effect@rc
16
18
  ```
17
19
 
18
- 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
-
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.
20
+ Weft tracks Effect 4's prerelease line (beta, then rc). This release is built and tested against `effect@4.0.0-rc.112`; the peer range accepts newer 4.0 prereleases, which may contain upstream breaking changes.
21
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