@weftui/core 0.29.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.
- package/dist/{index-4cTlhojA.d.ts → index-B-dPfhKZ.d.ts} +38 -20
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/types/index.d.ts +1 -1
- package/docs/explanation/boundaries-and-suspense.md +38 -8
- package/docs/explanation/combinator-api.md +17 -11
- package/docs/explanation/reactive-primitives.md +40 -13
- package/docs/explanation/rendering-model.md +14 -2
- package/docs/how-to/add-routing.md +299 -28
- package/docs/how-to/author-components.md +71 -105
- package/docs/how-to/compose-behavior-and-markup.md +141 -54
- package/docs/how-to/handle-forms.md +142 -15
- package/docs/how-to/load-async-data.md +110 -6
- package/docs/how-to/load-data-with-rpc.md +171 -18
- package/docs/how-to/provide-services.md +84 -1
- package/docs/how-to/render-keyed-lists.md +120 -14
- package/docs/how-to/render-on-the-server.md +113 -15
- package/docs/how-to/show-navigation-progress.md +120 -14
- package/docs/how-to/split-routes-lazily.md +106 -3
- package/docs/how-to/style-reactively.md +141 -13
- package/docs/how-to/use-element-refs.md +126 -8
- package/docs/reference/core.md +22 -2
- package/docs/reference/dom.md +43 -0
- package/docs/reference/router.md +2 -2
- package/docs/tutorial/01-your-first-app.md +43 -13
- package/docs/tutorial/02-reactivity.md +29 -30
- package/docs/tutorial/03-services-and-async.md +89 -38
- package/docs/tutorial/04-errors-and-server.md +41 -25
- package/package.json +1 -1
|
@@ -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
|
|
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,44 +7,150 @@ 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
|
|
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
|
-
import { h, List } from "@weftui/core";
|
|
13
|
+
import { h, List, Subscribable } from "@weftui/core";
|
|
16
14
|
import { Stream } from "effect";
|
|
17
15
|
|
|
18
16
|
declare const rows: Subscribable.Subscribable<ReadonlyArray<{ id: number; name: string }>>;
|
|
19
17
|
|
|
20
18
|
h.ul([
|
|
21
19
|
List.each(
|
|
22
|
-
{ of:
|
|
20
|
+
{ of: Subscribable.changes(rows), by: (row) => row.id }, // key by stable identity
|
|
23
21
|
(row) => h.li(row.name),
|
|
24
22
|
),
|
|
25
23
|
]);
|
|
26
24
|
```
|
|
27
25
|
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
30
29
|
|
|
31
|
-
|
|
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
|
+
```
|
|
32
36
|
|
|
33
|
-
|
|
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).
|
|
34
39
|
|
|
35
|
-
|
|
40
|
+
## Why not `map`
|
|
41
|
+
|
|
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
|
+
```
|
|
46
|
+
|
|
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
|
-
|
|
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
|
-
List.each({ of:
|
|
43
|
-
h.li([h.span([Stream.map(row.status
|
|
54
|
+
List.each({ of: Subscribable.changes(rows), by: (row) => row.id }, (row) =>
|
|
55
|
+
h.li([h.span([Stream.map(Subscribable.changes(row.status), (s) => s)])]),
|
|
44
56
|
);
|
|
45
57
|
```
|
|
46
58
|
|
|
47
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
|
@@ -54,7 +141,7 @@ Use a hydratable renderer whenever the client will call `hydrate`. The plain ren
|
|
|
54
141
|
It follows the same server/client split: the rpc **contract** (pure Schema) is shared, while its **handler** lives in a server-only Layer the client never imports.
|
|
55
142
|
|
|
56
143
|
```typescript
|
|
57
|
-
import { Boundary, h } from "@weftui/core";
|
|
144
|
+
import { Boundary, h, Subscribable } from "@weftui/core";
|
|
58
145
|
import { Stream } from "effect";
|
|
59
146
|
import { GetStock } from "./data/inventory";
|
|
60
147
|
|
|
@@ -65,7 +152,7 @@ const StockPanel = (productId: number) =>
|
|
|
65
152
|
(resource) =>
|
|
66
153
|
h.p([
|
|
67
154
|
"in stock: ",
|
|
68
|
-
h.span([Stream.map(resource.value
|
|
155
|
+
h.span([Stream.map(Subscribable.changes(resource.value), (stock) => String(stock.units))]),
|
|
69
156
|
h.button({ type: "button", onclick: () => resource.refetch }, "Refresh"),
|
|
70
157
|
]),
|
|
71
158
|
{ fallback: h.p("loading stock…") }, // shown only on a client-first SPA mount
|
|
@@ -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
|
-
|
|
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,14 +7,12 @@ 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)
|
|
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
|
-
|
|
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
|
-
import { Component, h } from "@weftui/core";
|
|
15
|
+
import { Component, h, Subscribable } from "@weftui/core";
|
|
18
16
|
import { Router } from "@weftui/router";
|
|
19
17
|
import { Stream } from "effect";
|
|
20
18
|
|
|
@@ -25,7 +23,7 @@ const Shell = Component.gen(function* () {
|
|
|
25
23
|
h.div({
|
|
26
24
|
id: "nav-progress",
|
|
27
25
|
"aria-hidden": "true",
|
|
28
|
-
class: Stream.map(
|
|
26
|
+
class: Stream.map(Subscribable.changes(nav), (s) =>
|
|
29
27
|
s._tag === "Navigating" ? "nav-progress is-navigating" : "nav-progress",
|
|
30
28
|
),
|
|
31
29
|
}),
|
|
@@ -34,11 +32,9 @@ const Shell = Component.gen(function* () {
|
|
|
34
32
|
});
|
|
35
33
|
```
|
|
36
34
|
|
|
37
|
-
|
|
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`
|
|
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.
|
|
50
|
-
- `Router.
|
|
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
|
-
|
|
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.
|
|
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
|
|