@weftui/core 0.26.2 → 0.27.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.
@@ -18,21 +18,21 @@ A component's `E` channel accumulates up the tree. A **failure boundary** is whe
18
18
  ```typescript
19
19
  import { Boundary, h } from "@weftui/core";
20
20
 
21
- Boundary.catchAll({ fallback: (e) => h.div({ class: "error" }, `Failed: ${e.message}`) }, [
21
+ Boundary.catch({ fallback: (e) => h.div({ class: "error" }, `Failed: ${e.message}`) }, [
22
22
  RiskyWidget(),
23
23
  ]);
24
24
  ```
25
25
 
26
26
  There are six failure-catch variants, mirroring Effect's own error operators so the mental model transfers directly:
27
27
 
28
- | Variant | Catches |
29
- | ------------------------ | ------------------------------------------ |
30
- | `catchAll` | every failure in `E` |
31
- | `catchAllCause` | the full `Cause` (defects included) |
32
- | `catchTag` / `catchTags` | one / several tagged errors by `_tag` |
33
- | `catchSome` / `catchIf` | a selected subset, by `Option` / predicate |
28
+ | Variant | Catches |
29
+ | ------------------------- | ------------------------------------------ |
30
+ | `catch` | every failure in `E` |
31
+ | `catchCause` | the full `Cause` (defects included) |
32
+ | `catchTag` / `catchTags` | one / several tagged errors by `_tag` |
33
+ | `catchFilter` / `catchIf` | a selected subset, by `Filter` / predicate |
34
34
 
35
- The channel algebra is the whole reason they exist: `catchTag("Foo", …)` removes `Foo` from the children's `E` and adds whatever the fallback needs — so the type of the boundary node reflects exactly which failures are still live and which were handled. An unhandled failure re-raises to the **nearest enclosing** boundary; if none catches it at **mount time**, mounting fails. Boundaries nest, so an inner `catchTag` can handle a specific case while an outer `catchAll` sweeps the rest.
35
+ The channel algebra is the whole reason they exist: `catchTag("Foo", …)` removes `Foo` from the children's `E` and adds whatever the fallback needs — so the type of the boundary node reflects exactly which failures are still live and which were handled. An unhandled failure re-raises to the **nearest enclosing** boundary; if none catches it at **mount time**, mounting fails. Boundaries nest, so an inner `catchTag` can handle a specific case while an outer `catch` sweeps the rest.
36
36
 
37
37
  ### Post-mount failures with no enclosing boundary
38
38
 
@@ -52,11 +52,11 @@ import { SubscriptionRef, Stream } from "effect";
52
52
 
53
53
  const count = yield * SubscriptionRef.make(0);
54
54
 
55
- // count.changes is a Stream<number> — each new value updates the text node
56
- h.span([count.changes]);
55
+ // SubscriptionRef.changes(count) is a Stream<number> — each new value updates the text node
56
+ h.span([SubscriptionRef.changes(count)]);
57
57
 
58
58
  // Stream as a prop — each emission sets the attribute
59
- const isDisabled = Stream.map(count.changes, (n) => n >= 10);
59
+ const isDisabled = Stream.map(SubscriptionRef.changes(count), (n) => n >= 10);
60
60
  h.button({ disabled: isDisabled }, "Submit");
61
61
  ```
62
62
 
@@ -65,22 +65,22 @@ Streams can also supply entire child arrays. Each emission replaces the previous
65
65
  ```typescript
66
66
  const todos = yield * SubscriptionRef.make<string[]>([]);
67
67
 
68
- h.ul([Stream.map(todos.changes, (list) => list.map((item) => h.li(item)))]);
68
+ h.ul([Stream.map(SubscriptionRef.changes(todos), (list) => list.map((item) => h.li(item)))]);
69
69
  ```
70
70
 
71
71
  ## Derived streams
72
72
 
73
- Because `.changes` is a plain `Stream`, the full Stream API applies:
73
+ Because `SubscriptionRef.changes(ref)` returns a plain `Stream`, the full Stream API applies:
74
74
 
75
75
  ```typescript
76
76
  const count = yield * SubscriptionRef.make(0);
77
77
 
78
- const doubled = Stream.map(count.changes, (n) => n * 2);
79
- const formatted = Stream.map(count.changes, (n) => `Count: ${n}`);
80
- const isHigh = Stream.map(count.changes, (n) => n > 10);
78
+ const doubled = Stream.map(SubscriptionRef.changes(count), (n) => n * 2);
79
+ const formatted = Stream.map(SubscriptionRef.changes(count), (n) => `Count: ${n}`);
80
+ const isHigh = Stream.map(SubscriptionRef.changes(count), (n) => n > 10);
81
81
 
