@weftui/router 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.
package/README.md CHANGED
@@ -28,6 +28,8 @@ npm install @weftui/core @weftui/dom @weftui/router effect
28
28
  | `RouterLive` (client) | History-backed `Router` layer; also provides the `AppRpcClientTag` seam. |
29
29
  | `RouterServer` (server) | `RouterServer.render` / `RouterServer.toWebHandler` for SSR dispatch. |
30
30
 
31
+ Client-side navigation to a different path resets the window scroll to the top at commit; query-only navigations and browser back/forward preserve it (the latter via the browser's native `history.scrollRestoration`).
32
+
31
33
  ## Example
32
34
 
33
35
  ```typescript
@@ -1,8 +1,8 @@
1
- import { S as Router, b as NavState, i as RouterDef, m as RouteNode, u as Fields, x as NavigateOptions } from "../compile-CQhAXB5f.js";
2
- import { n as outletNode, r as HrefArgs, t as RouterApp } from "../outlet-CGfLQgZS.js";
1
+ import { S as Router, b as NavState, i as RouterDef, m as RouteNode, u as Fields, x as NavigateOptions } from "../compile-DWdMZiMt.js";
2
+ import { n as outletNode, r as HrefArgs, t as RouterApp } from "../outlet-BiFb5gdK.js";
3
3
  import { Effect, Layer, Scope } from "effect";
4
4
  import { AppRpcClientTag } from "@weftui/core";
5
- import { RpcGroup } from "@effect/rpc";
5
+ import { RpcGroup } from "effect/unstable/rpc";
6
6
 
7
7
  //#region src/client/router-live.d.ts
8
8
  /**
@@ -63,7 +63,7 @@ interface RouterLiveOptions {
63
63
  * **network** flat rpc client (`RpcClient.make` over `layerProtocolHttp` →
64
64
  * `POST /_eui/rpc`) — so `@weftui/dom` can resolve a `Boundary.rpc` (hydrated
65
65
  * refetch and client-first mount) without depending on this package or
66
- * `@effect/rpc`.
66
+ * `effect/unstable/rpc`.
67
67
  */
68
68
  declare function RouterLive<R>(def: RouterDef<any, R>, options?: RouterLiveOptions & ContextOption<R>): Layer.Layer<Router | AppRpcClientTag | AppServices<R>>;
69
69
  //#endregion
@@ -1,8 +1,9 @@
1
- import { a as Router, i as setResolvedCommit, n as outletNode, o as getPreload, r as preRunLeaf, t as RouterApp } from "../outlet-PoLEThsy.js";
2
- import { r as match, t as href } from "../href-Deoi59OI.js";
3
- import { FetchHttpClient, HttpApiClient } from "@effect/platform";
4
- import { Context, Effect, Exit, Fiber, Layer, Option, Runtime, Schema, Scope, Stream, Subscribable, SubscriptionRef } from "effect";
5
- import { AppRpcClientTag } from "@weftui/core";
1
+ import { a as Router, i as setResolvedCommit, n as outletNode, o as getPreload, r as preRunLeaf, t as RouterApp } from "../outlet-ty5ZYKBW.js";
2
+ import { r as match, t as href } from "../href-DD8PoCGO.js";
3
+ import { HttpApiClient } from "effect/unstable/httpapi";
4
+ import { Context, Effect, Exit, Fiber, Layer, Option, Schema, Scope, Stream, SubscriptionRef } from "effect";
5
+ import { AppRpcClientTag, Subscribable } from "@weftui/core";
6
+ import { FetchHttpClient } from "effect/unstable/http";
6
7
  //#region src/client/link.ts
7
8
  /**
8
9
  * Installs a global, delegated click interceptor (in the `Router` layer scope)
@@ -18,7 +19,7 @@ import { AppRpcClientTag } from "@weftui/core";
18
19
  */
19
20
  function installLinkInterceptor(def, navigate) {
20
21
  return Effect.gen(function* () {
21
- const runtime = yield* Effect.runtime();
22
+ const services = yield* Effect.context();
22
23
  const onClick = (event) => {
23
24
  if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
24
25
  const target = event.target;
@@ -39,7 +40,7 @@ function installLinkInterceptor(def, navigate) {
39
40
  if (to === `${window.location.pathname}${window.location.search}`) return;
40
41
  if (match(def, to)._tag !== "Matched") return;
41
42
  event.preventDefault();
42
- Runtime.runFork(runtime)(navigate(to));
43
+ Effect.runForkWith(services)(navigate(to));
43
44
  };
44
45
  yield* Effect.acquireRelease(Effect.sync(() => document.addEventListener("click", onClick)), () => Effect.sync(() => document.removeEventListener("click", onClick)));
45
46
  });
@@ -73,19 +74,23 @@ function normalizeTo(to) {
73
74
  * **network** flat rpc client (`RpcClient.make` over `layerProtocolHttp` →
74
75
  * `POST /_eui/rpc`) — so `@weftui/dom` can resolve a `Boundary.rpc` (hydrated
75
76
  * refetch and client-first mount) without depending on this package or
76
- * `@effect/rpc`.
77
+ * `effect/unstable/rpc`.
77
78
  */
78
79
  function RouterLive(def, options = {}) {
79
- const core = Layer.scopedContext(Effect.gen(function* () {
80
+ const core = Layer.effectContext(Effect.gen(function* () {
80
81
  const urlRef = yield* SubscriptionRef.make(locationUrl());
81
82
  const navRef = yield* SubscriptionRef.make({ _tag: "Idle" });
82
- const runtime = yield* Effect.runtime();
83
+ const services = yield* Effect.context();
83
84
  const httpApiClient = yield* HttpApiClient.make(def.httpApi, { baseUrl: options.baseUrl ?? window.location.origin }).pipe(Effect.provide(FetchHttpClient.layer));
84
85
  let latest = 0;
85
86
  let inflightPreRun;
86
87
  let committedScope;
87
88
  let router;
88
89
  const collectPreloads = (m) => m._tag !== "Matched" ? [] : [m.leaf.component, ...m.leaf.layoutChain.map((l) => l.component)].map(getPreload).filter((p) => p !== void 0);
90
+ const pathOf = (url) => {
91
+ const qIndex = url.indexOf("?");
92
+ return qIndex === -1 ? url : url.slice(0, qIndex);
93
+ };
89
94
  const commitUrl = (normalized, replace) => Effect.sync(() => {
90
95
  if (replace) window.history.replaceState(null, "", normalized);
91
96
  else window.history.pushState(null, "", normalized);
@@ -110,11 +115,10 @@ function RouterLive(def, options = {}) {
110
115
  let exit;
111
116
  if (target._tag === "Matched") {
112
117
  const scope = yield* Scope.make();
113
- const fiber = yield* Effect.fork(Effect.provideService(preRunLeaf(router, target), Scope.Scope, scope));
118
+ const fiber = yield* Effect.forkChild(Effect.provideService(preRunLeaf(router, target), Scope.Scope, scope));
114
119
  inflightPreRun = fiber;
115
- if (!emitted) yield* Effect.fork(Effect.gen(function* () {
116
- const done = yield* Fiber.poll(fiber);
117
- if (Option.isNone(done) && token === latest) {
120
+ if (!emitted) yield* Effect.forkChild(Effect.gen(function* () {
121
+ if (fiber.pollUnsafe() === void 0 && token === latest) {
118
122
  emitted = true;
119
123
  yield* SubscriptionRef.set(navRef, {
120
124
  _tag: "Navigating",
@@ -137,34 +141,39 @@ function RouterLive(def, options = {}) {
137
141
  url: normalized,
138
142
  exit
139
143
  });
144
+ const previousUrl = yield* SubscriptionRef.get(urlRef);
140
145
  if (pushUrl) yield* commitUrl(normalized, replace);
141
146
  yield* SubscriptionRef.set(urlRef, normalized);
147
+ if (pushUrl && pathOf(previousUrl) !== pathOf(normalized)) yield* Effect.sync(() => window.scrollTo(0, 0));
142
148
  if (emitted) yield* SubscriptionRef.set(navRef, { _tag: "Idle" });
143
149
  });
144
150
  const navigate = (to, options) => commitTo(normalizeTo(to), true, options?.replace === true);
145
151
  const onPopState = () => {
146
- Runtime.runFork(runtime)(commitTo(locationUrl(), false, false));
152
+ Effect.runForkWith(services)(commitTo(locationUrl(), false, false));
147
153
  };
148
154
  yield* Effect.acquireRelease(Effect.sync(() => window.addEventListener("popstate", onPopState)), () => Effect.sync(() => window.removeEventListener("popstate", onPopState)));
149
155
  yield* installLinkInterceptor(def, navigate);
150
156
  const currentMatch = Subscribable.make({
151
157
  get: Effect.map(SubscriptionRef.get(urlRef), (url) => match(def, url)),
152
- changes: Stream.map(urlRef.changes, (url) => match(def, url))
158
+ changes: Stream.map(SubscriptionRef.changes(urlRef), (url) => match(def, url))
153
159
  });
154
160
  const rpc = options.rpc;
155
161
  let appRpcClient;
156
162
  if (rpc === void 0) appRpcClient = AppRpcClientTag.of({ call: (tag) => Effect.fail(/* @__PURE__ */ new Error(`Boundary.rpc "${tag}" cannot resolve: no \`rpc\` option was passed to RouterLive`)) });
