@weftui/core 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.
@@ -11,7 +11,7 @@ description: "Provide plain and scoped Layers to a WeftApp: app layers for the c
11
11
 
12
12
  ## Recipe 1: app layers
13
13
 
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.
14
+ Pass the layer to `WeftApp.make`: the common case, and it needs nothing else. The layer builds lazily on first mount; every component, event handler, and stream subscription in every root mounted from `app` can read it.
15
15
 
16
16
  ```typescript
17
17
  import { WeftApp } from "@weftui/dom/client";
@@ -25,6 +25,20 @@ const app = WeftApp.make(ThemeServiceLive);
25
25
  void Effect.runPromise(WeftApp.mount(app, App(), root));
26
26
  ```
27
27
 
28
+ A component reads the service the same way anywhere else in Effect: `yield* Service`.
29
+
30
+ ```typescript
31
+ import { h } from "@weftui/core";
32
+ import { Effect } from "effect";
33
+ import { ThemeService } from "./theme-service";
34
+
35
+ export const App = () =>
36
+ Effect.gen(function* () {
37
+ const theme = yield* ThemeService;
38
+ return yield* h.div({ class: `app app--${theme.mode}` }, [h.p(`Theme: ${theme.mode}`)]);
39
+ });
40
+ ```
41
+
28
42
  ## Recipe 2: scoped layers just work
29
43
 
30
44
  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.