82
82
  h.div([
83
- h.p([count.changes]),
83
+ h.p([SubscriptionRef.changes(count)]),
84
84
  h.p([doubled]),
85
85
  h.p([formatted]),
86
86
  h.p({ style: { color: Stream.map(isHigh, (b) => (b ? "red" : "black")) } }, "Status"),
@@ -93,8 +93,10 @@ Multiple refs can be combined with `Stream.zipLatestWith`, `Stream.merge`, or ot
93
93
  const firstName = yield * SubscriptionRef.make("");
94
94
  const lastName = yield * SubscriptionRef.make("");
95
95
 
96
- const fullName = Stream.zipLatestWith(firstName.changes, lastName.changes, (first, last) =>
97
- `${first} ${last}`.trim(),
96
+ const fullName = Stream.zipLatestWith(
97
+ SubscriptionRef.changes(firstName),
98
+ SubscriptionRef.changes(lastName),
99
+ (first, last) => `${first} ${last}`.trim(),
98
100
  );
99
101
  ```
100
102
 
@@ -20,7 +20,7 @@ type Node<E = never, R = never> = Effect.Effect<ElementDescriptor, E, R>;
20
20
  Every element in a Weft tree **is an Effect**. `h.div(...)`, a component's return value, a boundary — each is an `Effect` that, when run, produces an element descriptor. Two consequences follow immediately, and they shape everything else:
21
21
 
22
22
  1. **The error (`E`) and requirement (`R`) channels accumulate through the tree.** A child that reads a service, or a prop backed by a failible stream, contributes its `R` and `E` to its parent, which contributes to _its_ parent, up to the mount boundary. The type of your app node is the exact union of everything it needs and everything it can fail with — visible to the type checker, satisfiable exactly once, at `mount`/`hydrate`. See [The Combinator API](https://weftui.dev/docs/explanation/combinator-api) for how the accumulation works mechanically.
23
- 2. **Every Effect combinator applies to a node directly.** `Effect.provide`, `Effect.flatMap`, `Effect.gen`, `Effect.catchAll` — none of them are special-cased for UI. A node is an ordinary Effect, so the entire Effect ecosystem composes with your view for free.
23
+ 2. **Every Effect combinator applies to a node directly.** `Effect.provide`, `Effect.flatMap`, `Effect.gen`, `Effect.catch` — none of them are special-cased for UI. A node is an ordinary Effect, so the entire Effect ecosystem composes with your view for free.
24
24
 
25
25
  JSX collapses every component to an opaque `JSX.Element`, erasing both channels. Weft keeps them, and that is the point of the whole design. (There is [no JSX](https://weftui.dev/docs/explanation/combinator-api) here — components are plain functions you _call_.)
26
26
 
@@ -39,7 +39,7 @@ The `ThemeServiceLive` example above works because `mount`'s effect and the serv
39
39
  Effect.runPromise(mount(App(), root).pipe(Effect.provide(SomeScopedLayer)));
40
40
  ```
41
41
 
42
- This is exactly what happened with effect-atom's `Registry.layer` in the [`effect-atom` example](https://github.com/stefvw93/weft/tree/main/examples/effect-atom) (issue #122): every atom-driven region rendered empty, with no error, because the registry the streams read from had already been disposed.
42
+ This is exactly what happened with the atom registry layer (`AtomRegistry.layer`, from `effect/unstable/reactivity`) in the [`effect-atom` example](https://github.com/stefvw93/weft/tree/main/examples/effect-atom) (issue #122): every atom-driven region rendered empty, with no error, because the registry the streams read from had already been disposed.
43
43
 
44
44
  The fix is to give the scoped layer a lifetime that matches the app, not the initial render: provide it **outside** a scoped region that stays open for as long as the app should run, and mount inside that region with `mountScoped` (which ties `unmount` to the region's scope instead of to the resolution of the mount effect). An `Effect.never` (or `Deferred.await` on a shutdown signal) keeps the region — and therefore the layer — alive until something explicitly closes it. See [Provide Services](https://weftui.dev/docs/how-to/provide-services) for the recipe, including the `ManagedRuntime` alternative when a scoped region isn't a good fit.
45
45
 
@@ -50,7 +50,7 @@ A plain `mount`/`hydrate` discharges `R` at the call site. But under `@weftui/ro
50
50
  So the router exposes an explicit **`context` seam** — a `Layer` threaded to the document shell and every route, layout, and leaf:
51
51
 
52
52
  ```typescript
53
- class Greeting extends Context.Tag("Greeting")<Greeting, { text: string }>() {}
53
+ class Greeting extends Context.Service<Greeting, { text: string }>()("Greeting") {}
54
54
 
55
55
  // server entry
56
56
  RouterServer.render(App, { document, url, context: Layer.succeed(Greeting, { text: "hi" }) });
@@ -63,7 +63,7 @@ The seam is **symmetric** (same shape on both sides) and **type-tracked**: the d
63
63
 
64
64
  ## Server-only services: `ServerTag`
65
65
 
66
- Some services must _never_ run in the browser — a database handle, a private credential, an rpc handler's backing store. Declare those with [`ServerTag`](https://weftui.dev/docs/reference/core#servertag) instead of `Context.Tag`. It behaves exactly like `Context.Tag`, but its identifier carries a **server-only brand**.
66
+ Some services must _never_ run in the browser — a database handle, a private credential, an rpc handler's backing store. Declare those with [`ServerTag`](https://weftui.dev/docs/reference/core#servertag) instead of `Context.Service`. It behaves exactly like `Context.Service`, but its identifier carries a **server-only brand**.
67
67
 
68
68
  The brand's job is to turn a leak into a **compile error at the `hydrate` call site**. A `Boundary.rpc` handler legitimately reads server-only services on the server, but they must not survive into client code: since `render` only ever touches the _decoded result_ (never the service), a correctly-written boundary keeps its output `R` free of the brand. If a branded tag ever leaks into `render` and reaches the client requirement channel, `hydrate`'s `AssertNoServerOnly` resolves `R` to a compile-error sentinel — you learn at build time, not from a runtime defect.
69
69
 
@@ -165,7 +165,7 @@ Router.route("users/:id", {
165
165
  path: idParam,
166
166
  component: Component.gen(function* () {
167
167
  const { id } = yield* Router.params(idParam);
168
- if (id < 0) return yield* notFound();
168
+ if (!Number.isFinite(id) || id < 0) return yield* notFound();
169
169
  return yield* h.div(`User ${id}`);
170
170
  }),
171
171
  });
@@ -173,6 +173,8 @@ Router.route("users/:id", {
173
173
 
174
174
  `RouterNotFound` is exported, so a `Boundary.catchTag("RouterNotFound", …)` placed inside a subtree overrides the app-level fallback for that subtree (the router's internal boundary is outermost, so a nearer user boundary wins).
175
175
 
176
+ > **`Schema.NumberFromString` gotcha.** Decoding no longer fails on a non-numeric segment — `/users/abc` decodes `id` to `NaN` instead of missing the route. A leaf that guards a numeric param must check `Number.isFinite(id)` itself (as above); relying on the schema alone to 404 non-numeric input no longer works.
177
+
176
178
  ## Client setup
177
179
 
178
180
  On the client, provide the `Router` via `RouterLive(def)` and render `RouterApp(def)`. `RouterLive` is a **scoped layer** — it owns the `popstate` listener and the same-origin link-click interceptor — so it must outlive the mount. Provide it through a long-lived `ManagedRuntime` rather than `Effect.provide` at the node level:
@@ -231,6 +233,16 @@ yield * patchQuery({ sort: "old" }); // merges into the current query
231
233
  - **`navigate(ref, args)`** builds the URL via [`href`](#type-safe-links-with-href) (so it round-trips with the matcher) and pushes — or, with `{ replace: true }`, replaces — the History entry. `args` follows the same requiredness rules as `href`.
232
234
  - **`setQuery` / `patchQuery`** keep the path, so the active leaf is never remounted — pair them with `Router.queryStream` for in-place reactive updates. They are a no-op when no route is matched.
233
235
 
236
+ ### Scroll position on navigation
237
+
238
+ A client navigation whose **path** changes resets the window scroll to the top at commit — matching what a full page load would do, which a raw History `pushState`/`replaceState` otherwise doesn't. This applies uniformly to `Router.navigate`, clicking a link the [interceptor](#link-interception) handles, and the `push` / `replace` helpers.
239
+
240
+ - **Query-only navigations preserve scroll.** `setQuery` / `patchQuery` (and any navigation that keeps the same path) don't reset — the leaf stays mounted, so there's nothing to scroll away from.
241
+ - **Back/forward is untouched.** The router never resets scroll on `popstate`; the browser's native `history.scrollRestoration: "auto"` restores the offset the entry had when the user left it.
242
+ - **Hash navigation (`#section`) is unaffected** — it's browser-native, and the link interceptor already lets same-document/hash-only clicks fall through.
243
+
244
+ There's no opt-out; the behavior is hardwired.
245
+
234
246
  ## Server setup
235
247
 
236
248
  On the server, `RouterServer` matches a request URL, builds a fixed-match `Router`, renders `RouterApp` to hydratable HTML inside a **document shell**, and reports a status (404 when no route matches or a page raises `RouterNotFound`).
@@ -266,9 +278,9 @@ export const handler = RouterServer.toWebHandler(App, { document: documentShell
266
278
 
267
279
  `render` provides both `Router.Outlet` (the app, per request) and `Router` (so the shell may read params), and renders through `renderToStringHydratable` so the client can `hydrate` in place.
268
280
 
269
- ### `@effect/platform` is the spine
281
+ ### `effect/unstable/httpapi` is the spine
270
282
 
271
- The tree is the authoring surface, but `@effect/platform`'s `HttpApi` is the **single source of truth** for paths and schemas. Sealing the tree with `Router.router(...)` builds it once (`buildHttpApi`) and stamps it onto `def.httpApi`: a single `"pages"` group with one GET endpoint per leaf at its full path pattern, carrying `setPath(pathSchema)`, `setUrlParams(querySchema)`, and a `RouterNotFound → 404` error. Both sides read that one definition, so they always agree:
283
+ The tree is the authoring surface, but `effect/unstable/httpapi`'s `HttpApi` is the **single source of truth** for paths and schemas. Sealing the tree with `Router.router(...)` builds it once (`buildHttpApi`) and stamps it onto `def.httpApi`: a single `"pages"` group with one GET endpoint per leaf at its full path pattern, carrying `setPath(pathSchema)`, `setUrlParams(querySchema)`, and a `RouterNotFound → 404` error. Both sides read that one definition, so they always agree:
272
284
 
273
285
  - **Server** — `RouterServer` dispatches through `HttpApiBuilder` (platform owns request→leaf matching, path/query decode, and the 404 status).
274
286
  - **Client** — `RouterLive` derives a real `HttpApiClient` from the same `def.httpApi` (exposed as `Router.httpApiClient`) for network work. SPA URL→leaf resolution stays **local** (there is no public client-side "match this URL against my `HttpApi`" utility in platform), fed from the same endpoint definitions so it never drifts from the server.
@@ -280,7 +292,7 @@ The tree is the authoring surface, but `@effect/platform`'s `HttpApi` is the **s
280
292
  | `RouterNotFound` | `notFound()`, or no route matched | `Boundary.catchTag("RouterNotFound", …)` (or the app-level `notFound` page) |
281
293
  | `RouterParamsError` | `Router.params` / `Router.query` on a missing/invalid key or no match | `Boundary.catchTag("RouterParamsError", …)` |
282
294
 
283
- Both are modeled as `Schema.TaggedError`, so they encode/decode across the wire the same way `Boundary.rpc` replays typed failures.
295
+ Both are modeled as `Schema.TaggedErrorClass`, so they encode/decode across the wire the same way `Boundary.rpc` replays typed failures.
284
296
 
285
297
  ## `Boundary.rpc` interplay
286
298
 
@@ -289,7 +301,7 @@ Initial SSR navigation works end to end: the server resolves the rpc and inlines
289
301
  ## See also
290
302
 
291
303
  - [`@weftui/router` API reference](https://weftui.dev/docs/reference/router)
292
- - [examples/router-ssr](https://github.com/stefvw93/weft/tree/main/examples/router-ssr) — a runnable SSR + hydration app with nested layouts, persistent layout state, type-safe `href`s, handler-arg props, and programmatic navigation over the `@effect/platform` spine
304
+ - [examples/router-ssr](https://github.com/stefvw93/weft/tree/main/examples/router-ssr) — a runnable SSR + hydration app with nested layouts, persistent layout state, type-safe `href`s, handler-arg props, and programmatic navigation over the `effect/unstable/httpapi` spine
293
305
  - [Component Authoring](https://weftui.dev/docs/how-to/author-components) — `Component.make` / `Component.gen`, the idiomatic way to write route components
294
306
  - [Server-Side Rendering](https://weftui.dev/docs/how-to/render-on-the-server) — `renderToStringHydratable`, `hydrate`, and `Boundary.rpc`
295
307
  - [RPC Data Boundaries](https://weftui.dev/docs/how-to/load-data-with-rpc) — `Boundary.rpc`, the `Resource` handle, and the four lifecycles
@@ -43,7 +43,7 @@ const Counter = () =>
43
43
  const count = yield* SubscriptionRef.make(0);
44
44
 
45
45
  return yield* h.div([
46
- h.span([count.changes]),
46
+ h.span([SubscriptionRef.changes(count)]),
47
47
  h.button({ onclick: () => SubscriptionRef.update(count, (n) => n + 1) }, "+"),
48
48
  ]);
49
49
  });
@@ -63,10 +63,10 @@ so it is already in context when you need it.
63
63
  This matters the moment a component starts **background work** — a subscription, an
64
64
  observer of a `ref`, a polling timer, anything you `fork`. The rule:
65
65
 
66
- > Fork background work with **`Effect.forkScoped`**, never a bare `Effect.fork`.
66
+ > Fork background work with **`Effect.forkScoped`**, never a bare `Effect.forkChild`.
67
67
 
68
68
  `Effect.forkScoped` attaches the fiber to the instance scope, so it keeps running for
69
- the component's lifetime and is interrupted on unmount. A bare `Effect.fork` instead
69
+ the component's lifetime and is interrupted on unmount. A bare `Effect.forkChild` instead
70
70
  attaches the fiber to the component-body fiber — the one that runs your `Effect.gen` to
71
71
  produce the tree. That fiber completes the instant the gen returns its node, so the
72
72
  forked work is cancelled almost immediately.
@@ -82,12 +82,12 @@ const AutoFocusInput = () =>
82
82
  const inputRef = yield* SubscriptionRef.make<Option.Option<HTMLInputElement>>(Option.none());
83
83
 
84
84
  yield* pipe(
85
- inputRef.changes,
85
+ SubscriptionRef.changes(inputRef),
86
86
  Stream.filter(Option.isSome),
87
87
  Stream.take(1),
88
88
  Stream.runForEach((el) => Effect.sync(() => el.value.focus())),
89
89
  Effect.forkScoped, // ✅ tied to the instance scope — survives until unmount
90
- // Effect.fork, // ❌ tied to the body fiber — interrupted when the gen returns
90
+ // Effect.forkChild, // ❌ tied to the body fiber — interrupted when the gen returns
91
91
  );
92
92
 
93
93
  return yield* h.input({ ref: inputRef, type: "text" });
@@ -9,15 +9,15 @@ description: Build a controlled form with SubscriptionRef field state, reactive
9
9
 
10
10
  **Goal:** a controlled form whose inputs drive `SubscriptionRef` state, whose errors update reactively as the user types, and whose submit runs an Effect.
11
11
 
12
- Each field is a `SubscriptionRef`. Bind it with `oninput`, derive validation from its `.changes` stream, and return an `Effect` from `onsubmit` (after `preventDefault`).
12
+ Each field is a `SubscriptionRef`. Bind it with `oninput`, derive validation from its `SubscriptionRef.changes(ref)` stream, and return an `Effect` from `onsubmit` (after `preventDefault`).
13
13
 
14
14
  ```typescript
15
15
  import { h } from "@weftui/core";
16
- import { Effect, Either, Schema, Stream, SubscriptionRef } from "effect";
16
+ import { Effect, Result, Schema, Stream, SubscriptionRef } from "effect";
17
17
 
18
18
  const Email = Schema.String.pipe(
19
- Schema.filter((s) => s.includes("@"), { message: () => "Must contain @" }),
20
- Schema.filter((s) => s.includes("."), { message: () => "Must contain a domain" }),
19
+ Schema.check(Schema.makeFilter((s) => (s.includes("@") ? undefined : "Must contain @"))),
20
+ Schema.check(Schema.makeFilter((s) => (s.includes(".") ? undefined : "Must contain a domain"))),
21
21
  );
22
22
 
23
23
  const LoginForm = () =>
@@ -26,11 +26,11 @@ const LoginForm = () =>
26
26
  const status = yield* SubscriptionRef.make<string | null>(null);
27
27
 
28
28
  // Validation is a stream derived from the field — it re-runs as the user types.
29
- const error = Stream.map(email.changes, (value) => {
29
+ const error = Stream.map(SubscriptionRef.changes(email), (value) => {
30
30
  if (value.length === 0) return null; // don't nag an empty field
31
- return Either.match(Schema.decodeUnknownEither(Email)(value), {
32
- onLeft: (e) => e.message.split(":").pop()?.trim() ?? "Invalid",
33
- onRight: () => null,
31
+ return Result.match(Schema.decodeUnknownResult(Email)(value), {
32
+ onFailure: (e) => e.message.split(":").pop()?.trim() ?? "Invalid",
33
+ onSuccess: () => null,
34
34
  });
35
35
  });
36
36
 
@@ -52,7 +52,7 @@ const LoginForm = () =>
52
52
  }),
53
53
  Stream.map(error, (err) => (err ? h.span({ class: "error-text" }, err) : null)),
54
54
  h.button({ type: "submit" }, "Login"),
55
- h.div([Stream.map(status.changes, (s) => (s ? h.span(s) : null))]),
55
+ h.div([Stream.map(SubscriptionRef.changes(status), (s) => (s ? h.span(s) : null))]),
56
56
  ],
57
57
  );
58
58
  });
@@ -61,7 +61,7 @@ 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(email.changes, …)` produces an error string (or `null`) on every keystroke. Use [`Schema`](https://effect.website/docs/schema/introduction) to decode — `Schema.decodeUnknownEither(schema)(value)` returns an `Either`, and `Either.match` turns it into UI. A node or `null` in a child slot renders the error or nothing.
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
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
@@ -2,14 +2,14 @@
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.catchAll — all client-side.
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
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
- 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.catchAll`, which maps the error to a fallback node.
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
 
14
14
  ```typescript
15
15
  import { h } from "@weftui/core";
@@ -34,7 +34,7 @@ const UserCard = ({ id }: { id: number }) =>
34
34
  Stream.fromEffect(
35
35
  fetchUser(id).pipe(
36
36
  Effect.flatMap((user) => h.div({ class: "user-card" }, [h.h3(user.name), h.p(user.email)])),
37
- Effect.catchAll((error) => h.div({ class: "error" }, `Error: ${error.message}`)),
37
+ Effect.catch((error) => h.div({ class: "error" }, `Error: ${error.message}`)),
38
38
  ),
39
39
  ),
40
40
  );
@@ -44,7 +44,7 @@ const UserCard = ({ id }: { id: number }) =>
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
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
- - **`Effect.catchAll((error) => node)`** converts the error channel into a fallback node, so the stream always yields something renderable. The failure never escapes to the mount.
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
48
  - **Parallel loading is automatic:** place several async components as siblings and their fetches run concurrently — no orchestration needed.
49
49
 
50
50
  ## When to reach for a boundary instead
@@ -39,7 +39,7 @@ The rpc **contract** (pure Schema) is shared with the client. The rpc **handler*
39
39
 
40
40
  ```typescript
41
41
  // data/inventory.ts
42
- import { Rpc, RpcGroup } from "@effect/rpc";
42
+ import { Rpc, RpcGroup } from "effect/unstable/rpc";
43
43
  import { Context, Effect, Layer, Schema } from "effect";
44
44
 
45
45
  // --- Contract (shareable with the client) ---
@@ -53,10 +53,10 @@ export const GetStock = Rpc.make("GetStock", { payload: StockKey, success: Stock
53
53
  export const StockRpcs = RpcGroup.make(GetStock);
54
54
 
55
55
  // --- Handler (server-only; the client never imports this) ---
56
- class Inventory extends Context.Tag("Inventory")<
56
+ class Inventory extends Context.Service<
57
57
  Inventory,
58
58
  { readonly stockFor: (id: number) => Effect.Effect<typeof Stock.Type> }
59
- >() {}
59
+ >()("Inventory") {}
60
60
 
61
61
  const InventoryLive = Layer.succeed(Inventory, {
62
62
  stockFor: (id) => Effect.succeed({ units: 7 + (id % 5) }),
@@ -68,7 +68,7 @@ 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.Tag`) 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.
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
 
@@ -157,7 +157,7 @@ Boundary.catchTag({ tag: "OutOfStock", fallback: (e) => h.p({ class: "error" },
157
157
  ]);
158
158
  ```
159
159
 
160
- A transport **defect** (no `Cause.failureOption`), or an rpc with no `error` schema, is **not** replayed; it propagates — a server-side fallback and a client mismatch.
160
+ 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
161
 
162
162
  ## When to use
163
163
 
@@ -121,4 +121,4 @@ In both cases the tell is the same: nothing in the composition keeps a scope ope
121
121
  - [Layer lifetime at the mount](https://weftui.dev/docs/explanation/services-and-context#layer-lifetime-at-the-mount) — why the mount effect resolving early matters for scoped layers
122
122
  - [Services and Context](https://weftui.dev/docs/explanation/services-and-context) — how `R` accumulates and discharges at the mount
123
123
  - [`mountScoped` / `hydrateScoped` reference](https://weftui.dev/docs/reference/dom#mountscoped) — signatures and error unions
124
- - [examples/effect-atom](https://github.com/stefvw93/weft/tree/main/examples/effect-atom) — a real scoped layer (`Registry.layer`) mounted with this composition
124
+ - [examples/effect-atom](https://github.com/stefvw93/weft/tree/main/examples/effect-atom) — a real scoped layer (`AtomRegistry.layer` from `effect/unstable/reactivity`) mounted with this composition
@@ -9,7 +9,7 @@ description: Capture a DOM element with the ref prop into a SubscriptionRef<Opti
9
9
 
10
10
  **Goal:** get a handle to a real DOM element — to focus it, measure it, or call an imperative browser API on it.
11
11
 
12
- Declare a `SubscriptionRef<Option<HTMLElement>>`, attach it with the `ref` prop, and either **react** to the element appearing (a scoped observer on `.changes`) or **read** it later inside a handler.
12
+ Declare a `SubscriptionRef<Option<HTMLElement>>`, attach it with the `ref` prop, and either **react** to the element appearing (a scoped observer on `SubscriptionRef.changes(ref)`) or **read** it later inside a handler.
13
13
 
14
14
  ```typescript
15
15
  import { h } from "@weftui/core";
@@ -21,7 +21,7 @@ const AutoFocusInput = () =>
21
21
 
22
22
  // Observe the element becoming available, once, and focus it.
23
23
  yield* pipe(
24
- inputRef.changes,
24
+ SubscriptionRef.changes(inputRef),
25
25
  Stream.filter(Option.isSome),
26
26
  Stream.take(1),
27
27
  Stream.runForEach((el) => Effect.sync(() => el.value.focus())),
@@ -35,8 +35,8 @@ const AutoFocusInput = () =>
35
35
  ## How it works
36
36
 
37
37
  - **The `ref` prop** takes a `SubscriptionRef<Option<T>>`. The renderer sets it to `Option.some(element)` **once**, when the element is created — so the ref is an `Option`: `None` until mount, `Some(el)` after.
38
- - **React to mount** by observing `ref.changes`: `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.fork`.** `forkScoped` ties the observer fiber to the component's **instance scope** (the ambient `Scope` the renderer provides), so it lives as long as the component is mounted. A bare `Effect.fork` binds to the transient component-body fiber and is interrupted the instant the generator returns — the observer would never fire.
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), so 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 — the observer would never fire.
40
40
 
41
41
  ## Read a ref imperatively
42
42
 
@@ -58,6 +58,6 @@ const scroll = () =>
58
58
 
59
59
  ## See also
60
60
 
61
- - [Reactive Primitives](https://weftui.dev/docs/explanation/reactive-primitives) — `SubscriptionRef` and `.changes`
61
+ - [Reactive Primitives](https://weftui.dev/docs/explanation/reactive-primitives) — `SubscriptionRef` and `SubscriptionRef.changes`
62
62
  - [Author Components](https://weftui.dev/docs/how-to/author-components) — instance scope and `Effect.forkScoped`
63
63
  - [examples/element-ref](https://github.com/stefvw93/weft/tree/main/examples/element-ref) — auto-focus, element measurement, and imperative scroll via refs
@@ -144,7 +144,7 @@ Boundary.suspend(
144
144
 
145
145
  #### `Boundary.rpc`
146
146
 
147
- A universal server/client render boundary backed by one `Rpc` from the app's merged `RpcGroup` ([`@effect/rpc`](https://github.com/Effect-TS/effect/tree/main/packages/rpc)). The rpc **`_tag`** is the boundary's stable identity and its **payload schema** the typed input; the handler lives in the server-only rpc Layer (`group.toLayer(...)`), which the client never imports — tree-shaking does the client/server split structurally. Unlike the catch variants it takes a `render` function — not a children array — and that `render` receives a reactive [`Resource`](#resourcea), not a bare value.
147
+ A universal server/client render boundary backed by one `Rpc` from the app's merged `RpcGroup` ([`effect/unstable/rpc`](https://github.com/Effect-TS/effect)). The rpc **`_tag`** is the boundary's stable identity and its **payload schema** the typed input; the handler lives in the server-only rpc Layer (`group.toLayer(...)`), which the client never imports — tree-shaking does the client/server split structurally. Unlike the catch variants it takes a `render` function — not a children array — and that `render` receives a reactive [`Resource`](#resourcea), not a bare value.
148
148
 
149
149
  ```typescript
150
150
  Boundary.rpc<R extends Rpc.Any, C extends Node<any, any>>(
@@ -201,22 +201,21 @@ The descriptor `type` every `Boundary.rpc` carries (`{ type: SERVER_BOUNDARY, pr
201
201
  interface AppRpcClient {
202
202
  readonly call: (tag: string, payload: unknown) => Effect.Effect<unknown, unknown>;
203
203
  }
204
- class AppRpcClientTag extends Context.Tag("@weftui/core/AppRpcClient")<
205
- AppRpcClientTag,
206
- AppRpcClient
207
- >() {}
204
+ class AppRpcClientTag extends Context.Service<AppRpcClientTag, AppRpcClient>()(
205
+ "@weftui/core/AppRpcClient",
206
+ ) {}
208
207
  ```
209
208
 
210
- The ambient, package-neutral seam the renderer resolves a `Boundary.rpc` through — a **flat, untyped** caller `(tag, payload) => Effect<success>`. It lets `@weftui/dom` resolve a boundary without importing `@effect/rpc` or `@weftui/router`. `@weftui/router` provides it: a **network** `RpcClient` (POST `/_eui/rpc`) in the browser, an **in-process** client over the handler Layer on the server. `call` returns the already-decoded success; the renderer owns `successSchema`/`errorSchema` decoding of the inline SSR payload only. Both `AppRpcClientTag` and the `AppRpcClient` type are re-exported from `@weftui/core`. Absent in a router-less mount, where a `Boundary.rpc` resolves to a descriptive "needs router/rpc" error (not a defect).
209
+ The ambient, package-neutral seam the renderer resolves a `Boundary.rpc` through — a **flat, untyped** caller `(tag, payload) => Effect<success>`. It lets `@weftui/dom` resolve a boundary without importing `effect/unstable/rpc` or `@weftui/router`. `@weftui/router` provides it: a **network** `RpcClient` (POST `/_eui/rpc`) in the browser, an **in-process** client over the handler Layer on the server. `call` returns the already-decoded success; the renderer owns `successSchema`/`errorSchema` decoding of the inline SSR payload only. Both `AppRpcClientTag` and the `AppRpcClient` type are re-exported from `@weftui/core`. Absent in a router-less mount, where a `Boundary.rpc` resolves to a descriptive "needs router/rpc" error (not a defect).
211
210
 
212
211
  See the [rpc data boundaries guide](https://weftui.dev/docs/how-to/load-data-with-rpc) and [examples/router-ssr](https://github.com/stefvw93/weft/tree/main/examples/router-ssr).
213
212
 
214
- #### `Boundary.catchAll`
213
+ #### `Boundary.catch`
215
214
 
216
- Catches all typed failures (`Cause.fail`). Defects (`Cause.die`) are not caught and re-raise.
215
+ Catches all typed failures (`Cause.fail`). Defects (`Cause.die`) are not caught and re-raise. Mirrors Effect 4's `Effect.catch` (renamed from `catchAll` in v3).
217
216
 
218
217
  ```typescript
219
- Boundary.catchAll<C, FE, FR>(
218
+ Boundary.catch<C, FE, FR>(
220
219
  props: { fallback: (e: ChildrenE<C>) => Node<FE, FR> },
221
220
  children: C,
222
221
  ): Node<FE, ChildrenR<C> | FR>
@@ -224,12 +223,12 @@ Boundary.catchAll<C, FE, FR>(
224
223
 
225
224
  The children's `E` is fully consumed. The output `E` is only the fallback's own error channel.
226
225
 
227
- #### `Boundary.catchAllCause`
226
+ #### `Boundary.catchCause`
228
227
 
229
- Catches every `Cause` including defects and interruptions.
228
+ Catches every `Cause` including defects and interruptions. Mirrors Effect 4's `Effect.catchCause` (renamed from `catchAllCause` in v3).
230
229
 
231
230
  ```typescript
232
- Boundary.catchAllCause<C, FE, FR>(
231
+ Boundary.catchCause<C, FE, FR>(
233
232
  props: { fallback: (cause: Cause.Cause<ChildrenE<C>>) => Node<FE, FR> },
234
233
  children: C,
235
234
  ): Node<FE, ChildrenR<C> | FR>
@@ -266,18 +265,30 @@ Boundary.catchTags<C, Handlers>(
266
265
  ): Node<UnhandledE | HandlersE, ChildrenR<C> | HandlersR>
267
266
  ```
268
267
 
269
- #### `Boundary.catchSome`
268
+ #### `Boundary.catchFilter`
270
269
 
271
- The fallback returns `Option<Node>`. `Option.none()` re-raises the error; `Option.some(node)` catches it.
270
+ Conditionally catches using a `Filter`, run on each typed failure: a `Result.succeed` (pass) recovers via `fallback`, receiving the possibly-narrowed pass value; a `Result.fail` re-raises the error (its `Fail` channel `X` is preserved in the output `E`, since the boundary may not handle any given error). Takes the `Filter` and `fallback` as **positional** arguments — no wrapping props object. Mirrors Effect 4's `Effect.catchFilter` (renamed from `catchSome`, which took an `Option`-returning function in v3).
272
271
 
273
272
  ```typescript
274
- Boundary.catchSome<C, FE, FR>(
275
- props: { fallback: (e: ChildrenE<C>) => Option.Option<Node<FE, FR>> },
273
+ Boundary.catchFilter<C, EB, X, FE, FR>(
274
+ filter: Filter.Filter<ChildrenE<C>, EB, X>,
275
+ fallback: (matched: EB) => Node<FE, FR>,
276
276
  children: C,
277
- ): Node<ChildrenE<C> | FE, ChildrenR<C> | FR>
277
+ ): Node<X | FE, ChildrenR<C> | FR>
278
+ ```
279
+
280
+ ```typescript
281
+ import { Boundary } from "@weftui/core";
282
+ import { Filter, Result } from "effect";
283
+
284
+ Boundary.catchFilter(
285
+ Filter.make((e: AppError) => (e._tag === "Net" ? Result.succeed(e) : Result.fail(e))),
286
+ (matched) => h.div({}, matched.message),
287
+ [Widget()],
288
+ );
278
289
  ```
279
290
 
280
- The children's `E` is preserved in the output because the boundary may or may not handle any given error.
291
+ The children's `E` is narrowed to the filter's `Fail` channel `X` in the output, because the boundary may or may not handle any given error.
281
292
 
282
293
  #### `Boundary.catchIf`
283
294
 
@@ -301,7 +312,7 @@ Inner boundaries shadow outer ones for their subtree — the innermost boundary
301
312
 
302
313
  ```typescript
303
314
  // Inner catches FooError; BarError propagates to outer
304
- Boundary.catchAll({ fallback: (e) => h.div(`Outer: ${e.message}`) }, [
315
+ Boundary.catch({ fallback: (e) => h.div(`Outer: ${e.message}`) }, [
305
316
  Boundary.catchTag({ tag: "Foo", fallback: (e) => h.span(`Foo: ${e.msg}`) }, [
306
317
  ChildWithFooOrBarError(),
307
318
  ]),
@@ -312,7 +323,7 @@ Boundary.catchAll({ fallback: (e) => h.div(`Outer: ${e.message}`) }, [
312
323
 
313
324
  ## ServerTag
314
325
 
315
- A `Context.Tag` whose identifier is branded server-only. Use it exactly like `Context.Tag` for services that must only ever be provided on the server — e.g. a database handle read inside an rpc handler Layer. The brand also guards [`Boundary.rpc`](#boundaryrpc): a server-only tag accidentally referenced in `render` stays in the requirement channel, where `hydrate`'s `AssertNoServerOnly` rejects it at compile time.
326
+ A `Context.Service` key whose identifier is branded server-only. Use it exactly like `Context.Service` for services that must only ever be provided on the server — e.g. a database handle read inside an rpc handler Layer. The brand also guards [`Boundary.rpc`](#boundaryrpc): a server-only tag accidentally referenced in `render` stays in the requirement channel, where `hydrate`'s `AssertNoServerOnly` rejects it at compile time.
316
327
 
317
328
  ```typescript
318
329
  import { ServerTag } from "@weftui/core";
@@ -19,7 +19,7 @@ Three entry points mirror `@weftui/dom`:
19
19
 
20
20
  ## `Router`
21
21
 
22
- `Router` is both an Effect `Context.Tag` and the authoring namespace; the two roles merge by declaration. `yield* Router` reads the per-render service; `Router.route(…)` authors a tree.
22
+ `Router` is both an Effect `Context.Service` key and the authoring namespace; the two roles merge by declaration. `yield* Router` reads the per-render service; `Router.route(…)` authors a tree.
23
23
 
24
24
  The service value carries:
25
25
 
@@ -96,7 +96,7 @@ See the [Split Routes Lazily](https://weftui.dev/docs/how-to/split-routes-lazily
96
96
 
97
97
  ### `Router.Outlet`
98
98
 
99
- A `Context.Tag` whose value is the node to splice for the next level down. A layout (or the server document shell) reads it with `const outlet = yield* Router.Outlet`. Typed **opaque** as `Node<never, never>`, and discharged by the router at render time, so it never appears in a reader's aggregate requirement channel.
99
+ A `Context.Service` key whose value is the node to splice for the next level down. A layout (or the server document shell) reads it with `const outlet = yield* Router.Outlet`. Typed **opaque** as `Node<never, never>`, and discharged by the router at render time, so it never appears in a reader's aggregate requirement channel.
100
100
 
101
101
  ### `Router.params` / `Router.query`
102
102
 
@@ -224,7 +224,7 @@ RouterLive(
224
224
  ): Layer.Layer<Router | AppRpcClientTag>;
225
225
  ```
226
226
 
227
- The client `Router` layer, backed by the History API. Seeds a `SubscriptionRef` from `window.location`, listens for `popstate`, installs the same-origin link-click interceptor, and derives the `HttpApiClient` exposed as `Router.httpApiClient` (over `FetchHttpClient`; `baseUrl` defaults to same-origin). Alongside `Router` it also provides the core [`AppRpcClientTag`](https://weftui.dev/docs/reference/core#apprpcclienttag) seam — a **network** flat rpc client over the app's merged `RpcGroup` (`RpcClient.make` → `POST /_eui/rpc`) — so `@weftui/dom` can resolve a [`Boundary.rpc`](https://weftui.dev/docs/reference/core#boundaryrpc) (hydrated refetch and client-first SPA mount) without depending on this package or `@effect/rpc`. Pass the same merged `group` the server wires into [`RouterServer`](#routerserver). **Scoped** — it must outlive the mount, so provide it through a `ManagedRuntime`:
227
+ The client `Router` layer, backed by the History API. Seeds a `SubscriptionRef` from `window.location`, listens for `popstate`, installs the same-origin link-click interceptor, and derives the `HttpApiClient` exposed as `Router.httpApiClient` (over `FetchHttpClient`; `baseUrl` defaults to same-origin). Alongside `Router` it also provides the core [`AppRpcClientTag`](https://weftui.dev/docs/reference/core#apprpcclienttag) seam — a **network** flat rpc client over the app's merged `RpcGroup` (`RpcClient.make` → `POST /_eui/rpc`) — so `@weftui/dom` can resolve a [`Boundary.rpc`](https://weftui.dev/docs/reference/core#boundaryrpc) (hydrated refetch and client-first SPA mount) without depending on this package or `effect/unstable/rpc`. Pass the same merged `group` the server wires into [`RouterServer`](#routerserver). **Scoped** — it must outlive the mount, so provide it through a `ManagedRuntime`:
228
228
 
229
229
  ```typescript
230
230
  const runtime = ManagedRuntime.make(RouterLive(App, { rpc: { group: StockRpcs } }));
@@ -258,6 +258,10 @@ yield * push("/users/1/posts?sort=new");
258
258
  yield * patchQuery({ sort: "old" }); // keeps the current path + other query fields
259
259
  ```
260
260
 
261
+ ### Scroll reset
262
+
263
+ Every client-committed navigation whose path differs from the previously committed path — `navigate`, `push`, `replace`, and the link interceptor — resets `window.scrollTo(0, 0)` synchronously at commit. Query-only navigations (`setQuery` / `patchQuery`, or any push/replace to the same path) preserve scroll. `popstate` (`back` / `forward`) never resets; the browser's own `history.scrollRestoration: "auto"` restores the prior offset for those entries. Server rendering is unaffected. Not configurable — there is no `NavigateOptions` field to opt out.
264
+
261
265
  ### `installLinkInterceptor`
262
266
 
263
267
  ```typescript
@@ -290,11 +294,11 @@ Dispatch runs through the `def.httpApi` spine via `HttpApiBuilder`: platform own
290
294
 
291
295
  ### `RouterNotFound`
292
296
 
293
- `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).
297
+ `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).
294
298
 
295
299
  ### `RouterParamsError`
296
300
 
297
- `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.
301
+ `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.
298
302
 
299
303
  ### `notFound`
300
304