157
163
  else {
158
- const { RpcClient, RpcSerialization } = yield* Effect.promise(() => import("@effect/rpc"));
164
+ const { RpcClient, RpcSerialization } = yield* Effect.promise(() => import("effect/unstable/rpc"));
159
165
  const baseUrl = String(options.baseUrl ?? window.location.origin).replace(/\/$/, "");
160
- const flatClient = yield* RpcClient.make(rpc.group, { flatten: true }).pipe(Effect.provide(RpcClient.layerProtocolHttp({ url: `${baseUrl}${RPC_PATH}` }).pipe(Layer.provide(Layer.mergeAll(FetchHttpClient.layer, RpcSerialization.layerJson)))));
166
+ const flatClient = yield* RpcClient.make(rpc.group, { flatten: true }).pipe(Effect.provide(RpcClient.layerProtocolHttp({ url: `${baseUrl}${RPC_PATH}` }).pipe(Layer.provide(Layer.mergeAll(FetchHttpClient.layer, RpcSerialization.layerNdjson)))));
161
167
  appRpcClient = AppRpcClientTag.of({ call: (tag, payload) => flatClient(tag, payload) });
162
168
  }
163
169
  router = Router.of({
164
170
  currentMatch,
165
171
  navigate,
166
172
  httpApiClient: Option.some(httpApiClient),
167
- navigating: navRef
173
+ navigating: Subscribable.make({
174
+ get: SubscriptionRef.get(navRef),
175
+ changes: SubscriptionRef.changes(navRef)
176
+ })
168
177
  });
