@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 +2 -0
- package/dist/client/index.d.ts +4 -4
- package/dist/client/index.js +28 -19
- package/dist/{compile-CQhAXB5f.d.ts → compile-DWdMZiMt.d.ts} +19 -23
- package/dist/{href-Deoi59OI.js → href-DD8PoCGO.js} +10 -10
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/{outlet-CGfLQgZS.d.ts → outlet-BiFb5gdK.d.ts} +1 -1
- package/dist/{outlet-PoLEThsy.js → outlet-ty5ZYKBW.js} +16 -11
- package/dist/server/index.d.ts +2 -2
- package/dist/server/index.js +19 -18
- package/docs/explanation/boundaries-and-suspense.md +8 -8
- package/docs/explanation/reactive-primitives.md +13 -11
- package/docs/explanation/rendering-model.md +1 -1
- package/docs/explanation/services-and-context.md +3 -3
- package/docs/how-to/add-routing.md +17 -5
- package/docs/how-to/author-components.md +5 -5
- package/docs/how-to/handle-forms.md +10 -10
- package/docs/how-to/load-async-data.md +4 -4
- package/docs/how-to/load-data-with-rpc.md +5 -5
- package/docs/how-to/provide-services.md +1 -1
- package/docs/how-to/use-element-refs.md +5 -5
- package/docs/reference/core.md +31 -20
- package/docs/reference/router.md +9 -5
- package/docs/tutorial/02-reactivity.md +6 -6
- package/docs/tutorial/03-services-and-async.md +3 -4
- package/docs/tutorial/04-errors-and-server.md +2 -2
- package/package.json +5 -7
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
|
package/dist/client/index.d.ts
CHANGED
|
@@ -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-
|
|
2
|
-
import { n as outletNode, r as HrefArgs, t as RouterApp } from "../outlet-
|
|
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 "
|
|
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
|
-
*
|
|
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
|
package/dist/client/index.js
CHANGED
|
@@ -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-
|
|
2
|
-
import { r as match, t as href } from "../href-
|
|
3
|
-
import {
|
|
4
|
-
import { Context, Effect, Exit, Fiber, Layer, Option,
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
77
|
+
* `effect/unstable/rpc`.
|
|
77
78
|
*/
|
|
78
79
|
function RouterLive(def, options = {}) {
|
|
79
|
-
const core = Layer.
|
|
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
|
|
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.
|
|
118
|
+
const fiber = yield* Effect.forkChild(Effect.provideService(preRunLeaf(router, target), Scope.Scope, scope));
|
|
114
119
|
inflightPreRun = fiber;
|
|
115
|
-
if (!emitted) yield* Effect.
|
|
116
|
-
|
|
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
|
-
|
|
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(
|
|
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("
|
|
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.
|
|
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:
|
|
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 "
|
|
2
|
-
import { Context, Effect, Option, Schema
|
|
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.
|
|
7
|
-
readonly
|
|
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.
|
|
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.
|
|
33
|
-
readonly
|
|
34
|
-
|
|
35
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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-
|
|
2
|
-
import {
|
|
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:
|
|
60
|
-
querySchema:
|
|
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.
|
|
112
|
-
if (
|
|
113
|
-
const decodedQuery = Schema.
|
|
114
|
-
if (
|
|
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.
|
|
119
|
-
query: decodedQuery.
|
|
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-
|
|
2
|
-
import { i as href, n as outletNode, r as HrefArgs, t as RouterApp } from "./outlet-
|
|
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-
|
|
2
|
-
import { n as compileMatchers, r as match, t as href } from "./href-
|
|
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-
|
|
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 "
|
|
2
|
-
import { Context, Effect, Exit, Schema, Stream,
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
41
|
+
var RouterParamsError = class extends Schema.TaggedErrorClass()("RouterParamsError", {
|
|
42
42
|
/** Which side of the match failed validation. */
|
|
43
|
-
source: Schema.
|
|
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
|
|
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.
|
|
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.
|
|
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 = {};
|
package/dist/server/index.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { S as Router, i as RouterDef, l as ComponentSlot } from "../compile-
|
|
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 "
|
|
4
|
+
import { RpcGroup } from "effect/unstable/rpc";
|
|
5
5
|
|
|
6
6
|
//#region src/server/router-server.d.ts
|
|
7
7
|
/**
|
package/dist/server/index.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { a as Router, f as isRouterNotFound, n as outletNode, u as RouterNotFound } from "../outlet-
|
|
2
|
-
import { HttpApiBuilder, HttpApiEndpoint, HttpApiGroup
|
|
3
|
-
import { Cause, Effect, Exit, Layer, Option, Schema, Scope, Stream
|
|
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 "
|
|
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.
|
|
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.
|
|
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.
|
|
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", "*"
|
|
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.
|
|
164
|
-
query: request.
|
|
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
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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.
|
|
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
|
|
29
|
-
|
|
|
30
|
-
| `
|
|
31
|
-
| `
|
|
32
|
-
| `catchTag` / `catchTags`
|
|
33
|
-
| `
|
|
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 `
|
|
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
|
-
//
|
|
56
|
-
h.span([
|
|
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(
|
|
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(
|
|
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
|
|
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(
|
|
79
|
-
const formatted = Stream.map(
|
|
80
|
-
const isHigh = Stream.map(
|
|
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([
|
|
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(
|
|
97
|
-
|
|
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.
|
|
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
|
|
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.
|
|
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.
|
|
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
|
-
###
|
|
281
|
+
### `effect/unstable/httpapi` is the spine
|
|
270
282
|
|
|
271
|
-
The tree is the authoring surface, but
|
|
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.
|
|
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
|
|
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([
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
|
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,
|
|
16
|
+
import { Effect, Result, Schema, Stream, SubscriptionRef } from "effect";
|
|
17
17
|
|
|
18
18
|
const Email = Schema.String.pipe(
|
|
19
|
-
Schema.
|
|
20
|
-
Schema.
|
|
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(
|
|
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
|
|
32
|
-
|
|
33
|
-
|
|
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(
|
|
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(
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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 "
|
|
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.
|
|
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.
|
|
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.
|
|
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 (`
|
|
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
|
|
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
|
-
|
|
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 `
|
|
39
|
-
- **Use `Effect.forkScoped`, not `Effect.
|
|
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
|
|
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
|
package/docs/reference/core.md
CHANGED
|
@@ -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` ([
|
|
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.
|
|
205
|
-
|
|
206
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
268
|
+
#### `Boundary.catchFilter`
|
|
270
269
|
|
|
271
|
-
|
|
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.
|
|
275
|
-
|
|
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<
|
|
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
|
|
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.
|
|
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.
|
|
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";
|
package/docs/reference/router.md
CHANGED
|
@@ -19,7 +19,7 @@ Three entry points mirror `@weftui/dom`:
|
|
|
19
19
|
|
|
20
20
|
## `Router`
|
|
21
21
|
|
|
22
|
-
`Router` is both an Effect `Context.
|
|
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.
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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([
|
|
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 `
|
|
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.** `[
|
|
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
|
|
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(
|
|
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.
|
|
22
|
-
Logger,
|
|
23
|
-
|
|
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.
|
|
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 — `
|
|
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.
|
|
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
|
-
"@
|
|
48
|
-
"@
|
|
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": "
|
|
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": "
|
|
61
|
+
"effect": "4.0.0-beta.93"
|
|
64
62
|
}
|
|
65
63
|
}
|