@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.
- package/docs/explanation/boundaries-and-suspense.md +36 -6
- 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 +2 -8
- package/docs/how-to/author-components.md +69 -103
- 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 +167 -14
- package/docs/how-to/provide-services.md +84 -1
- package/docs/how-to/render-keyed-lists.md +116 -10
- package/docs/how-to/render-on-the-server.md +111 -13
- package/docs/how-to/show-navigation-progress.md +118 -12
- 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 +20 -0
- package/docs/reference/dom.md +43 -0
- 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 +3 -3
|
@@ -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
|
|
|
@@ -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,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)
|
|
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
15
|
import { Component, h, Subscribable } from "@weftui/core";
|
|
@@ -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
|
|
|
@@ -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)
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
##
|
|
37
|
+
## Per-property streams
|
|
38
38
|
|
|
39
|
-
|
|
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
|
|
45
|
-
Stream.schedule(Schedule.spaced("
|
|
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({
|
|
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
|
|
56
|
-
- **Pace with `Schedule`.**
|
|
57
|
-
|
|
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
|
|