169
178
  return Context.make(Router, router).pipe(Context.add(AppRpcClientTag, appRpcClient));
170
179
  }));
@@ -1,20 +1,18 @@
1
- import { HttpApi, HttpApiClient } from "@effect/platform";
2
- import { Context, Effect, Option, Schema, Subscribable } from "effect";
3
- import { Component, Node } from "@weftui/core";
1
+ import { HttpApi, HttpApiClient } from "effect/unstable/httpapi";
2
+ import { Context, Effect, Option, Schema } from "effect";
3
+ import { Component, Node, Subscribable } from "@weftui/core";
4
4
 
5
5
  //#region src/errors.d.ts
6
- declare const RouterNotFound_base: Schema.TaggedErrorClass<RouterNotFound, "RouterNotFound", {
7
- readonly _tag: Schema.tag<"RouterNotFound">;
8
- } & {
9
- /** The path that could not be resolved, when known. */path: Schema.optional<typeof Schema.String>;
10
- }>;
6
+ declare const RouterNotFound_base: Schema.Class<RouterNotFound, Schema.TaggedStruct<"RouterNotFound", {
7
+ /** The path that could not be resolved, when known. */readonly path: Schema.optional<Schema.String>;
8
+ }>, import("effect/Cause").YieldableError>;
11
9
  /**
12
10
  * Tagged error raised by {@link notFound} and caught by the router's internal
13
11
  * not-found boundary. Exported so a user can place their own
14
12
  * `Boundary.catchTag("RouterNotFound", …)` to override the fallback for a subtree
15
13
  * (the router's internal boundary is outermost, so a nearer user boundary wins).
16
14
  *
17
- * Modeled as a `Schema.TaggedError` so it can be encoded/decoded across the wire
15
+ * Modeled as a `Schema.TaggedErrorClass` so it can be encoded/decoded across the wire
18
16
  * the same way `Boundary.rpc` replays typed failures.
19
17
  */
20
18
  declare class RouterNotFound extends RouterNotFound_base {}
@@ -29,12 +27,10 @@ declare class RouterNotFound extends RouterNotFound_base {}
29
27
  declare const notFound: (path?: string) => Effect.Effect<never, RouterNotFound>;
30
28
  /** Type guard recognising a {@link RouterNotFound} value regardless of its prototype. */
31
29
  declare const isRouterNotFound: (u: unknown) => u is RouterNotFound;
32
- declare const RouterParamsError_base: Schema.TaggedErrorClass<RouterParamsError, "RouterParamsError", {
33
- readonly _tag: Schema.tag<"RouterParamsError">;
34
- } & {
35
- /** Which side of the match failed validation. */source: Schema.Literal<["path", "query"]>; /** The requested field names, for diagnostics. */
36
- keys: Schema.Array$<typeof Schema.String>;
37
- }>;
30
+ declare const RouterParamsError_base: Schema.Class<RouterParamsError, Schema.TaggedStruct<"RouterParamsError", {
31
+ /** Which side of the match failed validation. */readonly source: Schema.Literals<readonly ["path", "query"]>; /** The requested field names, for diagnostics. */
32
+ readonly keys: Schema.$Array<Schema.String>;
33
+ }>, import("effect/Cause").YieldableError>;
38
34
  /**
39
35
  * Tagged error raised by `Router.params` / `Router.query` when the live match does
40
36
  * not satisfy the requested fields — either no route is matched, or a requested
@@ -45,7 +41,7 @@ declare const RouterParamsError_base: Schema.TaggedErrorClass<RouterParamsError,
45
41
  * It bubbles up through the route tree's aggregate error channel, so a user may
46
42
  * place a `Boundary.catchTag("RouterParamsError", …)` to recover within a subtree.
47
43
  *
48
- * Modeled as a `Schema.TaggedError` so it can be encoded/decoded across the wire
44
+ * Modeled as a `Schema.TaggedErrorClass` so it can be encoded/decoded across the wire
49
45
  * the same way `RouterNotFound` and `Boundary.rpc` replay typed failures.
50
46
  */
51
47
  declare class RouterParamsError extends RouterParamsError_base {}
@@ -63,7 +59,7 @@ type RouteMatch = {
63
59
  readonly url: string;
64
60
  };
65
61
  /** A string-encodeable schema as carried by an HttpApi endpoint's path/urlParams slot. */
66
- type ParamSchema = Schema.Schema<Record<string, unknown>, unknown, never>;
62
+ type ParamSchema = Schema.Codec<Record<string, unknown>, unknown, never>;
67
63
  /** A precompiled regex + decode schemas for one leaf, sourced from its HttpApi endpoint. */
