@weftui/router 0.30.0 → 0.31.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.
@@ -7,37 +7,48 @@ description: renderToString / renderToStringHydratable / streaming variants, hyd
7
7
 
8
8
  # Server-Side Rendering
9
9
 
10
- Weft renders on the server and **hydrates** on the client. The server produces HTML plus inline data, and the browser adopts that existing DOM in place rather than re-creating it.
10
+ Weft renders on the server and hydrates on the client: the server produces HTML, and the browser adopts that existing DOM in place instead of re-creating it.
11
11
 
12
12
  [`Boundary.rpc`](https://weftui.dev/docs/reference/core#boundaryrpc) extends this to **rpc-backed server data**: resolve an rpc on the server, serialize its result into the HTML, and replay it on the client without a second request. The region then stays live for refetch.
13
13
 
14
- ## The two halves
15
-
16
- - **Server**: `@weftui/dom/server` renders an app node to an HTML string (or stream). The _hydratable_ variants also emit the inline data each reactive region and `Boundary.rpc` needs to resume on the client.
17
- - **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.
18
-
19
14
  ```typescript
20
- // server entry
15
+ // entry-server.ts
16
+ import { AppRpcClientTag } from "@weftui/core";
21
17
  import { renderToStringHydratable } from "@weftui/dom/server";
22
- import { Effect } from "effect";
18
+ import { Effect, Layer } from "effect";
23
19
  import { App } from "./app";
24
20
 
25
- export const render = (): Promise<string> => Effect.runPromise(renderToStringHydratable(App()));
21
+ // Every SSR render fn requires an AppRpcClientTag in context unconditionally,
22
+ // even when the tree has no Boundary.rpc. Discharge it with a no-op when unused.
23
+ const NoRpc = Layer.succeed(AppRpcClientTag, {
24
+ call: () => Effect.die(new Error("no rpc in this app")),
25
+ });
26
+
27
+ export const render = (): Promise<string> =>
28
+ Effect.runPromise(Effect.provide(renderToStringHydratable(App()), NoRpc));
26
29
  ```
27
30
 
28
31
  ```typescript
29
- // client entry
32
+ // entry-client.ts
30
33
  import { WeftApp } from "@weftui/dom/client";
31
34
  import { Effect } from "effect";
32
35
  import { App } from "./app";
33
36
 
34
- const root = document.getElementById("root")!;
37
+ const root = document.getElementById("root");
38
+ if (root === null) {
39
+ throw new Error("#root not found");
40
+ }
41
+
35
42
  const app = WeftApp.make();
36
43
  void Effect.runPromise(WeftApp.hydrate(app, App(), root));
37
44
  ```
38
45
 
39
46
  Both entries import the same side-effect-free `App`. Splice the server HTML into your template's outlet, ship it, and let the client entry hydrate it.
40
47
 
48
+ When `App` renders a real `Boundary.rpc`, replace `NoRpc` with the Layer `@weftui/router`'s `RouterServer` provides (see [Loading server data with `Boundary.rpc`](#loading-server-data-with-boundaryrpc) below). `NoRpc` only exists to satisfy the type when the tree has no rpc boundaries to resolve.
49
+
50
+ ## The four renderers
51
+
41
52
  `@weftui/dom/server` exports four renderers:
42
53
 
43
54
  | | String | Stream |
@@ -45,7 +56,83 @@ Both entries import the same side-effect-free `App`. Splice the server HTML into
45
56
  | **Plain** (no JS / no hydration) | `renderToString` | `renderToStream` |
46
57
  | **Hydratable** (emits inline payloads) | `renderToStringHydratable` | `renderToStreamHydratable` |
47
58
 
48
- Use a hydratable renderer whenever the client will call `hydrate`. The plain renderers produce complete, JS-free HTML with no payload scripts.
59
+ ```typescript
60
+ import {
61
+ renderToStream,
62
+ renderToStreamHydratable,
63
+ renderToString,
64
+ renderToStringHydratable,
65
+ } from "@weftui/dom/server";
66
+ ```
67
+
68
+ Use a hydratable renderer whenever the client will call `hydrate`. The plain renderers produce complete, JS-free HTML with no payload scripts, so use them for pages that never run client JS.
69
+
70
+ All four share the same requirement channel: `Effect.Effect<string, Error, AppRpcClientTag>` for the string variants, `Stream.Stream<string, Error, AppRpcClientTag>` for the stream variants.
71
+
72
+ ## Full example
73
+
74
+ The complete file set for an isomorphic counter: a shared `app.ts`, an SSR entry, and a hydrating client entry. This is the same shape `examples/ssr-hydration` runs; see that example for the dev server (`server.ts`) and `index.html` that bridge `entry-server.ts` into a request.
75
+
76
+ ```typescript
77
+ // src/app.ts
78
+ /**
79
+ * Shared isomorphic App. Rendered to hydratable HTML on the server and
80
+ * hydrated in the browser from that same markup. The `SubscriptionRef`
81
+ * region is flash-free: the server's first emission matches the client's
82
+ * first emission, so `hydrate` adopts the existing node in place.
83
+ */
84
+ import { h } from "@weftui/core";
85
+ import { Effect, SubscriptionRef } from "effect";
86
+
87
+ export const App = (props: { initialValue: number }) =>
88
+ Effect.gen(function* () {
89
+ const count = yield* SubscriptionRef.make(props.initialValue);
90
+ const increment = () => SubscriptionRef.update(count, (n) => n + 1);
91
+ const decrement = () => SubscriptionRef.update(count, (n) => n - 1);
92
+
93
+ return yield* h.div([
94
+ h.h1("SSR + Hydration"),
95
+ h.div({ class: "count" }, [SubscriptionRef.changes(count)]),
96
+ h.button({ type: "button", onclick: () => decrement() }, "-"),
97
+ h.button({ type: "button", onclick: () => increment() }, "+"),
98
+ ]);
99
+ });
100
+ ```
101
+
102
+ ```typescript
103
+ // src/entry-server.ts
104
+ import { AppRpcClientTag } from "@weftui/core";
105
+ import { renderToStringHydratable } from "@weftui/dom/server";
106
+ import { Effect, Layer } from "effect";
107
+ import { App } from "./app";
108
+
109
+ // This app has no Boundary.rpc, but the SSR render fns require an
110
+ // AppRpcClientTag in context unconditionally, so discharge it with a no-op.
111
+ const NoRpc = Layer.succeed(AppRpcClientTag, {
112
+ call: () => Effect.die(new Error("no rpc in this example")),
113
+ });
114
+
115
+ /** Renders the app to a hydratable HTML string. */
116
+ export const render = (): Promise<string> =>
117
+ Effect.runPromise(Effect.provide(renderToStringHydratable(App({ initialValue: 3 })), NoRpc));
118
+ ```
119
+
120
+ ```typescript
121
+ // src/entry-client.ts
122
+ import { WeftApp } from "@weftui/dom/client";
123
+ import { Effect } from "effect";
124
+ import { App } from "./app";
125
+
126
+ const root = document.getElementById("root");
127
+ if (root === null) {
128
+ throw new Error("#root not found");
129
+ }
130
+
131
+ const app = WeftApp.make();
132
+ void Effect.runPromise(WeftApp.hydrate(app, App({ initialValue: 3 }), root));
133
+ ```
134
+
135
+ `renderToStringHydratable` wraps the `SubscriptionRef.changes(count)` region in `<!-- stream-start-N -->` / `<!-- stream-end-N -->` markers around its first emission (`3`). `WeftApp.hydrate` locates that region via the markers, adopts the existing DOM node, and resumes the stream in place: no flash, no re-render.
49
136
 
50
137
  ## Loading server data with `Boundary.rpc`
51
138
 
@@ -74,7 +161,18 @@ const StockPanel = (productId: number) =>
74
161
 
75
162
  Under SSR the server resolves the rpc in-process, `successSchema`-encodes the result inline as `<script type="application/json">`, and renders in place; `hydrate` reads that payload positionally, seeds the `Resource`, and adopts the DOM **without re-calling the rpc** (replay, never retry). The full model lives in one place, the [RPC Data Boundaries guide](https://weftui.dev/docs/how-to/load-data-with-rpc): the contract/handler split, router wiring, the four lifecycles, the `Resource` handle, and typed-failure replay. This page does not repeat it.
76
163
 
77
- > **Note.** `Boundary.rpc` resolves through the ambient [`AppRpcClientTag`](https://weftui.dev/docs/reference/core#apprpcclienttag) seam, which `@weftui/router` provides on both sides. In a router-less mount there is no seam, so the boundary resolves to a descriptive "needs router/rpc" error (not a defect).
164
+ `Boundary.rpc` resolves through the ambient [`AppRpcClientTag`](https://weftui.dev/docs/reference/core#apprpcclienttag) seam, which `@weftui/router` provides on both sides:
165
+
166
+ ```typescript
167
+ // client (RouterLive): network rpc client over the shared group
168
+ const app = WeftApp.make(RouterLive(App, { rpc: { group: StockRpcs } }));
169
+
170
+ // server (RouterServer): same group, plus its handler Layer
171
+ const rpc = { group: StockRpcs, handlers: StockLive };
172
+ export const handler = RouterServer.toWebHandler(App, { document: documentShell, rpc });
173
+ ```
174
+
175
+ In a router-less mount (like the `NoRpc` layer above) there is no seam, so the boundary resolves to a descriptive "needs router/rpc" error, not a defect.
78
176
 
79
177
  ## When to use
80
178
 
@@ -7,11 +7,9 @@ description: Render a pending indicator (e.g. a top progress bar) during a defer
7
7
 
8
8
  # Show Navigation Progress
9
9
 
10
- **Goal:** show a progress indicator while a [lazy route](https://weftui.dev/docs/how-to/split-routes-lazily) resolves its chunk and data, so a slow network is visible instead of feeling frozen.
10
+ **Goal:** show a progress indicator while a navigation resolves a [lazy route](https://weftui.dev/docs/how-to/split-routes-lazily)'s chunk or a leaf's own async data, so a slow network is visible instead of feeling frozen.
11
11
 
12
- When you navigate to a route, the router is **deferred-commit**. It resolves the target branch's chunk (if the component is `Router.lazy`) **and the matched leaf's own component effect**, including any data the leaf awaits in its body. Only then does it swap the URL, keeping the previous page mounted for the whole window.
13
-
14
- That resolve window is exposed as a reactive signal, [`Router.navigating`](https://weftui.dev/docs/reference/router#routernavigating), that you read to render pending UI.
12
+ Client navigation is **deferred-commit**: the router resolves the target branch's chunk (if `Router.lazy`) and the matched leaf's own component effect before swapping the URL, so the previous page stays mounted for the whole window. Read [`Router.navigatingStream`](https://weftui.dev/docs/reference/router#routernavigating) in a persistent layout to render pending UI for that window:
15
13
 
16
14
  ```typescript
17
15
  import { Component, h, Subscribable } from "@weftui/core";
@@ -34,11 +32,9 @@ const Shell = Component.gen(function* () {
34
32
  });
35
33
  ```
36
34
 
37
- Thread the signal into a persistent layout (the outermost `Shell` is ideal, since it never re-renders across navigations), and style the pending class however you like: a top bar, a cursor change, a dimmed outlet.
38
-
39
- ## The signal
35
+ Put this in the outermost `Shell`, since it never re-renders across navigations, and style `.is-navigating` however you like: a top bar, a cursor change, a dimmed outlet.
40
36
 
41
- `NavState` is a two-state machine:
37
+ ## The `NavState` signal
42
38
 
43
39
  ```typescript
44
40
  type NavState = { readonly _tag: "Idle" } | { readonly _tag: "Navigating"; readonly to: string };
@@ -46,10 +42,120 @@ type NavState = { readonly _tag: "Idle" } | { readonly _tag: "Navigating"; reado
46
42
 
47
43
  Read it two ways, mirroring `Router.params` / `Router.paramsStream`:
48
44
 
49
- - `Router.navigating`: the `Subscribable<NavState>` on the `Router` service.
50
- - `Router.navigatingStream`: an `Effect` resolving that `Subscribable`, for use in a `Component.gen` body (as above).
45
+ - `Router.navigatingStream`: an `Effect` resolving the `Subscribable<NavState>`, for a `Component.gen` body (as above).
46
+ - `Router.navigating`: the raw `Subscribable<NavState>` on the `Router` service, for reading outside a component.
47
+
48
+ `Navigating`'s `to` field is the target URL, if you want to label _where_ the app is going.
49
+
50
+ ## Full example
51
+
52
+ A client-only app with an instant `Home` route and a `Reports` route whose component awaits its own data. That `yield*` blocks the commit, so the Shell's progress bar shows for the resolve window. This is the whole file set, copy/paste runnable in a `vite` + `@weftui/router` project.
53
+
54
+ ```html
55
+ <!-- index.html -->
56
+ <!doctype html>
57
+ <html lang="en">
58
+ <head>
59
+ <meta charset="UTF-8" />
60
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
61
+ <title>Weft navigation progress demo</title>
62
+ <style>
63
+ #nav-progress {
64
+ position: fixed;
65
+ top: 0;
66
+ left: 0;
67
+ height: 3px;
68
+ width: 0;
69
+ background: #06c;
70
+ opacity: 0;
71
+ }
72
+ #nav-progress.is-navigating {
73
+ width: 100%;
74
+ opacity: 1;
75
+ transition:
76
+ width 600ms ease-out,
77
+ opacity 150ms;
78
+ }
79
+ </style>
80
+ </head>
81
+ <body>
82
+ <div id="root"></div>
83
+ <script type="module" src="/src/main.ts"></script>
84
+ </body>
85
+ </html>
86
+ ```
87
+
88
+ ```typescript
89
+ // src/app.ts
90
+ /**
91
+ * Client-only demo: a Shell layout with an instant Home route and a Reports
92
+ * route whose component awaits its own data before rendering. That `yield*`
93
+ * makes the navigation deferred-commit, so the Shell's `Router.navigatingStream`
94
+ * reader flips to "Navigating" for the resolve window. Side-effect-free (no
95
+ * mount call), so `main.ts` and any test can import `App` directly.
96
+ */
97
+ import { Component, h, Subscribable } from "@weftui/core";
98
+ import { href, Router } from "@weftui/router";
99
+ import { Effect, Stream } from "effect";
100
+
101
+ const homeRoute = Router.route("", {
102
+ component: Component.make(() => h.section({ id: "page" }, [h.h2("Home")])),
103
+ });
104
+
105
+ const reportsRoute = Router.route("reports", {
106
+ component: Component.gen(function* () {
107
+ // Simulates fetching a report: this `yield*` blocks the commit, so
108
+ // `Router.navigatingStream` reports `Navigating` for its whole duration.
109
+ yield* Effect.sleep("600 millis");
110
+ return yield* h.section({ id: "page" }, [h.h2("Quarterly report")]);
111
+ }),
112
+ });
113
+
114
+ const Shell = Component.gen(function* () {
115
+ const outlet = yield* Router.Outlet;
116
+ const nav = yield* Router.navigatingStream;
117
+ return yield* h.div({ id: "app" }, [
118
+ h.div({
119
+ id: "nav-progress",
120
+ "aria-hidden": "true",
121
+ class: Stream.map(Subscribable.changes(nav), (s) =>
122
+ s._tag === "Navigating" ? "nav-progress is-navigating" : "nav-progress",
123
+ ),
124
+ }),
125
+ h.nav([
126
+ h.a({ href: href(homeRoute) }, "Home"),
127
+ " · ",
128
+ h.a({ href: href(reportsRoute) }, "Reports"),
129
+ ]),
130
+ h.main([outlet]),
131
+ ]);
132
+ });
133
+
134
+ export const App = Router.router(Router.layout({ component: Shell }, [homeRoute, reportsRoute]), {
135
+ notFound: () => h.section({ id: "page" }, [h.h2("404: page not found")]),
136
+ });
137
+ ```
138
+
139
+ ```typescript
140
+ // src/main.ts
141
+ /**
142
+ * Browser entry: mounts the navigation progress demo into `#root`.
143
+ */
144
+ import { WeftApp } from "@weftui/dom/client";
145
+ import { RouterApp, RouterLive } from "@weftui/router/client";
146
+ import { Effect } from "effect";
147
+ import { App } from "./app";
148
+
149
+ const root = document.getElementById("root");
150
+ if (root === null) {
151
+ throw new Error("#root not found");
152
+ }
153
+
154
+ const app = WeftApp.make(RouterLive(App));
155
+ void Effect.runPromise(WeftApp.mount(app, RouterApp(App), root));
156
+ ```
51
157
 
52
- The `to` field on `Navigating` is the target URL, if you want to label _where_ the app is going.
158
+ Click "Reports" and `#nav-progress` gains `is-navigating` for 600ms while `Home` stays mounted, then swaps atomically to "Quarterly report" with the bar reset to idle.
53
159
 
54
160
  ## Behavior to expect
55
161
 
@@ -58,7 +164,7 @@ The `to` field on `Navigating` is the target URL, if you want to label _where_ t
58
164
  - **Back/forward.** `popstate` into a route with async work also resolves before committing, so the indicator shows for browser back/forward too.
59
165
  - **Failure resets it.** A rejected chunk load or a failing leaf pre-run (a typed error such as `notFound()`, or a defect) resets `navigating` to `Idle` (it never sticks on), then surfaces through normal error/defect handling.
60
166
  - **Server renders `Idle`.** Server render is buffered, so `navigating` is a client-only concern; the server supplies a constant `Idle` so the same `Shell` type-checks and renders on both sides.
61
- - **No built-in anti-flash delay.** The signal flips as soon as an async window opens, so a borderline-fast navigation can flash the indicator briefly. If you want to only show it past a threshold, delay the reveal in CSS rather than in the signal (e.g. `transition-delay: 200ms` on `.is-navigating`), so genuinely fast navigations never flicker.
167
+ - **No built-in anti-flash delay.** The signal flips as soon as an async window opens, so a borderline-fast navigation can flash the indicator briefly. Delay the reveal in CSS instead (e.g. `transition-delay: 200ms` on `.is-navigating`), so genuinely fast navigations never flicker.
62
168
 
63
169
  ## See also
64
170
 
@@ -9,7 +9,7 @@ description: Code-split a route's component into its own chunk with Router.lazy,
9
9
 
10
10
  **Goal:** keep a heavy page's render code (and its dependencies) out of the initial bundle, loading it only when its route is actually rendered.
11
11
 
12
- Wrap the route's `component` in [`Router.lazy`](https://weftui.dev/docs/reference/router#routerlazy). The route **descriptor** (its segment and param schemas) stays eager, so the matcher, `href`, and the server's dispatch API still see it statically. Only the component body is split into its own chunk.
12
+ Wrap the route's `component` in [`Router.lazy`](https://weftui.dev/docs/reference/router#routerlazy):
13
13
 
14
14
  ```typescript
15
15
  import { Router } from "@weftui/router";
@@ -21,7 +21,7 @@ Router.route("docs/:category/:slug", {
21
21
  });
22
22
  ```
23
23
 
24
- The chunk loads on the server during render and on the client on navigation. Only the **matched branch's** chunks are ever fetched.
24
+ The route's **descriptor** (segment, `path`/`query` schemas) stays eager, so the matcher, `href`, and the server's dispatch API still see it statically. Only the component body is split into its own chunk, fetched on the server during render and on the client on navigation. Only the **matched branch's** chunks are ever loaded.
25
25
 
26
26
  `E`/`R` are preserved: a lazy route has the exact same channels as the same component declared eagerly. An unmet service requirement is still a compile error at `Router.router(...)`.
27
27
 
@@ -48,14 +48,117 @@ A descriptor file that still `import`s the impl statically gains nothing: the bu
48
48
 
49
49
  - **Flash-free hydration.** On a directly-loaded lazy route, the client re-invokes the same slot, awaits the chunk, and adopts the server DOM in place. The first production matches, so nothing is mutated.
50
50
  - **Blank-free navigation.** Client navigation is **deferred-commit**: the router resolves the target branch's chunk **and the matched leaf's own component effect** _before_ committing the URL. The previous page stays mounted through the fetch and any data the leaf awaits, and the swap is a single tick. See [Show Navigation Progress](https://weftui.dev/docs/how-to/show-navigation-progress) for the `Router.navigating` signal this exposes.
51
- - **Synchronous revisits.** `Router.lazy` memoizes its load per slot, so a second visit to a loaded route commits immediately.
51
+ - **Synchronous revisits.** `Router.lazy` memoizes its load per slot. The first render triggers the `import()`; every later render (including a revisit after navigating away) reuses the resolved module.
52
+
53
+ ```typescript
54
+ // One slot, created once. Its loader Promise resolves on first render and is
55
+ // reused on every later render, including back-navigation to this route.
56
+ const page = Router.lazy(() => import("./doc-page").then((m) => m.DocPage));
57
+
58
+ Router.route("docs/intro", { component: page });
59
+ ```
52
60
 
53
61
  ## Edge cases
54
62
 
55
63
  - **Lazy layouts.** A `Router.layout({ component: Router.lazy(...) })` splits too. Each lazy node in the matched branch is awaited; nodes outside it never load.
64
+
65
+ ```typescript
66
+ Router.layout(
67
+ { component: Router.lazy(() => import("./admin-shell").then((m) => m.AdminShell)) },
68
+ [settingsRoute, usersRoute],
69
+ );
70
+ ```
71
+
56
72
  - **Chunk-load failure is a defect.** If the `import()` rejects (offline, or a stale client requesting a chunk a new deploy removed), it dies as a defect and surfaces through normal defect handling. It never hangs or silently 404s. The rejection is memoized, so the route keeps failing until a reload (the deploy-skew case).
57
73
  - **Not a lazy _subtree_.** Only the component is lazy. You cannot defer a whole `RouteNode` behind an `import()`; the matcher needs every leaf's segment and param schema before anything loads.
58
74
 
75
+ ## Complete example
76
+
77
+ A client-only app with two routes: `Home` (eager) and `Lazy` (split into its own chunk). This is the whole file set, copy/paste runnable in a `vite` + `@weftui/router` project.
78
+
79
+ ```html
80
+ <!-- index.html -->
81
+ <!doctype html>
82
+ <html lang="en">
83
+ <head>
84
+ <meta charset="UTF-8" />
85
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
86
+ <title>Weft lazy routing demo</title>
87
+ </head>
88
+ <body>
89
+ <div id="root"></div>
90
+ <script type="module" src="/src/main.ts"></script>
91
+ </body>
92
+ </html>
93
+ ```
94
+
95
+ ```typescript
96
+ // src/lazy-page.ts
97
+ /**
98
+ * The lazily-loaded page body. Kept in its own module so the dynamic
99
+ * `import()` in `app.ts` is a real code-split point, not just a wrapper
100
+ * around a statically-imported value.
101
+ */
102
+ import { Component, h } from "@weftui/core";
103
+
104
+ export const LazyPage = Component.make(() =>
105
+ h.section({ id: "page" }, [h.h2("Lazy page"), h.p("Loaded on demand.")]),
106
+ );
107
+ ```
108
+
109
+ ```typescript
110
+ // src/app.ts
111
+ /**
112
+ * Client-only lazy-routing demo: a Home route declared eagerly, and a Lazy
113
+ * route whose component is code-split via `Router.lazy`. Side-effect-free (no
114
+ * mount call), so `main.ts` and any test can import `App` directly.
115
+ */
116
+ import { Component, h } from "@weftui/core";
117
+ import { href, Router } from "@weftui/router";
118
+
119
+ const homeRoute = Router.route("", {
120
+ component: Component.make(() => h.section({ id: "page" }, [h.h2("Home")])),
121
+ });
122
+
123
+ const lazyRoute = Router.route("lazy", {
124
+ component: Router.lazy(() => import("./lazy-page").then((m) => m.LazyPage)),
125
+ });
126
+
127
+ const Shell = Component.gen(function* () {
128
+ const outlet = yield* Router.Outlet;
129
+ return yield* h.div({ id: "app" }, [
130
+ h.nav([h.a({ href: href(homeRoute) }, "Home"), " · ", h.a({ href: href(lazyRoute) }, "Lazy")]),
131
+ h.main([outlet]),
132
+ ]);
133
+ });
134
+
135
+ export const App = Router.router(Router.layout({ component: Shell }, [homeRoute, lazyRoute]), {
136
+ notFound: () => h.section({ id: "page" }, [h.h2("404: page not found")]),
137
+ });
138
+ ```
139
+
140
+ ```typescript
141
+ // src/main.ts
142
+ /**
143
+ * Browser entry: mounts the lazy-routing demo into `#root`. No server render
144
+ * to hydrate, so this uses `WeftApp.mount`, not `hydrate`.
145
+ */
146
+ import { WeftApp } from "@weftui/dom/client";
147
+ import { RouterApp, RouterLive } from "@weftui/router/client";
148
+ import { Effect } from "effect";
149
+ import { App } from "./app";
150
+
151
+ const root = document.getElementById("root");
152
+ if (root === null) {
153
+ throw new Error("#root not found");
154
+ }
155
+
156
+ const app = WeftApp.make(RouterLive(App));
157
+ void Effect.runPromise(WeftApp.mount(app, RouterApp(App), root));
158
+ ```
159
+
160
+ Load `/`, open the network panel, then click "Lazy": `lazy-page`'s chunk fetches only on that click, not on initial load. Click "Home" then "Lazy" again and no second fetch fires: the slot's memo serves the resolved component.
161
+
59
162
  ## See also
60
163
 
61
164
  - [`Router.lazy` API reference](https://weftui.dev/docs/reference/router#routerlazy)
@@ -7,9 +7,9 @@ description: Drive inline styles from streams (a single property, or a whole sty
7
7
 
8
8
  # Style Reactively
9
9
 
10
- **Goal:** animate or react to state in an element's inline style without re-rendering. Drive a single CSS property, or a whole style object, from a stream.
10
+ **Goal:** animate or react to state in an element's inline style without re-rendering.
11
11
 
12
- The `style` prop accepts the [`Source`](https://weftui.dev/docs/explanation/reactive-primitives) vocabulary at any level. A property value can be a stream, and you can spread a stream of style objects. CSS `transition` composes naturally, because the renderer mutates the existing node in place.
12
+ The `style` prop accepts the [`Source`](https://weftui.dev/docs/explanation/reactive-primitives) vocabulary at either level: a single property's value, or the whole object. CSS `transition` composes naturally, because a stream emission patches the existing DOM node in place.
13
13
 
14
14
  ```typescript
15
15
  import { h } from "@weftui/core";
@@ -34,27 +34,155 @@ const AnimatedHue = () => {
34
34
  };
35
35
  ```
36
36
 
37
- ## Three modes
37
+ ## Per-property streams
38
38
 
39
- 1. **A single property as a stream.** As above: one key's value is a `Stream`, the others are static strings. Each stream property is subscribed independently.
40
- 2. **A static object.** An ordinary `style: { backgroundColor: "#667eea" }` with no streams; nothing updates.
41
- 3. **A whole style object as a stream.** Spread a stream that emits complete style objects, merged with static props:
39
+ Give one or more keys a `Stream` value and leave the rest as plain strings. Each reactive key subscribes independently, so two properties can animate off unrelated sources on the same element:
42
40
 
43
41
  ```typescript
44
- const pulse = Stream.make(1, 0.5).pipe(
45
- Stream.schedule(Schedule.spaced("800 millis")),
46
- Stream.forever,
42
+ const size = Stream.iterate(100, (s) => (s >= 150 ? 100 : s + 10)).pipe(
43
+ Stream.schedule(Schedule.spaced("200 millis")),
47
44
  );
48
45
 
49
- h.div({ style: { ...pulse, transition: "opacity 0.4s ease-in-out" } }, "Pulse");
46
+ h.div({
47
+ style: {
48
+ width: Stream.map(size, (s) => `${s}px`),
49
+ height: Stream.map(size, (s) => `${s}px`),
50
+ transition: "width 0.2s, height 0.2s",
51
+ },
52
+ });
53
+ ```
54
+
55
+ A key without a stream stays static: an ordinary `style: { backgroundColor: "#667eea" }` never updates.
56
+
57
+ ## Whole-object streams
58
+
59
+ Give `style` itself a `Stream` that emits complete style objects. Each emission replaces every property on the element, so it's the right shape for changes that move several properties together:
60
+
61
+ ```typescript
62
+ const theme = Stream.make(
63
+ { backgroundColor: "#667eea", transform: "scale(1)" },
64
+ { backgroundColor: "#764ba2", transform: "scale(1.1)" },
65
+ ).pipe(Stream.schedule(Schedule.spaced("1 second")), Stream.forever);
66
+
67
+ h.div({
68
+ // fold the static `transition` into every emitted object: the renderer
69
+ // clears and resets all style properties on each emission, so a sibling
70
+ // key can't survive alongside it. Spreading `theme` into a style object
71
+ // literal doesn't work either, since that copies the Stream's own fields,
72
+ // not the values it emits.
73
+ style: Stream.map(theme, (s) => ({ ...s, transition: "all 0.3s ease" })),
74
+ });
75
+ ```
76
+
77
+ ## Reactive classes
78
+
79
+ `Props.cx` (from `@weftui/dom`) builds a class string from static names and `{ className: condition }` records, where a condition may be a stream:
80
+
81
+ ```typescript
82
+ import { h } from "@weftui/core";
83
+ import { Props } from "@weftui/dom";
84
+
85
+ h.div({ class: Props.cx("demo-box", { "demo-box--active": isActiveStream }) });
50
86
  ```
51
87
 
88
+ See [Compose Behavior and Markup](https://weftui.dev/docs/how-to/compose-behavior-and-markup) for merging `class` across two prop bags.
89
+
52
90
  ## Notes
53
91
 
54
92
  - **Property names are camelCase** (`backgroundColor`, `boxShadow`), the same keys as the DOM `style` object.
55
- - **CSS transitions just work.** A stream emission patches the DOM node directly (no re-render), so the browser applies the `transition` as it would for any style mutation.
56
- - **Pace with `Schedule`.** The idiom for time-based style animation: `Stream.iterate`/`Stream.make` paced by `Stream.schedule(Schedule.spaced(…))` and looped with `Stream.forever`. Combine with any Effect timing you like.
57
- - **Classes have a reactive builder too.** `Props.cx` builds a class string from strings, falsy values, nested arrays, and `{ className: condition }` records, where a condition may be a stream. Merging two bags that both carry `class` concatenates them. See [Compose Behavior and Markup](https://weftui.dev/docs/how-to/compose-behavior-and-markup).
93
+ - **CSS transitions just work.** A stream emission patches the DOM node directly (no re-render), so the browser applies `transition` as it would for any style mutation.
94
+ - **Pace with `Schedule`.** `Stream.iterate`/`Stream.make`, paced by `Stream.schedule(Schedule.spaced(…))` and looped with `Stream.forever`, is the idiom for time-based style animation:
95
+
96
+ ```typescript
97
+ Stream.iterate(0, (n) => n + 1).pipe(
98
+ Stream.schedule(Schedule.spaced("100 millis")),
99
+ Stream.forever,
100
+ );
101
+ ```
102
+
103
+ Combine with any Effect timing you like.
104
+
105
+ ## Complete example
106
+
107
+ A page with one per-property demo (hue cycling) and one whole-object demo (theme switch), mounted into an empty `#root`. This is the whole file set, copy/paste runnable in a `vite` + `@weftui/core` project.
108
+
109
+ ```html
110
+ <!-- index.html -->
111
+ <!doctype html>
112
+ <html lang="en">
113
+ <head>
114
+ <meta charset="UTF-8" />
115
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
116
+ <title>Weft reactive styles demo</title>
117
+ </head>
118
+ <body>
119
+ <div id="root"></div>
120
+ <script type="module" src="/src/main.ts"></script>
121
+ </body>
122
+ </html>
123
+ ```
124
+
125
+ ```typescript
126
+ // src/app.ts
127
+ /**
128
+ * Reactive styles demo: a per-property hue cycle and a whole-object theme
129
+ * switch. Side-effect-free (no mount call), so `main.ts` and any test can
130
+ * import `App` directly.
131
+ */
132
+ import { h } from "@weftui/core";
133
+ import { Schedule, Stream } from "effect";
134
+
135
+ const AnimatedHue = () => {
136
+ const hue = Stream.iterate(0, (h) => (h + 2) % 360).pipe(
137
+ Stream.schedule(Schedule.spaced("50 millis")),
138
+ );
139
+
140
+ return h.div(
141
+ {
142
+ style: {
143
+ backgroundColor: Stream.map(hue, (h) => `hsl(${h}, 70%, 60%)`),
144
+ transition: "background-color 0.05s",
145
+ padding: "1rem",
146
+ },
147
+ },
148
+ "Hue",
149
+ );
150
+ };
151
+
152
+ const ThemeSwitch = () => {
153
+ const theme = Stream.make(
154
+ { backgroundColor: "#667eea", transform: "scale(1)" },
155
+ { backgroundColor: "#764ba2", transform: "scale(1.1)" },
156
+ ).pipe(Stream.schedule(Schedule.spaced("1 second")), Stream.forever);
157
+
158
+ return h.div(
159
+ {
160
+ style: Stream.map(theme, (s) => ({ ...s, transition: "all 0.3s ease", padding: "1rem" })),
161
+ },
162
+ "Theme",
163
+ );
164
+ };
165
+
166
+ export const App = () => h.div([AnimatedHue(), ThemeSwitch()]);
167
+ ```
168
+
169
+ ```typescript
170
+ // src/main.ts
171
+ /**
172
+ * Browser entry: mounts the reactive styles demo into `#root`.
173
+ */
174
+ import { WeftApp } from "@weftui/dom/client";
175
+ import { Effect } from "effect";
176
+ import { App } from "./app";
177
+
178
+ const root = document.getElementById("root");
179
+ if (root === null) {
180
+ throw new Error("#root not found");
181
+ }
182
+
183
+ const app = WeftApp.make();
184
+ void Effect.runPromise(WeftApp.mount(app, App(), root));
185
+ ```
58
186
 
59
187
  ## See also
60
188