@@ -85,9 +99,78 @@ const acquireApp = Effect.acquireRelease(
85
99
 
86
100
  `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).
87
101
 
102
+ ## Complete example
103
+
104
+ Recipe 1 end to end: a `ThemeService` defined with `Context.Service`, provided through `WeftApp.make`, and read by `App` with `yield* Service`. This is the whole file set, copy/paste runnable in a `vite` project.
105
+
106
+ ```html
107
+ <!-- index.html -->
108
+ <!doctype html>
109
+ <html lang="en">
110
+ <head>
111
+ <meta charset="UTF-8" />
112
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
113
+ <title>Provide services demo</title>
114
+ </head>
115
+ <body>
116
+ <div id="root"></div>
117
+ <script type="module" src="/src/main.ts"></script>
118
+ </body>
119
+ </html>
120
+ ```
121
+
122
+ ```typescript
123
+ // src/theme-service.ts
124
+ /** The active theme, provided app-wide by `ThemeServiceLive`. */
125
+ import { Context, Layer } from "effect";
126
+
127
+ export class ThemeService extends Context.Service<
128
+ ThemeService,
129
+ { readonly mode: "light" | "dark" }
130
+ >()("ThemeService") {}
131
+
132
+ export const ThemeServiceLive = Layer.succeed(ThemeService, { mode: "dark" });
133
+ ```
134
+
135
+ ```typescript
136
+ // src/app.ts
137
+ /**
138
+ * Reads `ThemeService` from the app layer and renders the active mode.
139
+ * Side-effect-free (no mount call), so `main.ts` and any test can import `App`.
140
+ */
141
+ import { h } from "@weftui/core";
142
+ import { Effect } from "effect";
143
+ import { ThemeService } from "./theme-service";
144
+
145
+ export const App = () =>
146
+ Effect.gen(function* () {
147
+ const theme = yield* ThemeService;
148
+ return yield* h.div({ class: `app app--${theme.mode}` }, [
149
+ h.h1("Provide Services demo"),
150
+ h.p(`Theme: ${theme.mode}`),
151
+ ]);
152
+ });
153
+ ```
154
+
155
+ ```typescript
156
+ // src/main.ts
157
+ /** Browser entry: mounts `App` with `ThemeServiceLive` provided through the app layer. */
158
+ import { WeftApp } from "@weftui/dom/client";
159
+ import { Effect } from "effect";
160
+ import { App } from "./app";
161
+ import { ThemeServiceLive } from "./theme-service";
162
+
163
+ const root = document.getElementById("root")!;
164
+
165
+ const app = WeftApp.make(ThemeServiceLive);
166
+ void Effect.runPromise(WeftApp.mount(app, App(), root));
167
+ ```
168
+
88
169
  ## Anti-pattern: `Effect.provide` around the mount call
89
170
 
90
171
  ```typescript
172
+ import { Effect, pipe } from "effect";
173
+
91
174
  // ❌ does nothing useful: WeftApp.mount's R is always `never`, and services
92
175
  // come exclusively from the app layer: a wrapped Effect.provide never
93
176
  // reaches components, handlers, or stream subscriptions
@@ -7,9 +7,7 @@ description: Render a reactive collection with List.each so reordering, insertin
7
7
 
8
8
  # Render Keyed Lists
9
9
 
10
- **Goal:** render a list whose items reorder, insert, or remove over time, without rebuilding the whole region (which would lose focus, scroll, and input state in the surviving rows).
11
-
12
- Use [`List.each`](https://weftui.dev/docs/reference/core#listeach), the keyed-list combinator. It renders each item **once per key** and reconciles across emissions. A reorder _moves_ existing DOM nodes, an insert adds one, a remove drops one, and untouched rows are left entirely alone.
10
+ **Goal:** render a list that reorders, inserts, or removes items over time, without rebuilding the whole region and losing focus, scroll, or input state in the surviving rows.
13
11
 
14
12
  ```typescript
15
13
  import { h, List, Subscribable } from "@weftui/core";
@@ -25,18 +23,32 @@ h.ul([
25
23
  ]);
26
24
  ```
27
25
 
28
- - **`of`** is the list source: any `Stream`, `Effect`, or `Subscribable` of an `Iterable`. Each emission is materialized to an array to fix order, then reconciled by key.
29
- - **`by`** projects each item to its reconciliation key, compared via Effect's `Equal`/`Hash`. Omit it and the item itself is the key (structural for `Data`, by reference otherwise).
26
+ [`List.each`](https://weftui.dev/docs/reference/core#listeach) renders each item **once per key** and reconciles across emissions. A reorder moves existing DOM nodes, an insert adds one, a remove drops one, and untouched rows are left alone.
27
+
28
+ ## Options
29
+
30
+ ```typescript
31
+ interface List.Options<S, K> {
32
+ readonly of: S; // Iterable<T>, or an Effect/Stream/Subscribable of one
33
+ readonly by?: (item: ItemOf<S>, index: number) => K; // reconciliation key
34
+ }
35
+ ```
36
+
37
+ - **`of`**: the list source. Each emission is materialized to an array to fix order, then reconciled by key.
38
+ - **`by`**: projects each item to its reconciliation key, compared via Effect's `Equal`/`Hash`. Omit it and the item itself is the key (structural for `Data`, by reference otherwise).
30
39
 
31
- ## Why not `map`?
40
+ ## Why not `map`
32
41
 
33
- Mapping items by hand (`Stream.map(Subscribable.changes(rows), (rs) => rs.map(r => h.li(r.name)))`) produces a **new children array on every emission**. The renderer then rebuilds the whole region: every row's DOM node is recreated even if only one item moved.
42
+ ```typescript
43
+ // Rebuilds every row on every emission: a new children array each time.
44
+ Stream.map(Subscribable.changes(rows), (rs) => rs.map((r) => h.li(r.name)));
45
+ ```
34
46
 
35
- `List.each` reconciles by key instead, so DOM identity (and the focus/scroll/typed-input state attached to it) survives across updates.
47
+ The renderer diffs children by position, so a new array means every row's DOM node is recreated, even the ones that didn't move. `List.each` reconciles by key instead, so DOM identity (and the focus/scroll/typed-input state attached to it) survives across updates.
36
48
 
37
49
  ## Refresh a row's content
38
50
 
39
- Because `render` runs **exactly once per key**, reconciliation never re-runs it for a kept row, so it never refreshes that row's content on its own. To make a row's content reactive, thread a `Stream` **inside** the row rather than expecting a re-render:
51
+ `render` runs **exactly once per key**, so reconciliation never re-runs it for a kept row. To make a row's content reactive, thread a `Stream` **inside** the row instead of expecting a re-render:
40
52
 
41
53
  ```typescript
42
54
  List.each({ of: Subscribable.changes(rows), by: (row) => row.id }, (row) =>
@@ -44,7 +56,101 @@ List.each({ of: Subscribable.changes(rows), by: (row) => row.id }, (row) =>
44
56
  );
45
57
  ```
46
58
 
47
- > **⚠️ Index-key footgun.** Keying by index (`by: (_, i) => i`) reuses rows positionally, so after a reorder each position keeps its old content and you see stale rows. Prefer a stable identity key (`by: (item) => item.id`).
59
+ ## Index-key footgun
60
+
61
+ ```typescript
62
+ // Wrong: reuses rows positionally. After a reorder, each position keeps its
63
+ // old content, so the visible rows are stale.
64
+ List.each({ of: Subscribable.changes(rows), by: (_row, i) => i }, renderRow);
65
+
66
+ // Right: a stable identity key follows the item, not its position.
67
+ List.each({ of: Subscribable.changes(rows), by: (row) => row.id }, renderRow);
68
+ ```
69
+
70
+ ## Complete example
71
+
72
+ A shuffleable row list. Each row starts a per-row tick counter and renders an uncontrolled `<input>`; shuffling moves rows instead of recreating them, so counters keep counting and typed input keeps its value and focus.
73
+
74
+ ```html
75
+ <!-- index.html -->
76
+ <!doctype html>
77
+ <html lang="en">
78
+ <head>
79
+ <meta charset="UTF-8" />
80
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
81
+ <title>Keyed list demo</title>
82
+ </head>
83
+ <body>
84
+ <div id="root"></div>
85
+ <script type="module" src="/src/main.ts"></script>
86
+ </body>
87
+ </html>
88
+ ```
89
+
90
+ ```typescript
91
+ // src/app.ts
92
+ /**
93
+ * Keyed list demo: List.each moves existing rows on shuffle instead of
94
+ * rebuilding them, so each row's own tick counter keeps running and its
95
+ * input keeps focus and value. Side-effect-free (no mount call), so
96
+ * `main.ts` and any test can import `App` directly.
97
+ */
98
+ import { h, List } from "@weftui/core";
99
+ import { Effect, Schedule, Stream, SubscriptionRef } from "effect";
100
+
101
+ interface Row {
102
+ readonly id: number;
103
+ readonly name: string;
104
+ }
105
+
106
+ const renderRow = (row: Row) => {
107
+ // Created once per key: starts a single time and keeps running across
108
+ // every later shuffle of this row.
109
+ const ticks = Stream.iterate(0, (n) => n + 1).pipe(Stream.schedule(Schedule.spaced("1 second")));
110
+
111
+ return h.li({ id: `row-${row.id}` }, [
112
+ h.span(row.name),
113
+ h.input({ placeholder: "type here…" }),
114
+ h.span(["ticks: ", ticks]),
115
+ ]);
116
+ };
117
+
118
+ export const App = () =>
119
+ Effect.gen(function* () {
120
+ const rows = yield* SubscriptionRef.make<ReadonlyArray<Row>>([
121
+ { id: 1, name: "Ada" },
122
+ { id: 2, name: "Babbage" },
123
+ { id: 3, name: "Curie" },
124
+ ]);
125
+
126
+ const shuffle = SubscriptionRef.update(rows, (current) =>
127
+ [...current].sort(() => Math.random() - 0.5),
128
+ );
129
+
130
+ return yield* h.div([
131
+ h.button({ onclick: () => shuffle }, "Shuffle"),
132
+ h.ul([List.each({ of: SubscriptionRef.changes(rows), by: (row) => row.id }, renderRow)]),
133
+ ]);
134
+ });
135
+ ```
136
+
137
+ ```typescript
138
+ // src/main.ts
139
+ /**
140
+ * Browser entry: mounts the keyed list demo into #root.
141
+ */
142
+ import { WeftApp } from "@weftui/dom/client";
143
+ import { Effect } from "effect";
144
+ import { App } from "./app";
145
+
146
+ const root = document.getElementById("root");
147
+ if (root === null) {
148
+ throw new Error("#root not found");
149
+ }
150
+
151
+ const app = WeftApp.make();
152
+ void Effect.runPromise(WeftApp.mount(app, App(), root));
153
+ ```
48
154
 
49
155
  ## See also
50
156
 
@@ -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