68
64
  interface MatcherEntry {
69
65
  /** The compiled leaf (render/nesting metadata), resolved from the endpoint id. */
@@ -145,7 +141,7 @@ interface NavigateOptions {
145
141
  */
146
142
  readonly replace?: boolean;
147
143
  }
148
- declare const Router_base: Context.TagClass<Router, "@weftui/router/Router", {
144
+ declare const Router_base: Context.ServiceClass<Router, "@weftui/router/Router", {
149
145
  /** The current match as a hot `Subscribable`; drives the outlet. */readonly currentMatch: Subscribable.Subscribable<RouteMatch>;
150
146
  /**
151
147
  * Navigates to `to` (a path, optionally with a query). On the client this
@@ -170,7 +166,7 @@ declare const Router_base: Context.TagClass<Router, "@weftui/router/Router", {
170
166
  readonly navigating: Subscribable.Subscribable<NavState>;
171
167
  }>;
172
168
  declare class Router extends Router_base {}
173
- declare const OutletTag_base: Context.TagClass<OutletTag, "@weftui/router/Outlet", Node<never, never>>;
169
+ declare const OutletTag_base: Context.ServiceClass<OutletTag, "@weftui/router/Outlet", Node<never, never>>;
174
170
  /**
175
171
  * The injected outlet: the node a layout (or the server document shell) splices
176
172
  * to place the next level down. Provided per render by the router
@@ -459,13 +455,13 @@ interface CompiledLeaf {
459
455
  * `HttpApiEndpoint.setPath` constraint without an `as any` cast — param schemas
460
456
  * round-trip strings, so the `Schema.Struct` value is asserted to this shape.
461
457
  */
462
- readonly pathSchema: Schema.Schema<Record<string, unknown>, Readonly<Record<string, string | undefined>>>;
458
+ readonly pathSchema: Schema.Codec<Record<string, unknown>, Readonly<Record<string, string | undefined>>>;
463
459
  /**
464
460
  * Query schema. Its **encoded** side is typed string-encodeable
465
461
  * (`Record<string, string | ReadonlyArray<string> | undefined>`) so it satisfies
466
462
  * platform's `HttpApiEndpoint.setUrlParams` constraint without a cast.
467
463
  */
468
- readonly querySchema: Schema.Schema<Record<string, unknown>, Readonly<Record<string, string | ReadonlyArray<string> | undefined>>>;
464
+ readonly querySchema: Schema.Codec<Record<string, unknown>, Readonly<Record<string, string | ReadonlyArray<string> | undefined>>>;
469
465
  /** The page's component slot; invoked per render, reads params via `Router.params` / `Router.query`. */
470
466
  readonly component: ComponentSlot;
471
467
  /** Ancestor layouts (root → parent) wrapping this leaf. */
@@ -493,7 +489,7 @@ interface RouterDef<E = any, R = any> {
493
489
  * nesting/render metadata platform's flat API can't represent. Built by
494
490
  * {@link buildHttpApi} during {@link makeRouter}.
495
491
  */
496
- readonly httpApi: HttpApi.HttpApi.Any;
492
+ readonly httpApi: HttpApi.Any;
497
493
  /**
498
494
  * Phantom marker for the tree's aggregate error channel. Covariant (stores `E`
499
495
  * directly) so a fully-static `RouterDef<never, never>` stays assignable to the
@@ -544,7 +540,7 @@ declare function compile(def: {
544
540
  * merged `RpcGroup` over the ambient `AppRpcClient` (`POST /_eui/rpc`), wired
545
541
  * explicitly into `RouterServer`/`RouterLive`. The matcher reads only `"pages"`.
546
542
  */
547
- declare function buildHttpApi(leaves: readonly CompiledLeaf[]): HttpApi.HttpApi.Any;
543
+ declare function buildHttpApi(leaves: readonly CompiledLeaf[]): HttpApi.Any;
548
544
  /**
549
545
  * Seals a route tree into a {@link RouterDef}, compiling it eagerly (so leaf
550
546
  * references are stamped for `href`), building its authoritative {@link buildHttpApi}
@@ -1,5 +1,5 @@
1
- import { l as leafRegistry } from "./outlet-PoLEThsy.js";
2
- import { Either, Option, Schema } from "effect";
1
+ import { l as leafRegistry } from "./outlet-ty5ZYKBW.js";
2
+ import { Result, Schema } from "effect";
3
3
  //#region src/matcher.ts
4
4
  /** Escapes a literal path segment for inclusion in a `RegExp`. */
5
5
  function escapeRegex(literal) {
@@ -56,8 +56,8 @@ function compileMatchers(def) {
56
56
  leaf,
57
57
  regex: patternToRegex(endpoint.path),
58
58
  paramNames: paramNamesOf(endpoint.path),
59
- pathSchema: Option.getOrElse(endpoint.pathSchema, () => emptySchema),
60
- querySchema: Option.getOrElse(endpoint.urlParamsSchema, () => emptySchema)
59
+ pathSchema: endpoint.params ?? emptySchema,
60
+ querySchema: endpoint.query ?? emptySchema
61
61
  });
62
62
  }
63
63
  entries.sort((a, b) => {
@@ -108,15 +108,15 @@ function match(def, url) {
108
108
  const raw = m[i + 1];
109
109
  if (raw !== void 0) rawParams[name] = decodeURIComponent(raw);
110
110
  });
111
- const decodedPath = Schema.decodeUnknownEither(entry.pathSchema)(rawParams);
112
- if (Either.isLeft(decodedPath)) continue;
113
- const decodedQuery = Schema.decodeUnknownEither(entry.querySchema)(parseQuery(search));
114
- if (Either.isLeft(decodedQuery)) continue;
111
+ const decodedPath = Schema.decodeUnknownResult(entry.pathSchema)(rawParams);
112
+ if (Result.isFailure(decodedPath)) continue;
113
+ const decodedQuery = Schema.decodeUnknownResult(entry.querySchema)(parseQuery(search));
114
+ if (Result.isFailure(decodedQuery)) continue;
115
115
  return {
116
116
  _tag: "Matched",
117
117
  leaf: entry.leaf,
118
- path: decodedPath.right,
119
- query: decodedQuery.right,
118
+ path: decodedPath.success,
119
+ query: decodedQuery.success,
120
120
  url: normalizedUrl
121
121
  };
122
122
  }
package/dist/index.d.ts CHANGED
@@ -1,3 +1,3 @@
1
- import { A as notFound, C as RouterHttpApiClient, D as RouterNotFound, E as match, O as RouterParamsError, S as Router, T as compileMatchers, _ as TreeE, a as RouterOptions, b as NavState, c as leafRegistry, d as FieldsType, f as LayoutNode, g as SubtreeR, h as SubtreeE, i as RouterDef, k as isRouterNotFound, l as ComponentSlot, m as RouteNode, n as CompiledLayout, o as buildHttpApi, p as RouteHandlerProps, r as CompiledLeaf, s as compile, t as Compiled, u as Fields, v as TreeNode, w as RouteMatch, x as NavigateOptions, y as TreeR } from "./compile-CQhAXB5f.js";
2
- import { i as href, n as outletNode, r as HrefArgs, t as RouterApp } from "./outlet-CGfLQgZS.js";
1
+ import { A as notFound, C as RouterHttpApiClient, D as RouterNotFound, E as match, O as RouterParamsError, S as Router, T as compileMatchers, _ as TreeE, a as RouterOptions, b as NavState, c as leafRegistry, d as FieldsType, f as LayoutNode, g as SubtreeR, h as SubtreeE, i as RouterDef, k as isRouterNotFound, l as ComponentSlot, m as RouteNode, n as CompiledLayout, o as buildHttpApi, p as RouteHandlerProps, r as CompiledLeaf, s as compile, t as Compiled, u as Fields, v as TreeNode, w as RouteMatch, x as NavigateOptions, y as TreeR } from "./compile-DWdMZiMt.js";
2
+ import { i as href, n as outletNode, r as HrefArgs, t as RouterApp } from "./outlet-BiFb5gdK.js";
3
3
  export { type Compiled, type CompiledLayout, type CompiledLeaf, type ComponentSlot, type Fields, type FieldsType, type HrefArgs, type LayoutNode, type NavState, type NavigateOptions, type RouteHandlerProps, type RouteMatch, type RouteNode, Router, RouterApp, type RouterDef, type RouterHttpApiClient, RouterNotFound, type RouterOptions, RouterParamsError, type SubtreeE, type SubtreeR, type TreeE, type TreeNode, type TreeR, buildHttpApi, compile, compileMatchers, href, isRouterNotFound, leafRegistry, match, notFound, outletNode };
package/dist/index.js CHANGED
@@ -1,3 +1,3 @@
1
- import { a as Router, c as compile, d as RouterParamsError, f as isRouterNotFound, l as leafRegistry, n as outletNode, p as notFound, s as buildHttpApi, t as RouterApp, u as RouterNotFound } from "./outlet-PoLEThsy.js";
2
- import { n as compileMatchers, r as match, t as href } from "./href-Deoi59OI.js";
1
+ import { a as Router, c as compile, d as RouterParamsError, f as isRouterNotFound, l as leafRegistry, n as outletNode, p as notFound, s as buildHttpApi, t as RouterApp, u as RouterNotFound } from "./outlet-ty5ZYKBW.js";
2
+ import { n as compileMatchers, r as match, t as href } from "./href-DD8PoCGO.js";
3
3
  export { Router, RouterApp, RouterNotFound, RouterParamsError, buildHttpApi, compile, compileMatchers, href, isRouterNotFound, leafRegistry, match, notFound, outletNode };
@@ -1,4 +1,4 @@
1
- import { D as RouterNotFound, S as Router, d as FieldsType, i as RouterDef, m as RouteNode, u as Fields } from "./compile-CQhAXB5f.js";
1
+ import { D as RouterNotFound, S as Router, d as FieldsType, i as RouterDef, m as RouteNode, u as Fields } from "./compile-DWdMZiMt.js";
2
2
  import { Node } from "@weftui/core";
3
3
 
4
4
  //#region src/href.d.ts
@@ -1,6 +1,6 @@
1
- import { HttpApi, HttpApiEndpoint, HttpApiGroup } from "@effect/platform";
2
- import { Context, Effect, Exit, Schema, Stream, Subscribable, pipe } from "effect";
3
- import { Boundary, getElementDescriptor, h } from "@weftui/core";
1
+ import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "effect/unstable/httpapi";
2
+ import { Context, Effect, Exit, Schema, Stream, pipe } from "effect";
3
+ import { Boundary, Subscribable, getElementDescriptor, h } from "@weftui/core";
4
4
  //#region src/errors.ts
5
5
  /**
6
6
  * Tagged error raised by {@link notFound} and caught by the router's internal
@@ -8,10 +8,10 @@ import { Boundary, getElementDescriptor, h } from "@weftui/core";
8
8
  * `Boundary.catchTag("RouterNotFound", …)` to override the fallback for a subtree
9
9
  * (the router's internal boundary is outermost, so a nearer user boundary wins).
10
10
  *
11
- * Modeled as a `Schema.TaggedError` so it can be encoded/decoded across the wire
11
+ * Modeled as a `Schema.TaggedErrorClass` so it can be encoded/decoded across the wire
12
12
  * the same way `Boundary.rpc` replays typed failures.
13
13
  */
14
- var RouterNotFound = class extends Schema.TaggedError()("RouterNotFound", {
14
+ var RouterNotFound = class extends Schema.TaggedErrorClass()("RouterNotFound", {
15
15
  /** The path that could not be resolved, when known. */
16
16
  path: Schema.optional(Schema.String) }) {};
17
17
  /**
@@ -35,12 +35,12 @@ const isRouterNotFound = (u) => typeof u === "object" && u !== null && "_tag" in
35
35
  * It bubbles up through the route tree's aggregate error channel, so a user may
36
36
  * place a `Boundary.catchTag("RouterParamsError", …)` to recover within a subtree.
37
37
  *
38
- * Modeled as a `Schema.TaggedError` so it can be encoded/decoded across the wire
38
+ * Modeled as a `Schema.TaggedErrorClass` so it can be encoded/decoded across the wire
39
39
  * the same way `RouterNotFound` and `Boundary.rpc` replay typed failures.
40
40
  */
41
- var RouterParamsError = class extends Schema.TaggedError()("RouterParamsError", {
41
+ var RouterParamsError = class extends Schema.TaggedErrorClass()("RouterParamsError", {
42
42
  /** Which side of the match failed validation. */
43
- source: Schema.Literal("path", "query"),
43
+ source: Schema.Literals(["path", "query"]),
44
44
  /** The requested field names, for diagnostics. */
45
45
  keys: Schema.Array(Schema.String)
46
46
  }) {};
@@ -171,7 +171,12 @@ function compile(def) {
171
171
  * explicitly into `RouterServer`/`RouterLive`. The matcher reads only `"pages"`.
172
172
  */
173
173
  function buildHttpApi(leaves) {
174
- const group = leaves.reduce((g, leaf) => g.add(HttpApiEndpoint.get(leaf.id, leaf.fullPathPattern).setPath(leaf.pathSchema).setUrlParams(leaf.querySchema).addSuccess(Schema.String).addError(RouterNotFound, { status: 404 })), HttpApiGroup.make("pages"));
174
+ const group = leaves.reduce((g, leaf) => g.add(HttpApiEndpoint.get(leaf.id, leaf.fullPathPattern, {
175
+ params: leaf.pathSchema,
176
+ query: leaf.querySchema,
177
+ success: Schema.String,
178
+ error: RouterNotFound.pipe(HttpApiSchema.status(404))
179
+ })), HttpApiGroup.make("pages"));
175
180
  return HttpApi.make("router").add(group);
176
181
  }
177
182
  /**
@@ -283,7 +288,7 @@ function lazyComponent(load) {
283
288
  }
284
289
  //#endregion
285
290
  //#region src/router-service.ts
286
- var Router = class extends Context.Tag("@weftui/router/Router")() {};
291
+ var Router = class extends Context.Service()("@weftui/router/Router") {};
287
292
  /**
288
293
  * The injected outlet: the node a layout (or the server document shell) splices
289
294
  * to place the next level down. Provided per render by the router
@@ -295,7 +300,7 @@ var Router = class extends Context.Tag("@weftui/router/Router")() {};
295
300
  * structurally by {@link makeLayout} / {@link makeRouter}, never inferred across
296
301
  * this DI boundary. Re-exported on the namespace as `Router.Outlet`.
297
302
  */
298
- var OutletTag = class extends Context.Tag("@weftui/router/Outlet")() {};
303
+ var OutletTag = class extends Context.Service()("@weftui/router/Outlet") {};
299
304
  /** Picks the requested `fields` keys out of a decoded match record. */
300
305
  function pick(fields, record) {
301
306
  const subset = {};
@@ -1,7 +1,7 @@
1
- import { S as Router, i as RouterDef, l as ComponentSlot } from "../compile-CQhAXB5f.js";
1
+ import { S as Router, i as RouterDef, l as ComponentSlot } from "../compile-DWdMZiMt.js";
2
2
  import { Effect, Layer } from "effect";
3
3
  import { AppRpcClientTag } from "@weftui/core";
4
- import { RpcGroup } from "@effect/rpc";
4
+ import { RpcGroup } from "effect/unstable/rpc";
5
5
 
6
6
  //#region src/server/router-server.d.ts
7
7
  /**
@@ -1,9 +1,10 @@
1
- import { a as Router, f as isRouterNotFound, n as outletNode, u as RouterNotFound } from "../outlet-PoLEThsy.js";
2
- import { HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, HttpServer, HttpServerResponse } from "@effect/platform";
3
- import { Cause, Effect, Exit, Layer, Option, Schema, Scope, Stream, Subscribable } from "effect";
4
- import { AppRpcClientTag } from "@weftui/core";
1
+ import { a as Router, f as isRouterNotFound, n as outletNode, u as RouterNotFound } from "../outlet-ty5ZYKBW.js";
2
+ import { HttpApiBuilder, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi";
3
+ import { Cause, Effect, Exit, Layer, Option, Schema, Scope, Stream } from "effect";
4
+ import { AppRpcClientTag, Subscribable } from "@weftui/core";
5
+ import { HttpRouter, HttpServer, HttpServerResponse } from "effect/unstable/http";
5
6
  import { SuspenseFailureHandlerTag, renderToHydratableShell, renderToStringHydratable } from "@weftui/dom/server";
6
- import { RpcSerialization, RpcServer, RpcTest } from "@effect/rpc";
7
+ import { RpcSerialization, RpcServer, RpcTest } from "effect/unstable/rpc";
7
8
  //#region src/server/router-server.ts
8
9
  let RouterServer;
9
10
  (function(_RouterServer) {
@@ -44,7 +45,7 @@ let RouterServer;
44
45
  */
45
46
  function appRpcClientLayer(rpc) {
46
47
  if (rpc === void 0) return Layer.succeed(AppRpcClientTag, AppRpcClientTag.of({ call: (tag) => Effect.fail(/* @__PURE__ */ new Error(`Boundary.rpc "${tag}" cannot resolve: no \`rpc\` option was passed to RouterServer`)) }));
47
- return Layer.scoped(AppRpcClientTag, Effect.map(RpcTest.makeClient(rpc.group, { flatten: true }), (flat) => AppRpcClientTag.of({ call: (tag, payload) => flat(tag, payload) }))).pipe(Layer.provide(rpc.handlers));
48
+ return Layer.effect(AppRpcClientTag, Effect.map(RpcTest.makeClient(rpc.group, { flatten: true }), (flat) => AppRpcClientTag.of({ call: (tag, payload) => flat(tag, payload) }))).pipe(Layer.provide(rpc.handlers));
48
49
  }
49
50
  /**
50
51
  * Renders the document shell — with `app` spliced via `Router.Outlet` — to a
@@ -106,7 +107,7 @@ let RouterServer;
106
107
  */
107
108
  function notFoundSuspenseHandler(def) {
108
109
  return { handle: (cause) => {
109
- const failure = Cause.failureOption(cause);
110
+ const failure = Cause.findErrorOption(cause);
110
111
  return Option.isSome(failure) && isRouterNotFound(failure.value) ? Option.some({
111
112
  content: def.compiled.notFound(),
112
113
  markNoindex: true,
@@ -126,7 +127,7 @@ let RouterServer;
126
127
  const app = outletNode(def);
127
128
  return Effect.gen(function* () {
128
129
  const scope = yield* Scope.make();
129
- const { shell, patches } = yield* renderToHydratableShell(Effect.provideService(options.document({}), Router.Outlet, app)).pipe(Effect.provideService(Router, router), Effect.provideService(SuspenseFailureHandlerTag, notFoundSuspenseHandler(def)), Effect.provide(appRpcClientLayer(options.rpc)), Effect.provide(options.context ?? Layer.empty), Scope.extend(scope), Effect.onError((cause) => Scope.close(scope, Exit.failCause(cause))));
130
+ const { shell, patches } = yield* renderToHydratableShell(Effect.provideService(options.document({}), Router.Outlet, app)).pipe(Effect.provideService(Router, router), Effect.provideService(SuspenseFailureHandlerTag, notFoundSuspenseHandler(def)), Effect.provide(appRpcClientLayer(options.rpc)), Effect.provide(options.context ?? Layer.empty), Scope.provide(scope), Effect.onError((cause) => Scope.close(scope, Exit.failCause(cause))));
130
131
  const body = Stream.make(`<!DOCTYPE html>\n${shell}`).pipe(Stream.concat(patches), Stream.ensuring(Scope.close(scope, Exit.void)), Stream.encodeText);
131
132
  return HttpServerResponse.stream(body, {
132
133
  status: 200,
@@ -155,24 +156,24 @@ let RouterServer;
155
156
  if (cached !== void 0) return cached;
156
157
  const leaves = def.compiled.leaves;
157
158
  const builder = HttpApiBuilder;
158
- const fallbackGroup = HttpApiGroup.make("fallback").add(HttpApiEndpoint.get("catchAll", "*").addSuccess(Schema.String));
159
+ const fallbackGroup = HttpApiGroup.make("fallback").add(HttpApiEndpoint.get("catchAll", "*", { success: Schema.String }));
159
160
  const api = def.httpApi.add(fallbackGroup);
160
161
  const pagesLayer = builder.group(api, "pages", (handlers) => leaves.reduce((h, leaf) => h.handle(leaf.id, (request) => leafRenderer(def, options, {
161
162
  _tag: "Matched",
162
163
  leaf,
163
- path: request.path,
164
- query: request.urlParams,
164
+ path: request.params,
165
+ query: request.query,
165
166
  url: request.request.url
166
167
  })), handlers));
167
168
  const fallbackLayer = builder.group(api, "fallback", (handlers) => handlers.handle("catchAll", (request) => renderNoMatch(def, options, request.request.url)));
168
- const apiLayer = builder.api(api).pipe(Layer.provide(Layer.mergeAll(pagesLayer, fallbackLayer)));
169
- const { handler: pageHandler } = HttpApiBuilder.toWebHandler(Layer.mergeAll(apiLayer, HttpServer.layerContext));
169
+ const apiRoutes = HttpApiBuilder.layer(api).pipe(Layer.provide(Layer.mergeAll(pagesLayer, fallbackLayer)));
170
170
  const rpc = options.rpc;
171
- let handler = pageHandler;
172
- if (rpc !== void 0) {
173
- const { handler: rpcHandler } = RpcServer.toWebHandler(rpc.group, { layer: Layer.mergeAll(rpc.handlers, RpcSerialization.layerJson) });
174
- handler = (request) => new URL(request.url).pathname === RPC_PATH ? rpcHandler(request) : pageHandler(request);
175
- }
171
+ const rpcRoutes = rpc !== void 0 ? RpcServer.layerHttp({
172
+ group: rpc.group,
173
+ path: RPC_PATH,
174
+ protocol: "http"
175
+ }).pipe(Layer.provide(Layer.mergeAll(rpc.handlers, RpcSerialization.layerNdjson))) : Layer.empty;
176
+ const { handler } = HttpRouter.toWebHandler(Layer.mergeAll(apiRoutes, rpcRoutes, HttpServer.layerServices));
176
177
  perDef.set(options.document, handler);
177
178
  return handler;
178
179
  }
@@ -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
 
@@ -11,7 +11,7 @@ description: Add component-local state with SubscriptionRef and weave its stream
11
11
 
12
12
  ## Local state with `SubscriptionRef`
13
13
 
14
- Use Effect's `SubscriptionRef` for component-local state. Its `.changes` property is 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 as a child or prop and the DOM at that spot becomes live:
15
15
 
16
16
  ```typescript
17
17
  import { h } from "@weftui/core";
@@ -23,7 +23,7 @@ const Counter = () =>
23
23
  const count = yield* SubscriptionRef.make(0);
24
24
 
25
25
  return yield* h.div([
26
- h.span([count.changes]),
26
+ h.span([SubscriptionRef.changes(count)]),
27
27
  h.button({ onclick: () => SubscriptionRef.update(count, (n) => n + 1) }, "+"),
28
28
  h.button({ onclick: () => SubscriptionRef.update(count, (n) => n - 1) }, "-"),
29
29
  ]);
@@ -36,18 +36,18 @@ void Effect.runPromise(mount(Counter(), document.getElementById("root")!));
36
36
 
37
37
  ## The key idea: the body runs once
38
38
 
39
- 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 `count.changes` stream woven into the `h.span`. 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.
39
+ 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`. 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.
40
40
 
41
41
  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).
42
42
 
43
- > **Note.** `[count.changes]` — 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.
43
+ > **Note.** `[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.
44
44
 
45
45
  ## Deriving values
46
46
 
47
- Because `.changes` is a `Stream`, you shape reactive text with ordinary stream operators:
47
+ Because `SubscriptionRef.changes(count)` returns a `Stream`, you shape reactive text with ordinary stream operators:
48
48
 
49
49
  ```typescript
50
- h.span([Stream.map(count.changes, (n) => `Count: ${n}`)]);
50
+ h.span([Stream.map(SubscriptionRef.changes(count), (n) => `Count: ${n}`)]);
51
51
  ```
52
52
 
53
53
  Anywhere you would compute a derived value, map the stream instead — the derivation stays reactive.
@@ -18,10 +18,9 @@ import { h } from "@weftui/core";
18
18
  import { mount } from "@weftui/dom/client";
19
19
  import { Context, Effect, Layer, pipe } from "effect";
20
20
 
21
- class Logger extends Context.Tag("Logger")<
22
- Logger,
23
- { log: (message: string) => Effect.Effect<void> }
24
- >() {}
21
+ class Logger extends Context.Service<Logger, { log: (message: string) => Effect.Effect<void> }>()(
22
+ "Logger",
23
+ ) {}
25
24
 
26
25
  const LoggerLive = Layer.succeed(Logger, {
27
26
  log: (message) => Effect.sync(() => console.log(message)),
@@ -20,12 +20,12 @@ import { Data, Effect } from "effect";
20
20
  class ApiError extends Data.TaggedError("ApiError")<{ status: number }> {}
21
21
 
22
22
  const SafeWidget = () =>
23
- Boundary.catchAll({ fallback: (e) => h.div({ class: "error" }, `Request failed: ${e.status}`) }, [
23
+ Boundary.catch({ fallback: (e) => h.div({ class: "error" }, `Request failed: ${e.status}`) }, [
24
24
  Effect.fail(new ApiError({ status: 503 })),
25
25
  ]);
26
26
  ```
27
27
 
28
- There are six failure-catch variants — `catchAll`, `catchAllCause`, `catchTag`, `catchTags`, `catchSome`, `catchIf` — mirroring Effect's own error operators. A failure that a boundary does not match re-raises to the **nearest enclosing** boundary; if none catches it, the mount fails. The conceptual model (and why the boundary's type reflects exactly which failures are handled) is [Boundaries and Suspense](https://weftui.dev/docs/explanation/boundaries-and-suspense).
28
+ There are six failure-catch variants — `catch`, `catchCause`, `catchTag`, `catchTags`, `catchFilter`, `catchIf` — mirroring Effect's own error operators. A failure that a boundary does not match re-raises to the **nearest enclosing** boundary; if none catches it, the mount fails. The conceptual model (and why the boundary's type reflects exactly which failures are handled) is [Boundaries and Suspense](https://weftui.dev/docs/explanation/boundaries-and-suspense).
29
29
 
30
30
  ## Render on the server
31
31
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@weftui/router",
3
- "version": "0.26.2",
3
+ "version": "0.27.0",
4
4
  "description": "Universal nested router for Weft — one route tree, server and client, with type-safe params and href",
5
5
  "keywords": [
6
6
  "effect",
@@ -44,15 +44,13 @@
44
44
  "access": "public"
45
45
  },
46
46
  "dependencies": {
47
- "@effect/platform": "^0.96.1",
48
- "@effect/rpc": "^0.75.1",
49
- "@weftui/dom": "0.26.2",
50
- "@weftui/core": "0.26.2"
47
+ "@weftui/core": "0.27.0",
48
+ "@weftui/dom": "0.27.0"
51
49
  },
52
50
  "devDependencies": {
53
51
  "@types/jsdom": "^28.0.3",
54
52
  "@types/node": "^25.9.2",
55
- "effect": "^3.21.4",
53
+ "effect": "4.0.0-beta.93",
56
54
  "jsdom": "^29.1.1",
57
55
  "tsx": "^4.22.4",
58
56
  "typescript": "^6.0.3",
@@ -60,6 +58,6 @@
60
58
  "vite-plus": "0.2.2"
61
59
  },
62
60
  "peerDependencies": {
63
- "effect": "^3.21"
61
+ "effect": "4.0.0-beta.93"
64
62
  }
65
63
  }