@weftui/router 0.27.0 → 0.28.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 +3 -1
- package/dist/client/index.d.ts +4 -4
- package/dist/client/index.js +2 -2
- package/dist/{compile-DWdMZiMt.d.ts → compile-DySac7KN.d.ts} +18 -14
- package/dist/{href-DD8PoCGO.js → href-uvJ6b7zz.js} +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/{outlet-ty5ZYKBW.js → outlet-C9N4a4_F.js} +2 -2
- package/dist/{outlet-BiFb5gdK.d.ts → outlet-saKduDFz.d.ts} +1 -2
- package/dist/server/index.d.ts +1 -2
- package/dist/server/index.js +1 -1
- package/docs/explanation/boundaries-and-suspense.md +2 -2
- package/docs/explanation/reactive-primitives.md +1 -1
- package/docs/explanation/rendering-model.md +2 -2
- package/docs/explanation/services-and-context.md +13 -17
- package/docs/how-to/add-routing.md +7 -7
- package/docs/how-to/author-components.md +7 -5
- package/docs/how-to/load-data-with-rpc.md +4 -2
- package/docs/how-to/provide-services.md +52 -74
- package/docs/how-to/render-on-the-server.md +4 -3
- package/docs/index.md +2 -2
- package/docs/reference/dom.md +166 -45
- package/docs/reference/router.md +5 -5
- package/docs/tutorial/01-your-first-app.md +7 -4
- package/docs/tutorial/02-reactivity.md +3 -2
- package/docs/tutorial/03-services-and-async.md +12 -10
- package/docs/tutorial/04-errors-and-server.md +3 -2
- package/package.json +11 -10
package/README.md
CHANGED
|
@@ -9,9 +9,11 @@ Three entry points mirror `@weftui/dom`: `@weftui/router` (authoring + universal
|
|
|
9
9
|
## Installation
|
|
10
10
|
|
|
11
11
|
```bash
|
|
12
|
-
npm install @weftui/core @weftui/dom @weftui/router effect
|
|
12
|
+
npm install @weftui/core @weftui/dom @weftui/router effect@beta
|
|
13
13
|
```
|
|
14
14
|
|
|
15
|
+
Weft tracks Effect 4's beta line. This release is built and tested against `effect@4.0.0-beta.98`; the peer range accepts newer 4.0 betas, which may contain upstream breaking changes.
|
|
16
|
+
|
|
15
17
|
`effect` is a peer dependency; `@weftui/core` and `@weftui/dom` provide the tree and renderer.
|
|
16
18
|
|
|
17
19
|
## Key exports
|
package/dist/client/index.d.ts
CHANGED
|
@@ -1,9 +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-DySac7KN.js";
|
|
2
|
+
import { n as outletNode, r as HrefArgs, t as RouterApp } from "../outlet-saKduDFz.js";
|
|
3
3
|
import { Effect, Layer, Scope } from "effect";
|
|
4
4
|
import { AppRpcClientTag } from "@weftui/core";
|
|
5
5
|
import { RpcGroup } from "effect/unstable/rpc";
|
|
6
|
-
|
|
7
6
|
//#region src/client/router-live.d.ts
|
|
8
7
|
/**
|
|
9
8
|
* The residual app services a caller must still provide through the {@link RouterLiveOptions.context}
|
|
@@ -44,7 +43,8 @@ interface RouterLiveOptions {
|
|
|
44
43
|
* descriptive error.
|
|
45
44
|
*/
|
|
46
45
|
readonly rpc?: {
|
|
47
|
-
/** The app's merged `RpcGroup` (pure Schema contract). */
|
|
46
|
+
/** The app's merged `RpcGroup` (pure Schema contract). */
|
|
47
|
+
readonly group: RpcGroup.RpcGroup<any>;
|
|
48
48
|
};
|
|
49
49
|
}
|
|
50
50
|
/**
|
package/dist/client/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
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-
|
|
1
|
+
import { a as Router, i as setResolvedCommit, n as outletNode, o as getPreload, r as preRunLeaf, t as RouterApp } from "../outlet-C9N4a4_F.js";
|
|
2
|
+
import { r as match, t as href } from "../href-uvJ6b7zz.js";
|
|
3
3
|
import { HttpApiClient } from "effect/unstable/httpapi";
|
|
4
4
|
import { Context, Effect, Exit, Fiber, Layer, Option, Schema, Scope, Stream, SubscriptionRef } from "effect";
|
|
5
5
|
import { AppRpcClientTag, Subscribable } from "@weftui/core";
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { HttpApi, HttpApiClient } from "effect/unstable/httpapi";
|
|
2
2
|
import { Context, Effect, Option, Schema } from "effect";
|
|
3
3
|
import { Component, Node, Subscribable } from "@weftui/core";
|
|
4
|
-
|
|
5
4
|
//#region src/errors.d.ts
|
|
6
5
|
declare const RouterNotFound_base: Schema.Class<RouterNotFound, Schema.TaggedStruct<"RouterNotFound", {
|
|
7
|
-
/** The path that could not be resolved, when known. */
|
|
6
|
+
/** The path that could not be resolved, when known. */
|
|
7
|
+
readonly path: Schema.optional<Schema.String>;
|
|
8
8
|
}>, import("effect/Cause").YieldableError>;
|
|
9
9
|
/**
|
|
10
10
|
* Tagged error raised by {@link notFound} and caught by the router's internal
|
|
@@ -28,7 +28,9 @@ declare const notFound: (path?: string) => Effect.Effect<never, RouterNotFound>;
|
|
|
28
28
|
/** Type guard recognising a {@link RouterNotFound} value regardless of its prototype. */
|
|
29
29
|
declare const isRouterNotFound: (u: unknown) => u is RouterNotFound;
|
|
30
30
|
declare const RouterParamsError_base: Schema.Class<RouterParamsError, Schema.TaggedStruct<"RouterParamsError", {
|
|
31
|
-
/** Which side of the match failed validation. */
|
|
31
|
+
/** Which side of the match failed validation. */
|
|
32
|
+
readonly source: Schema.Literals<readonly ["path", "query"]>;
|
|
33
|
+
/** The requested field names, for diagnostics. */
|
|
32
34
|
readonly keys: Schema.$Array<Schema.String>;
|
|
33
35
|
}>, import("effect/Cause").YieldableError>;
|
|
34
36
|
/**
|
|
@@ -52,7 +54,8 @@ type RouteMatch = {
|
|
|
52
54
|
readonly _tag: "Matched";
|
|
53
55
|
readonly leaf: CompiledLeaf;
|
|
54
56
|
readonly path: Record<string, unknown>;
|
|
55
|
-
readonly query: Record<string, unknown>;
|
|
57
|
+
readonly query: Record<string, unknown>;
|
|
58
|
+
/** Normalized request URL (path + search), used by the outlet as a dedupe key. */
|
|
56
59
|
readonly url: string;
|
|
57
60
|
} | {
|
|
58
61
|
readonly _tag: "NotFound";
|
|
@@ -66,9 +69,9 @@ interface MatcherEntry {
|
|
|
66
69
|
readonly leaf: CompiledLeaf;
|
|
67
70
|
readonly regex: RegExp;
|
|
68
71
|
readonly paramNames: readonly string[];
|
|
69
|
-
/** Path-param schema, read from the endpoint's `
|
|
72
|
+
/** Path-param schema, read from the endpoint's `params` slot. */
|
|
70
73
|
readonly pathSchema: ParamSchema;
|
|
71
|
-
/** Query schema, read from the endpoint's `
|
|
74
|
+
/** Query schema, read from the endpoint's `query` slot. */
|
|
72
75
|
readonly querySchema: ParamSchema;
|
|
73
76
|
}
|
|
74
77
|
/**
|
|
@@ -111,7 +114,7 @@ declare function match(def: RouterDef, url: string): RouteMatch;
|
|
|
111
114
|
*/
|
|
112
115
|
/**
|
|
113
116
|
* The platform `HttpApiClient` derived from a router's `HttpApi` spine. Typed
|
|
114
|
-
* opaquely (`Client<any, …>`) because the spine is `HttpApi.
|
|
117
|
+
* opaquely (`Client<any, …>`) because the spine is `HttpApi.Top` — its
|
|
115
118
|
* group/endpoint shapes are assembled in a runtime loop by `buildHttpApi`, so a
|
|
116
119
|
* precise client type is not recoverable. Present (`Option.some`) on the client
|
|
117
120
|
* (`RouterLive`), absent (`Option.none`) on the server, which is itself the origin.
|
|
@@ -142,7 +145,8 @@ interface NavigateOptions {
|
|
|
142
145
|
readonly replace?: boolean;
|
|
143
146
|
}
|
|
144
147
|
declare const Router_base: Context.ServiceClass<Router, "@weftui/router/Router", {
|
|
145
|
-
/** The current match as a hot `Subscribable`; drives the outlet. */
|
|
148
|
+
/** The current match as a hot `Subscribable`; drives the outlet. */
|
|
149
|
+
readonly currentMatch: Subscribable.Subscribable<RouteMatch>;
|
|
146
150
|
/**
|
|
147
151
|
* Navigates to `to` (a path, optionally with a query). On the client this
|
|
148
152
|
* pushes History state and re-renders the affected outlet; on the server it
|
|
@@ -452,14 +456,14 @@ interface CompiledLeaf {
|
|
|
452
456
|
/**
|
|
453
457
|
* Path-param schema. Its **encoded** side is typed string-encodeable
|
|
454
458
|
* (`Record<string, string | undefined>`) so it satisfies platform's
|
|
455
|
-
* `HttpApiEndpoint
|
|
459
|
+
* `HttpApiEndpoint` `params` constraint without an `as any` cast — param schemas
|
|
456
460
|
* round-trip strings, so the `Schema.Struct` value is asserted to this shape.
|
|
457
461
|
*/
|
|
458
462
|
readonly pathSchema: Schema.Codec<Record<string, unknown>, Readonly<Record<string, string | undefined>>>;
|
|
459
463
|
/**
|
|
460
464
|
* Query schema. Its **encoded** side is typed string-encodeable
|
|
461
465
|
* (`Record<string, string | ReadonlyArray<string> | undefined>`) so it satisfies
|
|
462
|
-
* platform's `HttpApiEndpoint
|
|
466
|
+
* platform's `HttpApiEndpoint` `query` constraint without a cast.
|
|
463
467
|
*/
|
|
464
468
|
readonly querySchema: Schema.Codec<Record<string, unknown>, Readonly<Record<string, string | ReadonlyArray<string> | undefined>>>;
|
|
465
469
|
/** The page's component slot; invoked per render, reads params via `Router.params` / `Router.query`. */
|
|
@@ -489,7 +493,7 @@ interface RouterDef<E = any, R = any> {
|
|
|
489
493
|
* nesting/render metadata platform's flat API can't represent. Built by
|
|
490
494
|
* {@link buildHttpApi} during {@link makeRouter}.
|
|
491
495
|
*/
|
|
492
|
-
readonly httpApi: HttpApi.
|
|
496
|
+
readonly httpApi: HttpApi.Top;
|
|
493
497
|
/**
|
|
494
498
|
* Phantom marker for the tree's aggregate error channel. Covariant (stores `E`
|
|
495
499
|
* directly) so a fully-static `RouterDef<never, never>` stays assignable to the
|
|
@@ -527,20 +531,20 @@ declare function compile(def: {
|
|
|
527
531
|
/**
|
|
528
532
|
* Builds the authoritative `HttpApi` for a compiled tree (S4): a single `"pages"`
|
|
529
533
|
* group whose endpoints are GET endpoints — one per leaf — at each leaf's full path
|
|
530
|
-
* pattern, carrying `
|
|
534
|
+
* pattern, carrying `params: pathSchema`, `query: querySchema`, a
|
|
531
535
|
* `Schema.String` (text/HTML) success, and a `RouterNotFound → 404` error. The tree
|
|
532
536
|
* (not `HttpApi`) is the authoring surface; this is the single source of truth the
|
|
533
537
|
* server dispatch (`HttpApiBuilder`) and the client matcher / derived `HttpApiClient`
|
|
534
538
|
* read from, so both sides agree on paths and schemas.
|
|
535
539
|
*
|
|
536
540
|
* Each leaf's `pathSchema`/`querySchema` are typed string-encodeable (see
|
|
537
|
-
* {@link CompiledLeaf}), so `
|
|
541
|
+
* {@link CompiledLeaf}), so the `params`/`query` options need no `as any` casts.
|
|
538
542
|
*
|
|
539
543
|
* `Boundary.rpc` data no longer rides this spine: it resolves through the app's
|
|
540
544
|
* merged `RpcGroup` over the ambient `AppRpcClient` (`POST /_eui/rpc`), wired
|
|
541
545
|
* explicitly into `RouterServer`/`RouterLive`. The matcher reads only `"pages"`.
|
|
542
546
|
*/
|
|
543
|
-
declare function buildHttpApi(leaves: readonly CompiledLeaf[]): HttpApi.
|
|
547
|
+
declare function buildHttpApi(leaves: readonly CompiledLeaf[]): HttpApi.Top;
|
|
544
548
|
/**
|
|
545
549
|
* Seals a route tree into a {@link RouterDef}, compiling it eagerly (so leaf
|
|
546
550
|
* references are stamped for `href`), building its authoritative {@link buildHttpApi}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { l as leafRegistry } from "./outlet-
|
|
1
|
+
import { l as leafRegistry } from "./outlet-C9N4a4_F.js";
|
|
2
2
|
import { Result, Schema } from "effect";
|
|
3
3
|
//#region src/matcher.ts
|
|
4
4
|
/** Escapes a literal path segment for inclusion in a `RegExp`. */
|
|
@@ -50,7 +50,7 @@ function compileMatchers(def) {
|
|
|
50
50
|
const endpoints = def.httpApi.groups["pages"]?.endpoints ?? {};
|
|
51
51
|
const entries = [];
|
|
52
52
|
for (const endpoint of Object.values(endpoints)) {
|
|
53
|
-
const leaf = leafById.get(endpoint.
|
|
53
|
+
const leaf = leafById.get(endpoint.identifier);
|
|
54
54
|
if (leaf === void 0) continue;
|
|
55
55
|
entries.push({
|
|
56
56
|
leaf,
|
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-DySac7KN.js";
|
|
2
|
+
import { i as href, n as outletNode, r as HrefArgs, t as RouterApp } from "./outlet-saKduDFz.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-C9N4a4_F.js";
|
|
2
|
+
import { n as compileMatchers, r as match, t as href } from "./href-uvJ6b7zz.js";
|
|
3
3
|
export { Router, RouterApp, RouterNotFound, RouterParamsError, buildHttpApi, compile, compileMatchers, href, isRouterNotFound, leafRegistry, match, notFound, outletNode };
|
|
@@ -157,14 +157,14 @@ function compile(def) {
|
|
|
157
157
|
/**
|
|
158
158
|
* Builds the authoritative `HttpApi` for a compiled tree (S4): a single `"pages"`
|
|
159
159
|
* group whose endpoints are GET endpoints — one per leaf — at each leaf's full path
|
|
160
|
-
* pattern, carrying `
|
|
160
|
+
* pattern, carrying `params: pathSchema`, `query: querySchema`, a
|
|
161
161
|
* `Schema.String` (text/HTML) success, and a `RouterNotFound → 404` error. The tree
|
|
162
162
|
* (not `HttpApi`) is the authoring surface; this is the single source of truth the
|
|
163
163
|
* server dispatch (`HttpApiBuilder`) and the client matcher / derived `HttpApiClient`
|
|
164
164
|
* read from, so both sides agree on paths and schemas.
|
|
165
165
|
*
|
|
166
166
|
* Each leaf's `pathSchema`/`querySchema` are typed string-encodeable (see
|
|
167
|
-
* {@link CompiledLeaf}), so `
|
|
167
|
+
* {@link CompiledLeaf}), so the `params`/`query` options need no `as any` casts.
|
|
168
168
|
*
|
|
169
169
|
* `Boundary.rpc` data no longer rides this spine: it resolves through the app's
|
|
170
170
|
* merged `RpcGroup` over the ambient `AppRpcClient` (`POST /_eui/rpc`), wired
|
|
@@ -1,6 +1,5 @@
|
|
|
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-DySac7KN.js";
|
|
2
2
|
import { Node } from "@weftui/core";
|
|
3
|
-
|
|
4
3
|
//#region src/href.d.ts
|
|
5
4
|
/**
|
|
6
5
|
* The `href` argument object for a leaf with path fields `Path` and query fields
|
package/dist/server/index.d.ts
CHANGED
|
@@ -1,8 +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-DySac7KN.js";
|
|
2
2
|
import { Effect, Layer } from "effect";
|
|
3
3
|
import { AppRpcClientTag } from "@weftui/core";
|
|
4
4
|
import { RpcGroup } from "effect/unstable/rpc";
|
|
5
|
-
|
|
6
5
|
//#region src/server/router-server.d.ts
|
|
7
6
|
/**
|
|
8
7
|
* Server-side rendering for a {@link RouterDef}. Dispatch runs through the
|
package/dist/server/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { a as Router, f as isRouterNotFound, n as outletNode, u as RouterNotFound } from "../outlet-
|
|
1
|
+
import { a as Router, f as isRouterNotFound, n as outletNode, u as RouterNotFound } from "../outlet-C9N4a4_F.js";
|
|
2
2
|
import { HttpApiBuilder, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi";
|
|
3
3
|
import { Cause, Effect, Exit, Layer, Option, Schema, Scope, Stream } from "effect";
|
|
4
4
|
import { AppRpcClientTag, Subscribable } from "@weftui/core";
|
|
@@ -38,9 +38,9 @@ The channel algebra is the whole reason they exist: `catchTag("Foo", …)` remov
|
|
|
38
38
|
|
|
39
39
|
The routing above describes what happens while a node is being built. Once mounted, a reactive region — an attribute, child, or list stream, or a hydrated equivalent — keeps running for the lifetime of its scope, and it can still fail later: a `Stream` backing a `Boundary.rpc` resource might raise `RouterNotFound` after a client-side navigation, for instance. If a `BoundaryContext` encloses the region, the failure routes to it exactly as above, and the boundary's fallback swaps in.
|
|
40
40
|
|
|
41
|
-
If no boundary encloses it, there is nothing to swap to. Weft does not synthesize one: the region's DOM keeps its last rendered content, and
|
|
41
|
+
If no boundary encloses it, there is nothing to swap to. Weft does not synthesize one: the region's DOM keeps its last rendered content, and a watcher fiber — forked into the same scope alongside the subscription itself — observes its exit directly. When that exit is a failure whose cause is not interruption-only, Weft reports it explicitly via `Effect.logError(exit.cause)`, annotated with `weft.region` to identify the failing region by kind and identity (e.g. `attribute:class`, `child:stream-3`, `list:stream-2`, `hydrate:stream-1 (/products/42)`). This fires for typed failures and defects alike, in both dev and prod, exactly once per failing region, at the `"Error"` level. Interruption — the ordinary case of unmount tearing down the region's scope — is never reported; only genuine failures are.
|
|
42
42
|
|
|
43
|
-
This is deliberate: rather than
|
|
43
|
+
This is deliberate: rather than leaving the failure to whatever the Effect runtime would otherwise do with an unobserved fiber exit, Weft observes and logs it itself, so visibility is controlled by the same knobs any Effect program uses — `References.MinimumLogLevel` (provided via `Effect.provideService`) to filter it, or a custom `Logger` to route it elsewhere. A stream that can fail and has no enclosing boundary is a stream whose failures you've chosen not to route into the UI — the log is what tells you that decision has consequences at runtime.
|
|
44
44
|
|
|
45
45
|
## Suspense boundaries
|
|
46
46
|
|
|
@@ -147,7 +147,7 @@ When a `Stream` prop ends before emitting, the renderer raises a `NoPropValue` t
|
|
|
147
147
|
// Handle at the mount boundary if needed. `Effect.catchTag` matches the error
|
|
148
148
|
// by its string tag, so no `NoPropValue` import is required here.
|
|
149
149
|
pipe(
|
|
150
|
-
mount(App(), root),
|
|
150
|
+
WeftApp.mount(app, App(), root),
|
|
151
151
|
Effect.catchTag("NoPropValue", (e) =>
|
|
152
152
|
Effect.logWarning(`Prop stream ended before emitting: ${e.key}`),
|
|
153
153
|
),
|
|
@@ -46,9 +46,9 @@ This also fixes the update _shape_. Because the structure is fixed, an update is
|
|
|
46
46
|
The same component tree renders on the server and the client:
|
|
47
47
|
|
|
48
48
|
- On the **server**, the tree renders to an HTML string (or a streaming response) via `@weftui/dom/server`. The _hydratable_ renderers additionally emit the inline data each reactive region needs to resume.
|
|
49
|
-
- On the **client**, `hydrate
|
|
49
|
+
- On the **client**, `WeftApp.hydrate` walks that server-rendered DOM and **adopts it in place** — it wires up reactivity and event handlers on the existing nodes rather than re-rendering. The first client production matches the adopted DOM exactly, so nothing is mutated and there is no flash.
|
|
50
50
|
|
|
51
|
-
Because the same `Node<E, R>` describes both passes, there is nothing to keep in sync: the server output and the client's first render are the _same tree_ run in two environments. Services flow from the
|
|
51
|
+
Because the same `Node<E, R>` describes both passes, there is nothing to keep in sync: the server output and the client's first render are the _same tree_ run in two environments. Services flow from the app layer (or the router's render-time context) through the tree to wherever a component reads them, on both sides. The mechanics of the two-sided render live in [Render on the Server](https://weftui.dev/docs/how-to/render-on-the-server); the service flow is [Services and Context](https://weftui.dev/docs/explanation/services-and-context).
|
|
52
52
|
|
|
53
53
|
## Why this matters
|
|
54
54
|
|
|
@@ -15,37 +15,33 @@ When a component does `yield* ThemeService`, `ThemeService` enters that node's r
|
|
|
15
15
|
|
|
16
16
|
```typescript
|
|
17
17
|
import { Effect } from "effect";
|
|
18
|
-
import {
|
|
18
|
+
import { WeftApp } from "@weftui/dom/client";
|
|
19
19
|
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
Effect.provide(ThemeServiceLive),
|
|
23
|
-
);
|
|
20
|
+
const app = WeftApp.make(ThemeServiceLive);
|
|
21
|
+
const handle = WeftApp.mount(app, App(), document.getElementById("root")!);
|
|
24
22
|
```
|
|
25
23
|
|
|
26
|
-
Provide too little and it is a compile error at
|
|
24
|
+
Provide too little and it is a compile error at `WeftApp.make` — the type of `App()` names exactly which service is missing. This is the same discipline as any Effect program: `R` is a promise the type checker holds you to, discharged at the program's boundary, not sprinkled through the tree.
|
|
27
25
|
|
|
28
|
-
Services flow **down** from
|
|
26
|
+
Services flow **down** from the app's layer to every reader, including across reactive boundaries: a stream woven into a prop carries its own `R`, and a handler that reads a service resolves it from the same context. There is no prop-drilling and no context-provider component — the requirement channel _is_ the wiring.
|
|
29
27
|
|
|
30
|
-
## Layer lifetime
|
|
28
|
+
## Layer lifetime and the app runtime
|
|
31
29
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
`Effect.provide(scopedLayer)` is `acquireUseRelease` sugar: acquire, run the wrapped effect, then release **when that effect completes**. Wrap it directly around `mount`, and the release runs at mount-resolve — while the mounted tree is still reading from the now-disposed service:
|
|
30
|
+
Under the old `mount`/`hydrate` model this was a real footgun. Each call created its own implicit `ManagedRuntime`, and that runtime's effect resolved right after the tree's **initial render** — not when the app stopped running — while streams, event handlers, and forked work kept running on it long after. `Effect.provide(scopedLayer)` is `acquireUseRelease` sugar: acquire, run the wrapped effect, then release **when that effect completes**. Wrapped directly around `mount`, the release ran at mount-resolve — while the mounted tree was still reading from the now-disposed service:
|
|
35
31
|
|
|
36
32
|
```typescript
|
|
37
|
-
// ❌ the layer's finalizers
|
|
38
|
-
// mounted tree
|
|
33
|
+
// ❌ (old API) the layer's finalizers ran the instant runPromise settled, while the
|
|
34
|
+
// mounted tree kept running — every subscription then read a disposed service
|
|
39
35
|
Effect.runPromise(mount(App(), root).pipe(Effect.provide(SomeScopedLayer)));
|
|
40
36
|
```
|
|
41
37
|
|
|
42
38
|
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
39
|
|
|
44
|
-
|
|
40
|
+
`WeftApp` closes this gap structurally instead of by convention. An app owns exactly **one** lazy `ManagedRuntime`: `WeftApp.make(layer)` builds the layer on the first `mount`/`hydrate`, and it releases only at `WeftApp.dispose(app)` — never when any individual mount's render effect resolves. A scoped layer (`AtomRegistry.layer`, `RouterLive`) therefore just works passed straight to `WeftApp.make`, with no `mountScoped`, no `Effect.never`, and no manual `ManagedRuntime` composition to reach for. See [Provide Services](https://weftui.dev/docs/how-to/provide-services) for the recipes — including `memoMap` sharing across apps and the `Effect.acquireRelease(make, dispose)` pattern for binding an app's own lifetime to an external scope (there is deliberately no `makeScoped`).
|
|
45
41
|
|
|
46
42
|
## The router's render-time context seam
|
|
47
43
|
|
|
48
|
-
A plain `mount`/`hydrate` discharges `R` at
|
|
44
|
+
A plain `WeftApp.mount`/`WeftApp.hydrate` discharges `R` once, at `WeftApp.make`. But under `@weftui/router`, the tree does not render in the context of the effect that called `render` — each request dispatches through platform's HTTP layer in its own managed context, and the reactive outlet drains in the top render context, not in any intermediate node's. Providing a service _ambiently_ around the render would be lost before it reached a route component.
|
|
49
45
|
|
|
50
46
|
So the router exposes an explicit **`context` seam** — a `Layer` threaded to the document shell and every route, layout, and leaf:
|
|
51
47
|
|
|
@@ -77,7 +73,7 @@ class Db extends ServerTag("Db")<Db, { query: (sql: string) => Effect.Effect<Row
|
|
|
77
73
|
## The whole picture
|
|
78
74
|
|
|
79
75
|
- A component reads a service with `yield* Service`; the requirement enters `R`.
|
|
80
|
-
- `R` accumulates through the tree and is discharged **once** — at `
|
|
76
|
+
- `R` accumulates through the tree and is discharged **once** — at `WeftApp.make`, or through the router's `context` seam.
|
|
81
77
|
- The same services flow to the same components on the server and the client, because it is the same tree.
|
|
82
78
|
- `ServerTag` brands the services that must stay server-side, enforced at the `hydrate` boundary.
|
|
83
79
|
|
|
@@ -85,7 +81,7 @@ class Db extends ServerTag("Db")<Db, { query: (sql: string) => Effect.Effect<Row
|
|
|
85
81
|
|
|
86
82
|
- [The Rendering Model](https://weftui.dev/docs/explanation/rendering-model) — why services flow through the tree at all
|
|
87
83
|
- [The Combinator API](https://weftui.dev/docs/explanation/combinator-api) — how `R` accumulates from children and reactive props
|
|
88
|
-
- [Provide Services](https://weftui.dev/docs/how-to/provide-services) — recipes for
|
|
84
|
+
- [Provide Services](https://weftui.dev/docs/how-to/provide-services) — recipes for app layers, scoped layers, and binding an app's lifetime to an external scope
|
|
89
85
|
- [Add Routing](https://weftui.dev/docs/how-to/add-routing) — providing app services through the router `context` seam
|
|
90
86
|
- [Load Data with RPC](https://weftui.dev/docs/how-to/load-data-with-rpc) — where `ServerTag` and the rpc handler Layer meet
|
|
91
87
|
- [`ServerTag` API reference](https://weftui.dev/docs/reference/core#servertag)
|
|
@@ -177,21 +177,21 @@ Router.route("users/:id", {
|
|
|
177
177
|
|
|
178
178
|
## Client setup
|
|
179
179
|
|
|
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.
|
|
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. Give it to `WeftApp.make`: the app runtime owns it for the app's lifetime, built lazily on first hydrate and released only at `WeftApp.dispose`. Do not wrap `Effect.provide` around the mount/hydrate call — services come exclusively from the app layer.
|
|
181
181
|
|
|
182
182
|
```typescript
|
|
183
183
|
// entry-client.ts
|
|
184
|
-
import {
|
|
184
|
+
import { WeftApp } from "@weftui/dom/client";
|
|
185
185
|
import { RouterApp, RouterLive } from "@weftui/router/client";
|
|
186
|
-
import {
|
|
186
|
+
import { Effect } from "effect";
|
|
187
187
|
import { App } from "./app";
|
|
188
188
|
|
|
189
189
|
const root = document.getElementById("root")!;
|
|
190
|
-
const
|
|
191
|
-
void
|
|
190
|
+
const app = WeftApp.make(RouterLive(App));
|
|
191
|
+
void Effect.runPromise(WeftApp.hydrate(app, RouterApp(App), root));
|
|
192
192
|
```
|
|
193
193
|
|
|
194
|
-
For a client-only app (no SSR), swap `hydrate` for `mount` — everything else is identical.
|
|
194
|
+
For a client-only app (no SSR), swap `WeftApp.hydrate` for `WeftApp.mount` — everything else is identical.
|
|
195
195
|
|
|
196
196
|
### Link interception
|
|
197
197
|
|
|
@@ -280,7 +280,7 @@ export const handler = RouterServer.toWebHandler(App, { document: documentShell
|
|
|
280
280
|
|
|
281
281
|
### `effect/unstable/httpapi` is the spine
|
|
282
282
|
|
|
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 `
|
|
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 `params: pathSchema`, `query: querySchema`, and a `RouterNotFound → 404` error. Both sides read that one definition, so they always agree:
|
|
284
284
|
|
|
285
285
|
- **Server** — `RouterServer` dispatches through `HttpApiBuilder` (platform owns request→leaf matching, path/query decode, and the 404 status).
|
|
286
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.
|
|
@@ -56,7 +56,7 @@ The return type here is `Effect.Effect<Node, never, never>` — itself a valid `
|
|
|
56
56
|
Every component instance is rendered under its own **instance scope** — a child of the
|
|
57
57
|
mount scope created fresh for that instance. Anything bound to the instance scope lives
|
|
58
58
|
exactly as long as the component is mounted and is torn down automatically when the
|
|
59
|
-
component unmounts (or when
|
|
59
|
+
component unmounts (or when its root unmounts via `RootHandle.unmount()`). The renderer
|
|
60
60
|
provides this scope as the ambient `Scope.Scope` while it evaluates the component body,
|
|
61
61
|
so it is already in context when you need it.
|
|
62
62
|
|
|
@@ -235,12 +235,14 @@ const UserAvatar = Component.gen(function* (props: { userId: string }) {
|
|
|
235
235
|
const avatar = UserAvatar({ userId: "123" });
|
|
236
236
|
```
|
|
237
237
|
|
|
238
|
-
|
|
238
|
+
Give the service to the app layer:
|
|
239
239
|
|
|
240
240
|
```typescript
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
241
|
+
import { WeftApp } from "@weftui/dom/client";
|
|
242
|
+
import { Effect } from "effect";
|
|
243
|
+
|
|
244
|
+
const app = WeftApp.make(UserServiceLive);
|
|
245
|
+
void Effect.runPromise(WeftApp.mount(app, App(), document.getElementById("root")!));
|
|
244
246
|
```
|
|
245
247
|
|
|
246
248
|
## Returning fragments
|
|
@@ -88,11 +88,13 @@ export const render = (url: string) =>
|
|
|
88
88
|
|
|
89
89
|
```typescript
|
|
90
90
|
// entry-client.ts — network client posting to /_eui/rpc
|
|
91
|
+
import { WeftApp } from "@weftui/dom/client";
|
|
91
92
|
import { RouterApp, RouterLive } from "@weftui/router/client";
|
|
93
|
+
import { Effect } from "effect";
|
|
92
94
|
import { StockRpcs } from "./data/inventory";
|
|
93
95
|
|
|
94
|
-
const
|
|
95
|
-
void
|
|
96
|
+
const app = WeftApp.make(RouterLive(App, { rpc: { group: StockRpcs } }));
|
|
97
|
+
void Effect.runPromise(WeftApp.hydrate(app, RouterApp(App), root));
|
|
96
98
|
```
|
|
97
99
|
|
|
98
100
|
- **Server** ([`RouterServer`](https://weftui.dev/docs/reference/router#routerserver)) mounts the handler Layer at `POST /_eui/rpc` (so a client refetch re-runs it on the server) **and** exposes an in-process client over the same handlers for SSR resolution — never a network hop.
|
|
@@ -2,123 +2,101 @@
|
|
|
2
2
|
title: Provide Services
|
|
3
3
|
order: 12
|
|
4
4
|
section: how-to
|
|
5
|
-
description: Provide plain and scoped Layers to a
|
|
5
|
+
description: Provide plain and scoped Layers to a WeftApp — app layers for the common case, scoped layers that just work, memoMap sharing, and binding an app's lifetime to a scope.
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# Provide Services
|
|
9
9
|
|
|
10
|
-
**Goal:** provide a `Layer` to
|
|
10
|
+
**Goal:** provide a `Layer` to a `WeftApp` so its components can read services with `yield* Service`.
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
## Recipe 1 — app layers
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
Provide the layer directly around `mount` and run with `runPromise`. This is the common case and needs nothing else.
|
|
14
|
+
Pass the layer to `WeftApp.make`. This is the common case and needs nothing else — the layer builds lazily on first mount, and every component, event handler, and stream subscription in every root mounted from `app` can read it.
|
|
17
15
|
|
|
18
16
|
```typescript
|
|
19
|
-
import {
|
|
20
|
-
import { Effect
|
|
17
|
+
import { WeftApp } from "@weftui/dom/client";
|
|
18
|
+
import { Effect } from "effect";
|
|
21
19
|
import { App } from "./app";
|
|
22
20
|
import { ThemeServiceLive } from "./theme-service";
|
|
23
21
|
|
|
24
22
|
const root = document.getElementById("root")!;
|
|
25
23
|
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
Effect.runPromise(program);
|
|
24
|
+
const app = WeftApp.make(ThemeServiceLive);
|
|
25
|
+
void Effect.runPromise(WeftApp.mount(app, App(), root));
|
|
29
26
|
```
|
|
30
27
|
|
|
31
|
-
## Recipe 2 — scoped layers
|
|
28
|
+
## Recipe 2 — scoped layers just work
|
|
29
|
+
|
|
30
|
+
A **scoped** layer — `Layer.effect` backed by `acquireRelease`, or anything else that owns a subscription, listener, or registry — needs nothing different from Recipe 1. The app owns one lazy `ManagedRuntime`: the layer builds on first mount and releases only at `WeftApp.dispose(app)`, not when any individual mount's render effect resolves. There is no `mountScoped`, no `Effect.never`, no manual scope threading.
|
|
32
31
|
|
|
33
|
-
|
|
32
|
+
`AtomRegistry.layer` (from `effect/unstable/reactivity`) is a real scoped layer — its atom subscriptions are fibers forked for the app's whole lifetime:
|
|
34
33
|
|
|
35
34
|
```typescript
|
|
36
|
-
import {
|
|
37
|
-
import {
|
|
35
|
+
import { WeftApp } from "@weftui/dom/client";
|
|
36
|
+
import { Effect } from "effect";
|
|
37
|
+
import { AtomRegistry } from "effect/unstable/reactivity";
|
|
38
38
|
import { App } from "./app";
|
|
39
|
-
import { AppLive } from "./app-live";
|
|
40
|
-
|
|
41
|
-
const root = document.getElementById("root")!;
|
|
42
|
-
|
|
43
|
-
const program = pipe(
|
|
44
|
-
Effect.scoped(
|
|
45
|
-
Effect.gen(function* () {
|
|
46
|
-
yield* mountScoped(App(), root);
|
|
47
|
-
yield* Effect.never; // keeps the region — and AppLive — alive
|
|
48
|
-
}),
|
|
49
|
-
),
|
|
50
|
-
Effect.provide(AppLive), // OUTSIDE the scoped region: outlives initial render
|
|
51
|
-
);
|
|
52
39
|
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
// later, e.g. on a "sign out" action or test teardown:
|
|
56
|
-
// await Effect.runPromise(Fiber.interrupt(fiber));
|
|
40
|
+
const app = WeftApp.make(AtomRegistry.layer);
|
|
41
|
+
void Effect.runPromise(WeftApp.mount(app, App(), document.getElementById("root")!));
|
|
57
42
|
```
|
|
58
43
|
|
|
59
|
-
|
|
44
|
+
`RouterLive` (from `@weftui/router/client`) is another — it owns the `popstate` listener and the same-origin link-click interceptor for as long as the app runs:
|
|
60
45
|
|
|
61
46
|
```typescript
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
const program = pipe(
|
|
65
|
-
Effect.scoped(
|
|
66
|
-
Effect.gen(function* () {
|
|
67
|
-
yield* mountScoped(App(), root);
|
|
68
|
-
yield* Deferred.await(shutdown); // resolves when shutdown is signalled
|
|
69
|
-
}),
|
|
70
|
-
),
|
|
71
|
-
Effect.provide(AppLive),
|
|
72
|
-
);
|
|
73
|
-
Effect.runFork(program);
|
|
74
|
-
|
|
75
|
-
// elsewhere, to request shutdown:
|
|
76
|
-
// await Effect.runPromise(Deferred.succeed(shutdown, undefined));
|
|
47
|
+
const app = WeftApp.make(RouterLive(App, { rpc: { group: StockRpcs } }));
|
|
48
|
+
void Effect.runPromise(WeftApp.hydrate(app, RouterApp(App), root));
|
|
77
49
|
```
|
|
78
50
|
|
|
79
|
-
|
|
51
|
+
Both examples are runnable in full at [examples/effect-atom](https://github.com/stefvw93/weft/tree/main/examples/effect-atom) and [examples/router-ssr](https://github.com/stefvw93/weft/tree/main/examples/router-ssr).
|
|
80
52
|
|
|
81
|
-
## Recipe 3 —
|
|
53
|
+
## Recipe 3 — sharing layer memoization with `memoMap`
|
|
82
54
|
|
|
83
|
-
|
|
55
|
+
`WeftApp.make(layer, { memoMap })` accepts an explicit `Layer.MemoMap`, so multiple `WeftApp` instances can share layer construction — for example, building one app per test case while reusing an expensive shared dependency's memoized build across them:
|
|
84
56
|
|
|
85
57
|
```typescript
|
|
86
|
-
import {
|
|
87
|
-
import {
|
|
88
|
-
import { App } from "./app";
|
|
89
|
-
import { AppLive } from "./app-live";
|
|
90
|
-
|
|
91
|
-
const root = document.getElementById("root")!;
|
|
92
|
-
const runtime = ManagedRuntime.make(AppLive);
|
|
58
|
+
import { WeftApp } from "@weftui/dom/client";
|
|
59
|
+
import { Layer } from "effect";
|
|
93
60
|
|
|
94
|
-
|
|
61
|
+
const memoMap = Layer.makeMemoMap();
|
|
95
62
|
|
|
96
|
-
|
|
97
|
-
|
|
63
|
+
const appA = WeftApp.make(SharedLive, { memoMap });
|
|
64
|
+
const appB = WeftApp.make(SharedLive, { memoMap });
|
|
98
65
|
```
|
|
99
66
|
|
|
100
|
-
|
|
67
|
+
Most apps have exactly one `WeftApp` and never need this option.
|
|
101
68
|
|
|
102
|
-
##
|
|
69
|
+
## Recipe 4 — binding an app's lifetime to a scope
|
|
103
70
|
|
|
104
|
-
|
|
71
|
+
There is deliberately no `makeScoped`. To tie an app's disposal to a `Scope` you already manage — a framework integration or a test harness that owns one — compose it yourself with `Effect.acquireRelease`:
|
|
105
72
|
|
|
106
73
|
```typescript
|
|
107
|
-
|
|
108
|
-
|
|
74
|
+
import { Effect } from "effect";
|
|
75
|
+
import { WeftApp } from "@weftui/dom/client";
|
|
76
|
+
import { AppLive } from "./app-live";
|
|
77
|
+
|
|
78
|
+
const acquireApp = Effect.acquireRelease(
|
|
79
|
+
Effect.sync(() => WeftApp.make(AppLive)),
|
|
80
|
+
(app) => WeftApp.dispose(app),
|
|
81
|
+
);
|
|
109
82
|
```
|
|
110
83
|
|
|
84
|
+
`acquireApp` yields a `WeftApp` and registers `WeftApp.dispose` as a finalizer on whatever scope the surrounding effect runs in — closing that scope tears the app down the same way `WeftApp.dispose` normally would (roots, then layers, then the error hub).
|
|
85
|
+
|
|
86
|
+
## Anti-pattern: `Effect.provide` around the mount call
|
|
87
|
+
|
|
111
88
|
```typescript
|
|
112
|
-
// ❌
|
|
113
|
-
//
|
|
114
|
-
|
|
89
|
+
// ❌ does nothing useful: WeftApp.mount's R is always `never`, and services
|
|
90
|
+
// come exclusively from the app layer — a wrapped Effect.provide never
|
|
91
|
+
// reaches components, handlers, or stream subscriptions
|
|
92
|
+
Effect.runPromise(pipe(WeftApp.mount(app, App(), root), Effect.provide(SomeLayer)));
|
|
115
93
|
```
|
|
116
94
|
|
|
117
|
-
|
|
95
|
+
`WeftApp.mount`/`WeftApp.hydrate` return an effect whose requirement channel is always `never` — there is no `R` left for `Effect.provide` to discharge. Any service a component needs must be in the layer passed to `WeftApp.make`.
|
|
118
96
|
|
|
119
97
|
## See also
|
|
120
98
|
|
|
121
|
-
- [
|
|
122
|
-
- [
|
|
123
|
-
- [
|
|
124
|
-
- [examples/
|
|
99
|
+
- [Services and Context](https://weftui.dev/docs/explanation/services-and-context) — how `R` accumulates and discharges at `WeftApp.make`, and why scoped layers no longer need special handling
|
|
100
|
+
- [`WeftApp` reference](https://weftui.dev/docs/reference/dom) — full signatures for `make`, `mount`, `hydrate`, `dispose`
|
|
101
|
+
- [examples/effect-atom](https://github.com/stefvw93/weft/tree/main/examples/effect-atom) — a real scoped layer (`AtomRegistry.layer`)
|
|
102
|
+
- [examples/shared-state-islands](https://github.com/stefvw93/weft/tree/main/examples/shared-state-islands) — one app layer shared by reference across multiple mounted roots
|
|
@@ -12,7 +12,7 @@ Weft renders on the server and **hydrates** on the client: the server produces H
|
|
|
12
12
|
## The two halves
|
|
13
13
|
|
|
14
14
|
- **Server** — `@weftui/dom/server` renders an app node to an HTML string (or stream). The _hydratable_ variants additionally emit the inline data each reactive region and `Boundary.rpc` needs to resume on the client.
|
|
15
|
-
- **Client** — `@weftui/dom/client`'s `hydrate` walks the server DOM, adopts it, wires up reactivity and event handlers, and resumes from the inline data. It does **not** re-render from scratch.
|
|
15
|
+
- **Client** — `@weftui/dom/client`'s `WeftApp.hydrate` walks the server DOM, adopts it, wires up reactivity and event handlers, and resumes from the inline data. It does **not** re-render from scratch.
|
|
16
16
|
|
|
17
17
|
```typescript
|
|
18
18
|
// server entry
|
|
@@ -25,12 +25,13 @@ export const render = (): Promise<string> => Effect.runPromise(renderToStringHyd
|
|
|
25
25
|
|
|
26
26
|
```typescript
|
|
27
27
|
// client entry
|
|
28
|
-
import {
|
|
28
|
+
import { WeftApp } from "@weftui/dom/client";
|
|
29
29
|
import { Effect } from "effect";
|
|
30
30
|
import { App } from "./app";
|
|
31
31
|
|
|
32
32
|
const root = document.getElementById("root")!;
|
|
33
|
-
|
|
33
|
+
const app = WeftApp.make();
|
|
34
|
+
void Effect.runPromise(WeftApp.hydrate(app, App(), root));
|
|
34
35
|
```
|
|
35
36
|
|
|
36
37
|
The same side-effect-free `App` is imported by both entries — splice the server HTML into your template's outlet, ship it, and let the client entry hydrate it.
|
package/docs/index.md
CHANGED
|
@@ -10,7 +10,7 @@ The docs follow the [Diátaxis](https://diataxis.fr) model. Pick your entry poin
|
|
|
10
10
|
|
|
11
11
|
**[→ Tutorial](https://weftui.dev/docs/tutorial/01-your-first-app)** — a four-step guided path from a static component to a server-rendered, error-handled app. Start here if you are new to Weft:
|
|
12
12
|
|
|
13
|
-
1. [Your First App](https://weftui.dev/docs/tutorial/01-your-first-app) — `h` and `
|
|
13
|
+
1. [Your First App](https://weftui.dev/docs/tutorial/01-your-first-app) — `h` and `WeftApp`
|
|
14
14
|
2. [Reactivity](https://weftui.dev/docs/tutorial/02-reactivity) — `SubscriptionRef` and streams
|
|
15
15
|
3. [Services and Async](https://weftui.dev/docs/tutorial/03-services-and-async) — handlers, services, async loading
|
|
16
16
|
4. [Errors and Server Rendering](https://weftui.dev/docs/tutorial/04-errors-and-server) — boundaries and SSR
|
|
@@ -31,7 +31,7 @@ New to the model itself? Read [The Rendering Model](https://weftui.dev/docs/expl
|
|
|
31
31
|
Three published packages make up Weft's public API, plus one build-time plugin:
|
|
32
32
|
|
|
33
33
|
- **`@weftui/core`** — element builders (`h`), components, sources/streams, and boundaries. Start here.
|
|
34
|
-
- **`@weftui/dom`** — the renderer: `./client` (`mount`/`hydrate`) and `./server` (`renderToString*`).
|
|
34
|
+
- **`@weftui/dom`** — the renderer: `./client` (`WeftApp.mount`/`WeftApp.hydrate`) and `./server` (`renderToString*`).
|
|
35
35
|
- **`@weftui/router`** — universal nested routing, `Router.lazy`, and the rpc seam.
|
|
36
36
|
- **`@weftui/vite`** — a build-time Vite plugin (tooling, not a runtime API).
|
|
37
37
|
|
package/docs/reference/dom.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
title: "@weftui/dom"
|
|
3
3
|
order: 2
|
|
4
4
|
section: reference
|
|
5
|
-
description: Full API surface for @weftui/dom — the client
|
|
5
|
+
description: Full API surface for @weftui/dom — the WeftApp client runtime (make, mount, hydrate, errors, dispose) and the server renderer (renderToString and streaming variants).
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# @weftui/dom API Reference
|
|
@@ -14,70 +14,191 @@ for a narrative walkthrough.
|
|
|
14
14
|
|
|
15
15
|
## `@weftui/dom/client`
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
`WeftApp` is the client entry point's app namespace (`export * as WeftApp from
|
|
18
|
+
"./weft-app"`). One `WeftApp` value is one lazily-built `ManagedRuntime` (the app
|
|
19
|
+
layer) + one root `Scope` + one unhandled-error hub. Each `WeftApp.mount` /
|
|
20
|
+
`WeftApp.hydrate` call creates a child **root scope** under the app scope;
|
|
21
|
+
layer-built services are shared **by reference** across every root mounted from the
|
|
22
|
+
same app (layer memoization), which is what makes cross-island reactive state work
|
|
23
|
+
(see [examples/shared-state-islands](https://github.com/stefvw93/weft/tree/main/examples/shared-state-islands)). The
|
|
24
|
+
barrel also re-exports `MountError`, `HydrateError`, `RootHandle`, `UnhandledError`,
|
|
25
|
+
and the `WeftApp` interface's type as `WeftAppType` (renamed on export to avoid
|
|
26
|
+
colliding with the `WeftApp` namespace import).
|
|
27
|
+
|
|
28
|
+
### `WeftApp.make`
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
const make: {
|
|
32
|
+
(): WeftApp<never, never>;
|
|
33
|
+
<R, E>(
|
|
34
|
+
layer: Layer.Layer<R, E, never>,
|
|
35
|
+
options?: { readonly memoMap?: Layer.MemoMap },
|
|
36
|
+
): WeftApp<R, E>;
|
|
37
|
+
};
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Creates a `WeftApp` from an app layer. Synchronous and side-effect-free with
|
|
41
|
+
respect to the layer: the layer builds **lazily** on the first `mount` / `hydrate`
|
|
42
|
+
(or the first direct `app.runtime` run) — `ManagedRuntime.make` semantics. A layer
|
|
43
|
+
whose construction has an observable side effect shows that effect only after the
|
|
44
|
+
first mount, never at `make` time. `options.memoMap` shares layer memoization
|
|
45
|
+
across multiple `WeftApp` instances.
|
|
46
|
+
|
|
47
|
+
There is deliberately no `makeScoped`. To bind an app's lifetime to a scope,
|
|
48
|
+
compose it yourself:
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
const acquireApp = Effect.acquireRelease(
|
|
52
|
+
Effect.sync(() => WeftApp.make(AppLive)),
|
|
53
|
+
(app) => WeftApp.dispose(app),
|
|
54
|
+
);
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### `WeftApp.mount`
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
const mount: <R, E>(
|
|
61
|
+
app: WeftApp<R, E>,
|
|
62
|
+
node: Renderable,
|
|
63
|
+
root: HTMLElement,
|
|
64
|
+
) => Effect.Effect<RootHandle, E | MountError>;
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Mounts `node` into `root` as a new root of `app`. Self-contained — the returned
|
|
68
|
+
effect's requirement channel is `never`, so it runs with a bare `Effect.runPromise`;
|
|
69
|
+
services come exclusively from the app layer, and an `Effect.provide` wrapped around
|
|
70
|
+
this call does not reach components. Clears `root`'s existing children, renders,
|
|
71
|
+
appends the result. Completes after initial render; streams keep running in the
|
|
72
|
+
background, owned by the root's scope (a child of the app scope). The app layer
|
|
73
|
+
builds lazily here on first mount; its error channel `E` surfaces at that point. On
|
|
74
|
+
render failure the root scope is closed before the error propagates; the app
|
|
75
|
+
runtime and other roots are untouched. Mounting on a disposed app fails — it does
|
|
76
|
+
not hang.
|
|
77
|
+
|
|
78
|
+
### `WeftApp.hydrate`
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
function hydrate<A extends Renderable, R = never, E = never>(
|
|
82
|
+
app: WeftApp<R, E>,
|
|
83
|
+
node: A,
|
|
84
|
+
root: HTMLElement,
|
|
85
|
+
): [AssertNoServerOnly<CoreNode.Context<A>>] extends [CoreNode.Context<A>]
|
|
86
|
+
? Effect.Effect<RootHandle, E | HydrateError>
|
|
87
|
+
: ServerOnlyLeak;
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Continues, on the client, the DOM produced on the server by
|
|
91
|
+
`renderToStringHydratable` / `renderToStreamHydratable`, as a new root of `app`.
|
|
92
|
+
Unlike `mount`, does **not** clear `root`: it walks the node tree in lockstep with
|
|
93
|
+
the existing server DOM, adopting nodes in place. Error channel is `E |
|
|
94
|
+
HydrateError` (adds `HydrationMismatchError` on top of everything `mount` can fail
|
|
95
|
+
with). Preserves the compile-time `AssertNoServerOnly` → `ServerOnlyLeak` guard: a
|
|
96
|
+
server-only requirement left in `node`'s context degrades the return type to the
|
|
97
|
+
`ServerOnlyLeak` sentinel (compile error at the call site) instead of a runtime
|
|
98
|
+
failure. Hydration mechanics — the readiness barrier, stream-id seeding — are
|
|
99
|
+
otherwise unchanged from `mount`.
|
|
100
|
+
|
|
101
|
+
### `WeftApp.errors`
|
|
18
102
|
|
|
19
103
|
```ts
|
|
20
|
-
|
|
104
|
+
const errors: <R, E>(app: WeftApp<R, E>) => Stream.Stream<UnhandledError>;
|
|
21
105
|
```
|
|
22
106
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
107
|
+
The app's unhandled-error stream. While at least one subscriber exists, the default
|
|
108
|
+
`Effect.logError` fallback is suppressed and every `UnhandledError` is delivered to
|
|
109
|
+
all subscribers. With zero subscribers, each unhandled error runs the default log
|
|
110
|
+
(annotated with `weft.region`) instead. No replay — a subscriber sees only errors
|
|
111
|
+
published after it subscribed; multiple concurrent subscribers each receive every
|
|
112
|
+
subsequent error. When the last subscriber unsubscribes, the default log resumes.
|
|
27
113
|
|
|
28
|
-
### `
|
|
114
|
+
### `WeftApp.dispose`
|
|
29
115
|
|
|
30
116
|
```ts
|
|
31
|
-
|
|
117
|
+
const dispose: <R, E>(app: WeftApp<R, E>) => Effect.Effect<void>;
|
|
32
118
|
```
|
|
33
119
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
is the flash-free path: no second render, the existing nodes simply become live.
|
|
120
|
+
Disposes the app: closes every root scope (in mount order), then releases the
|
|
121
|
+
runtime's layers (`runtime.disposeEffect`), then shuts the error hub down.
|
|
122
|
+
Idempotent — teardown effects run once. Subsequent `mount` / `hydrate` calls fail.
|
|
38
123
|
|
|
39
|
-
### `
|
|
124
|
+
### `WeftApp<R, E>` (`WeftAppType`)
|
|
40
125
|
|
|
41
126
|
```ts
|
|
42
|
-
|
|
127
|
+
interface WeftApp<in R = never, out E = never> {
|
|
128
|
+
readonly [TypeId]: typeof TypeId;
|
|
129
|
+
readonly runtime: ManagedRuntime.ManagedRuntime<R, E>;
|
|
130
|
+
}
|
|
43
131
|
```
|
|
44
132
|
|
|
45
|
-
|
|
46
|
-
`
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
[Provide Services](https://weftui.dev/docs/how-to/provide-services) for the composition and
|
|
50
|
-
[Layer lifetime at the mount](https://weftui.dev/docs/explanation/services-and-context#layer-lifetime-at-the-mount)
|
|
51
|
-
for why.
|
|
133
|
+
Re-exported from the barrel as `WeftAppType`. `runtime` is the app's
|
|
134
|
+
`ManagedRuntime`, for running app-level effects against the shared layer outside any
|
|
135
|
+
root — e.g. `app.runtime.runFork(trackPageviews)` (see
|
|
136
|
+
`website/src/entry-client.ts`) or `app.runtime.runPromise(Router.push("/about"))`.
|
|
52
137
|
|
|
53
|
-
### `
|
|
138
|
+
### `RootHandle`
|
|
54
139
|
|
|
55
140
|
```ts
|
|
56
|
-
|
|
141
|
+
interface RootHandle {
|
|
142
|
+
readonly element: HTMLElement;
|
|
143
|
+
unmount(): Effect.Effect<void>;
|
|
144
|
+
}
|
|
57
145
|
```
|
|
58
146
|
|
|
59
|
-
|
|
60
|
-
`
|
|
61
|
-
|
|
62
|
-
|
|
147
|
+
Returned by `mount` / `hydrate`. `element` is the DOM element the root was mounted
|
|
148
|
+
into. `unmount()` closes **this root's scope only**: it interrupts its stream
|
|
149
|
+
subscriptions and any scoped work forked from its event handlers. It does **not**
|
|
150
|
+
dispose the app runtime, touch other roots, or remove the rendered DOM nodes from
|
|
151
|
+
`element`. Idempotent — teardown side effects fire once.
|
|
63
152
|
|
|
64
|
-
### `
|
|
153
|
+
### `UnhandledError`
|
|
65
154
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
155
|
+
```ts
|
|
156
|
+
interface UnhandledError {
|
|
157
|
+
readonly cause: Cause.Cause<unknown>;
|
|
158
|
+
readonly region: string;
|
|
159
|
+
readonly root: RootHandle;
|
|
160
|
+
}
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
An error that escaped every user-level handler and reached the app's
|
|
164
|
+
unhandled-error hub, published on `WeftApp.errors(app)`. `region` identifies where
|
|
165
|
+
in the render tree the error escaped. Sources (one entry per failing occurrence):
|
|
166
|
+
|
|
167
|
+
- a rendered stream subscription failing or dying with **no enclosing `Boundary`**
|
|
168
|
+
(region e.g. `"attribute:class"`, `"child:stream-3"`),
|
|
169
|
+
- an error escaping the **outermost** `Boundary` recovery (region
|
|
170
|
+
`"boundary:outermost"`),
|
|
171
|
+
- an event-handler effect **failing or dying** (region `"event:onClick"`) — reported
|
|
172
|
+
in development and production alike; there is no `NODE_ENV`-gated swallow.
|
|
173
|
+
|
|
174
|
+
Interrupt-only causes are never published. Errors handled by a nested `Boundary`
|
|
175
|
+
never reach the hub.
|
|
176
|
+
|
|
177
|
+
### `MountError`
|
|
178
|
+
|
|
179
|
+
```ts
|
|
180
|
+
type MountError = UnsupportedNodeTypeError | StreamSubscriptionError | RenderError;
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
Errors `mount` can fail with, beyond the app layer's own error channel `E`.
|
|
184
|
+
|
|
185
|
+
### `HydrateError`
|
|
186
|
+
|
|
187
|
+
```ts
|
|
188
|
+
type HydrateError = MountError | HydrationMismatchError;
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
Everything `MountError` covers, plus `HydrationMismatchError` when the server DOM
|
|
192
|
+
and the node tree diverge.
|
|
193
|
+
|
|
194
|
+
### `TypeId`
|
|
195
|
+
|
|
196
|
+
```ts
|
|
197
|
+
const TypeId: unique symbol; // Symbol.for("@weftui/dom/WeftApp")
|
|
198
|
+
```
|
|
71
199
|
|
|
72
|
-
The
|
|
73
|
-
|
|
74
|
-
render, while streams and handlers keep running in the background. If `mount` or
|
|
75
|
-
`hydrate` runs inside a region that supplies an ambient `Scope.Scope` (e.g. under
|
|
76
|
-
`Effect.scoped`), `unmount` is auto-registered on that scope as a finalizer, so the
|
|
77
|
-
mount tears down when the scope closes; with no ambient scope, behavior is
|
|
78
|
-
unchanged and `unmount` must be called explicitly. `mountScoped`/`hydrateScoped`
|
|
79
|
-
register the same finalizer explicitly, so the typed variant does not silently
|
|
80
|
-
depend on this auto-registration.
|
|
200
|
+
The unique brand for `WeftApp` values. Internal identity marker; rarely referenced
|
|
201
|
+
directly.
|
|
81
202
|
|
|
82
203
|
## `@weftui/dom/server`
|
|
83
204
|
|
|
@@ -136,7 +257,7 @@ Re-exports the renderer error types:
|
|
|
136
257
|
## See also
|
|
137
258
|
|
|
138
259
|
- [Render on the Server](https://weftui.dev/docs/how-to/render-on-the-server) — a narrative walkthrough of the server/client split
|
|
139
|
-
- [Provide Services](https://weftui.dev/docs/how-to/provide-services) — recipes for
|
|
260
|
+
- [Provide Services](https://weftui.dev/docs/how-to/provide-services) — recipes for app layers, scoped layers, and binding an app's lifetime to an external scope
|
|
140
261
|
- [The Rendering Model](https://weftui.dev/docs/explanation/rendering-model) — hydrate-in-place and why there is no virtual DOM
|
|
141
|
-
- [Services and Context](https://weftui.dev/docs/explanation/services-and-context
|
|
262
|
+
- [Services and Context](https://weftui.dev/docs/explanation/services-and-context) — how services flow from the app layer to every root
|
|
142
263
|
- [`@weftui/core` reference](https://weftui.dev/docs/reference/core) · [`@weftui/router` reference](https://weftui.dev/docs/reference/router)
|
package/docs/reference/router.md
CHANGED
|
@@ -203,7 +203,7 @@ RouterApp<E, R>(def: RouterDef<E, R>): Node<Exclude<E, RouterNotFound>, R | Rout
|
|
|
203
203
|
|
|
204
204
|
The universal router root node — render this on both server and client. Wraps the nested outlet in the router's internal not-found boundary, so a `RouterNotFound` raised by a page renders the configured `notFound` page in place. Server dispatch runs through `HttpApiBuilder`: a page-raised `RouterNotFound` and a no-match surface their 404 through the platform request pipeline.
|
|
205
205
|
|
|
206
|
-
`RouterApp` requires `Router` in its environment — provide it via `RouterLive` (client) or `RouterServer` (server), not `Effect.provide` at the node level
|
|
206
|
+
`RouterApp` requires `Router` in its environment — provide it via `RouterLive` (client) or `RouterServer` (server), not `Effect.provide` at the node level — services under `WeftApp` come exclusively from the app's layer, not ambient `Effect.provide`.
|
|
207
207
|
|
|
208
208
|
### `outletNode` (a.k.a. `RouterOutlet`)
|
|
209
209
|
|
|
@@ -224,11 +224,11 @@ 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/unstable/rpc`. Pass the same merged `group` the server wires into [`RouterServer`](#routerserver). **Scoped** — it must outlive the mount
|
|
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; give it to `WeftApp.make` and the app runtime owns its lifetime (built lazily on first mount, released at `WeftApp.dispose`):
|
|
228
228
|
|
|
229
229
|
```typescript
|
|
230
|
-
const
|
|
231
|
-
void
|
|
230
|
+
const app = WeftApp.make(RouterLive(App, { rpc: { group: StockRpcs } }));
|
|
231
|
+
void Effect.runPromise(WeftApp.hydrate(app, RouterApp(App), root));
|
|
232
232
|
```
|
|
233
233
|
|
|
234
234
|
### Programmatic navigation
|
|
@@ -323,7 +323,7 @@ These power the runtime and are exported for tooling/tests; most apps never touc
|
|
|
323
323
|
| Export | Description |
|
|
324
324
|
| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
325
325
|
| `compile(def)` | Walks a tree into flat `CompiledLeaf`s with merged path/query schemas and layout chains. |
|
|
326
|
-
| `buildHttpApi(leaves)` | Builds the authoritative `HttpApi` (one `"pages"` group, a GET endpoint per leaf with `
|
|
326
|
+
| `buildHttpApi(leaves)` | Builds the authoritative `HttpApi` (one `"pages"` group, a GET endpoint per leaf with `params`/`query` schemas + 404). Called by `Router.router`; the result is `def.httpApi`. |
|
|
327
327
|
| `leafRegistry` | `WeakMap<RouteNode, CompiledLeaf>` read by `href` to resolve a leaf's pattern/schemas. |
|
|
328
328
|
| `match(compiled, url)` | Resolves a URL to a `RouteMatch` (`Matched` with decoded `path`/`query`, or `NotFound`). |
|
|
329
329
|
| `compileMatchers(compiled)` | Precompiles per-leaf regex matchers. |
|
|
@@ -14,9 +14,11 @@ We assume you know [Effect](https://effect.website/docs/getting-started/introduc
|
|
|
14
14
|
## Install
|
|
15
15
|
|
|
16
16
|
```bash
|
|
17
|
-
npm install @weftui/core @weftui/dom effect
|
|
17
|
+
npm install @weftui/core @weftui/dom effect@beta
|
|
18
18
|
```
|
|
19
19
|
|
|
20
|
+
Weft tracks Effect 4's beta line. This release is built and tested against `effect@4.0.0-beta.98`; the peer range accepts newer 4.0 betas, which may contain upstream breaking changes.
|
|
21
|
+
|
|
20
22
|
`@weftui/core` gives you the element builders and combinators; `@weftui/dom` renders them (its `./client` entry mounts in the browser). `effect` is the peer everything is built on.
|
|
21
23
|
|
|
22
24
|
## Build a component
|
|
@@ -25,14 +27,15 @@ A **component is a plain function you call** — there is no JSX and no `<Compon
|
|
|
25
27
|
|
|
26
28
|
```typescript
|
|
27
29
|
import { h } from "@weftui/core";
|
|
28
|
-
import {
|
|
30
|
+
import { WeftApp } from "@weftui/dom/client";
|
|
29
31
|
import { Effect } from "effect";
|
|
30
32
|
|
|
31
33
|
function App() {
|
|
32
34
|
return h.div({ class: "app" }, [h.h1("Hello, Weft"), h.p("A minimal app.")]);
|
|
33
35
|
}
|
|
34
36
|
|
|
35
|
-
|
|
37
|
+
const app = WeftApp.make();
|
|
38
|
+
void Effect.runPromise(WeftApp.mount(app, App(), document.getElementById("root")!));
|
|
36
39
|
```
|
|
37
40
|
|
|
38
41
|
The `h` namespace is the entry point: every property (`h.div`, `h.h1`, `h.button`, …) is a builder for that HTML tag. A builder takes optional props and children, and returns a `Node`.
|
|
@@ -40,7 +43,7 @@ The `h` namespace is the entry point: every property (`h.div`, `h.h1`, `h.button
|
|
|
40
43
|
## What just happened
|
|
41
44
|
|
|
42
45
|
- `App()` returns a **`Node<never, never>`** — the two type parameters are the error channel (`E`) and the requirement channel (`R`), both `never` here because this component neither fails nor needs a service. As your app grows, those channels accumulate what it can fail with and what it depends on. That is the whole point of Weft's types — see [The Rendering Model](https://weftui.dev/docs/explanation/rendering-model).
|
|
43
|
-
- `mount(node, target)` renders the node into `target`, building real DOM and starting any reactive streams. It returns
|
|
46
|
+
- `WeftApp.make()` creates a Weft app — synchronously, with no layer to build yet. `WeftApp.mount(app, node, target)` renders the node into `target`, building real DOM and starting any reactive streams. It returns `Effect<RootHandle, …>` with `R = never`, so a bare `Effect.runPromise` runs it — no `Effect.provide` needed. You will give `WeftApp.make` a `Layer` once components need services — see [Services and Async](https://weftui.dev/docs/tutorial/03-services-and-async).
|
|
44
47
|
- The component function runs **once**. Nothing here re-runs on a timer or a state change — because there is no state yet. That comes next.
|
|
45
48
|
|
|
46
49
|
## Next
|
|
@@ -15,7 +15,7 @@ Use Effect's `SubscriptionRef` for component-local state. `SubscriptionRef.chang
|
|
|
15
15
|
|
|
16
16
|
```typescript
|
|
17
17
|
import { h } from "@weftui/core";
|
|
18
|
-
import {
|
|
18
|
+
import { WeftApp } from "@weftui/dom/client";
|
|
19
19
|
import { Effect, SubscriptionRef } from "effect";
|
|
20
20
|
|
|
21
21
|
const Counter = () =>
|
|
@@ -29,7 +29,8 @@ const Counter = () =>
|
|
|
29
29
|
]);
|
|
30
30
|
});
|
|
31
31
|
|
|
32
|
-
|
|
32
|
+
const app = WeftApp.make();
|
|
33
|
+
void Effect.runPromise(WeftApp.mount(app, Counter(), document.getElementById("root")!));
|
|
33
34
|
```
|
|
34
35
|
|
|
35
36
|
`Effect.gen` lets you `yield*` the `SubscriptionRef` to set up state **before** building the tree. Because a `Node` is an `Effect`, the component body is an ordinary generator — no hooks, no dependency arrays.
|
|
@@ -11,12 +11,12 @@ description: Give handlers access to services from the environment, and render a
|
|
|
11
11
|
|
|
12
12
|
## Handlers that use services
|
|
13
13
|
|
|
14
|
-
An event handler can **return an Effect**, and that Effect runs in the
|
|
14
|
+
An event handler can **return an Effect**, and that Effect runs in the app's environment — so it can read any service the app's layer provides:
|
|
15
15
|
|
|
16
16
|
```typescript
|
|
17
17
|
import { h } from "@weftui/core";
|
|
18
|
-
import {
|
|
19
|
-
import { Context, Effect, Layer
|
|
18
|
+
import { WeftApp } from "@weftui/dom/client";
|
|
19
|
+
import { Context, Effect, Layer } from "effect";
|
|
20
20
|
|
|
21
21
|
class Logger extends Context.Service<Logger, { log: (message: string) => Effect.Effect<void> }>()(
|
|
22
22
|
"Logger",
|
|
@@ -38,13 +38,12 @@ const LogButton = () =>
|
|
|
38
38
|
"Log",
|
|
39
39
|
);
|
|
40
40
|
|
|
41
|
-
//
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
);
|
|
41
|
+
// Give the app the layer — every handler in every root can now read Logger.
|
|
42
|
+
const app = WeftApp.make(LoggerLive);
|
|
43
|
+
void Effect.runPromise(WeftApp.mount(app, LogButton(), document.getElementById("root")!));
|
|
45
44
|
```
|
|
46
45
|
|
|
47
|
-
`Logger` entered the tree's requirement channel the moment `LogButton` read it, and you discharged it **once**,
|
|
46
|
+
`Logger` entered the tree's requirement channel the moment `LogButton` read it, and you discharged it **once**, by passing `LoggerLive` to `WeftApp.make`. Provide too little and it is a compile error — the type of `app` (and so of `WeftApp.mount(app, LogButton(), …)`) names exactly which service is missing. Services come exclusively from the app's layer: an `Effect.provide` wrapped around the `mount` call does **not** reach components or handlers. This is Weft's entire dependency-injection story — it is just Effect's. The deeper treatment is [Services and Context](https://weftui.dev/docs/explanation/services-and-context).
|
|
48
47
|
|
|
49
48
|
## Async loading states
|
|
50
49
|
|
|
@@ -52,7 +51,7 @@ A component can return a **`Stream<Node>`** to show different content over time.
|
|
|
52
51
|
|
|
53
52
|
```typescript
|
|
54
53
|
import { h } from "@weftui/core";
|
|
55
|
-
import {
|
|
54
|
+
import { WeftApp } from "@weftui/dom/client";
|
|
56
55
|
import { Effect, Stream } from "effect";
|
|
57
56
|
|
|
58
57
|
const AsyncGreeting = ({ name }: { name: string }) =>
|
|
@@ -66,7 +65,10 @@ const AsyncGreeting = ({ name }: { name: string }) =>
|
|
|
66
65
|
),
|
|
67
66
|
);
|
|
68
67
|
|
|
69
|
-
|
|
68
|
+
const app = WeftApp.make();
|
|
69
|
+
void Effect.runPromise(
|
|
70
|
+
WeftApp.mount(app, AsyncGreeting({ name: "World" }), document.getElementById("root")!),
|
|
71
|
+
);
|
|
70
72
|
```
|
|
71
73
|
|
|
72
74
|
The stream emits the loading node first, then the resolved node — the renderer swaps the DOM in place on the second emission. This is the raw mechanism; for coordinating _several_ async regions with a single fallback, reach for [`Boundary.suspend`](https://weftui.dev/docs/explanation/boundaries-and-suspense), which you will meet in the next step.
|
|
@@ -42,11 +42,12 @@ export const render = () => Effect.runPromise(renderToStringHydratable(App()));
|
|
|
42
42
|
|
|
43
43
|
```typescript
|
|
44
44
|
// client entry
|
|
45
|
-
import {
|
|
45
|
+
import { WeftApp } from "@weftui/dom/client";
|
|
46
46
|
import { Effect } from "effect";
|
|
47
47
|
import { App } from "./app";
|
|
48
48
|
|
|
49
|
-
|
|
49
|
+
const app = WeftApp.make();
|
|
50
|
+
void Effect.runPromise(WeftApp.hydrate(app, App(), document.getElementById("root")!));
|
|
50
51
|
```
|
|
51
52
|
|
|
52
53
|
The same side-effect-free `App` is imported by both entries. For server-resolved data that replays into the client without a second request, `Boundary.rpc` extends this model — resolve an rpc on the server, serialize its result into the HTML, replay it on hydrate, then keep the region live for refetch.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@weftui/router",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.28.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,20 +44,21 @@
|
|
|
44
44
|
"access": "public"
|
|
45
45
|
},
|
|
46
46
|
"dependencies": {
|
|
47
|
-
"@weftui/core": "0.
|
|
48
|
-
"@weftui/dom": "0.
|
|
47
|
+
"@weftui/core": "0.28.0",
|
|
48
|
+
"@weftui/dom": "0.28.0"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
51
|
"@types/jsdom": "^28.0.3",
|
|
52
|
-
"@types/node": "^
|
|
53
|
-
"effect": "4.0.0-beta.
|
|
52
|
+
"@types/node": "^26.1.1",
|
|
53
|
+
"effect": "4.0.0-beta.98",
|
|
54
54
|
"jsdom": "^29.1.1",
|
|
55
|
-
"
|
|
56
|
-
"
|
|
57
|
-
"
|
|
58
|
-
"vite
|
|
55
|
+
"tstyche": "^7.2.2",
|
|
56
|
+
"tsx": "^4.23.1",
|
|
57
|
+
"typescript": "^7.0.2",
|
|
58
|
+
"vite": "npm:@voidzero-dev/vite-plus-core@0.2.5",
|
|
59
|
+
"vite-plus": "0.2.5"
|
|
59
60
|
},
|
|
60
61
|
"peerDependencies": {
|
|
61
|
-
"effect": "4.0.0-beta.
|
|
62
|
+
"effect": ">=4.0.0-beta.98 <4.0.0"
|
|
62
63
|
}
|
|
63
64
|
}
|