@weftui/dom 0.26.1 → 0.26.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +66 -0
- package/docs/explanation/boundaries-and-suspense.md +91 -0
- package/docs/explanation/combinator-api.md +164 -0
- package/docs/explanation/reactive-primitives.md +162 -0
- package/docs/explanation/rendering-model.md +65 -0
- package/docs/explanation/services-and-context.md +91 -0
- package/docs/how-to/add-routing.md +296 -0
- package/docs/how-to/author-components.md +264 -0
- package/docs/how-to/handle-forms.md +76 -0
- package/docs/how-to/load-async-data.md +70 -0
- package/docs/how-to/load-data-with-rpc.md +172 -0
- package/docs/how-to/provide-services.md +124 -0
- package/docs/how-to/render-keyed-lists.md +51 -0
- package/docs/how-to/render-on-the-server.md +86 -0
- package/docs/how-to/show-navigation-progress.md +65 -0
- package/docs/how-to/split-routes-lazily.md +62 -0
- package/docs/how-to/style-reactively.md +63 -0
- package/docs/how-to/use-element-refs.md +63 -0
- package/docs/index.md +59 -0
- package/docs/reference/core.md +496 -0
- package/docs/reference/dom.md +142 -0
- package/docs/reference/router.md +348 -0
- package/docs/tutorial/01-your-first-app.md +48 -0
- package/docs/tutorial/02-reactivity.md +57 -0
- package/docs/tutorial/03-services-and-async.md +77 -0
- package/docs/tutorial/04-errors-and-server.md +61 -0
- package/package.json +19 -5
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Load Async Data
|
|
3
|
+
order: 9
|
|
4
|
+
section: how-to
|
|
5
|
+
description: Show a loading state then resolved content with Stream.concat, and turn a failed fetch into a fallback node with Effect.catchAll — all client-side.
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Load Async Data
|
|
9
|
+
|
|
10
|
+
**Goal:** render a loading placeholder, then the fetched content, and a fallback if the fetch fails — for data that loads **on the client**.
|
|
11
|
+
|
|
12
|
+
Return a `Stream<Node>` that emits the loading node first and the resolved node second, sequenced with `Stream.concat`. Handle failure inside the effect with `Effect.catchAll`, which maps the error to a fallback node.
|
|
13
|
+
|
|
14
|
+
```typescript
|
|
15
|
+
import { h } from "@weftui/core";
|
|
16
|
+
import { Effect, Stream } from "effect";
|
|
17
|
+
|
|
18
|
+
interface User {
|
|
19
|
+
id: number;
|
|
20
|
+
name: string;
|
|
21
|
+
email: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const fetchUser = (id: number): Effect.Effect<User, Error> =>
|
|
25
|
+
Effect.gen(function* () {
|
|
26
|
+
yield* Effect.sleep("1000 millis");
|
|
27
|
+
if (id === 3) return yield* Effect.fail(new Error("User not found"));
|
|
28
|
+
return { id, name: `User ${id}`, email: `user${id}@example.com` };
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const UserCard = ({ id }: { id: number }) =>
|
|
32
|
+
Stream.concat(
|
|
33
|
+
Stream.make(h.div({ class: "loading" }, `Loading user ${id}…`)),
|
|
34
|
+
Stream.fromEffect(
|
|
35
|
+
fetchUser(id).pipe(
|
|
36
|
+
Effect.flatMap((user) => h.div({ class: "user-card" }, [h.h3(user.name), h.p(user.email)])),
|
|
37
|
+
Effect.catchAll((error) => h.div({ class: "error" }, `Error: ${error.message}`)),
|
|
38
|
+
),
|
|
39
|
+
),
|
|
40
|
+
);
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## How it works
|
|
44
|
+
|
|
45
|
+
- **`Stream.concat`** sequences two streams: `Stream.make(loadingNode)` emits once immediately, then `Stream.fromEffect(effect)` emits the resolved node when the effect completes. The renderer swaps the DOM in place on the second emission.
|
|
46
|
+
- **`Effect.flatMap((data) => h.div(...))`** builds the content node from the data — `h.*` returns a `Node`, which is an `Effect`, so it composes directly in the pipeline.
|
|
47
|
+
- **`Effect.catchAll((error) => node)`** converts the error channel into a fallback node, so the stream always yields something renderable. The failure never escapes to the mount.
|
|
48
|
+
- **Parallel loading is automatic:** place several async components as siblings and their fetches run concurrently — no orchestration needed.
|
|
49
|
+
|
|
50
|
+
## When to reach for a boundary instead
|
|
51
|
+
|
|
52
|
+
This is the raw, per-region pattern. When you need **one fallback for several async siblings** (all-or-nothing), use [`Boundary.suspend`](https://weftui.dev/docs/explanation/boundaries-and-suspense). When the data must be resolved on the **server** and replayed on hydrate without a second request, use [`Boundary.rpc`](https://weftui.dev/docs/how-to/load-data-with-rpc) instead — this recipe is purely client-side.
|
|
53
|
+
|
|
54
|
+
## Blocking on navigation vs streaming in place
|
|
55
|
+
|
|
56
|
+
The `Stream.concat` placeholder above lives on a **child** node, so it always streams in after mount — it never delays a navigation commit. If the component above is a route's leaf, moving the `fetchUser` call into the **body** instead changes that:
|
|
57
|
+
|
|
58
|
+
- **Await in the leaf's own body** — commit-blocking. Navigating to the route pre-runs its component effect to completion before the URL commits: the previous page stays mounted for the fetch, and [`Router.navigating`](https://weftui.dev/docs/reference/router#routernavigating) reports the window.
|
|
59
|
+
- **The `Stream.concat` placeholder pattern above, kept as a child** — streaming. The leaf commits immediately and the region fills in place once the effect resolves.
|
|
60
|
+
|
|
61
|
+
Choose blocking for **primary route content the page is meaningless without** (an article body, a user's profile) — the old page stays visible with no blank or skeleton. Choose streaming for **secondary or slow regions** where partial content is still useful (a comments panel, a "related" rail) — the commit isn't held hostage by one slow fetch.
|
|
62
|
+
|
|
63
|
+
See [Show Navigation Progress](https://weftui.dev/docs/how-to/show-navigation-progress) for rendering pending UI during the blocking window, and the router reference's [Blocking vs streaming data](https://weftui.dev/docs/reference/router#blocking-vs-streaming-data) for the full model.
|
|
64
|
+
|
|
65
|
+
## See also
|
|
66
|
+
|
|
67
|
+
- [Boundaries and Suspense](https://weftui.dev/docs/explanation/boundaries-and-suspense) — coordinating multiple async regions
|
|
68
|
+
- [Reactive Primitives](https://weftui.dev/docs/explanation/reactive-primitives) — `Stream`/`Effect` as node-producing children
|
|
69
|
+
- [Show Navigation Progress](https://weftui.dev/docs/how-to/show-navigation-progress) — the pending signal for the commit-blocking window
|
|
70
|
+
- [examples/async-data-loading](https://github.com/stefvw93/weft/tree/main/examples/async-data-loading) — loading states, retry, parallel and sequential loads with error boundaries
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: RPC Data Boundaries
|
|
3
|
+
order: 3
|
|
4
|
+
section: how-to
|
|
5
|
+
description: Boundary.rpc — server-resolved, client-refreshable data; the contract/handler split and the Resource handle's four lifecycles.
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# RPC Data Boundaries
|
|
9
|
+
|
|
10
|
+
`Boundary.rpc` is Weft's primitive for **server-resolved, client-refreshable** data. One [`Rpc`](https://github.com/Effect-TS/effect/tree/main/packages/rpc) from the app's merged `RpcGroup` backs a single render boundary across four lifecycles: server-side render, hydrate-replay, client refetch, and client-first SPA mount. The rpc's `_tag` is the boundary's stable identity and its payload schema the typed input.
|
|
11
|
+
|
|
12
|
+
## Overview
|
|
13
|
+
|
|
14
|
+
A `Boundary.rpc` is a **thin consumer**. It carries an rpc, a payload thunk, and a `render` that receives a reactive [`Resource`](https://weftui.dev/docs/reference/core#resourcea); the renderer resolves the rpc through the ambient [`AppRpcClientTag`](https://weftui.dev/docs/reference/core#apprpcclienttag) seam (provided by `@weftui/router`). The same rpc serves every lifecycle, so SSR-replay, refetch, and client-first mount are one mechanism, not three.
|
|
15
|
+
|
|
16
|
+
```typescript
|
|
17
|
+
import { Boundary, h } from "@weftui/core";
|
|
18
|
+
import { Stream } from "effect";
|
|
19
|
+
import { GetStock } from "./data/inventory";
|
|
20
|
+
|
|
21
|
+
Boundary.rpc(
|
|
22
|
+
GetStock, // the rpc — its _tag + schemas drive the boundary
|
|
23
|
+
() => ({ id: product.id }), // payload thunk — a fresh typed input per call
|
|
24
|
+
(
|
|
25
|
+
resource, // render — receives a reactive Resource, not a bare value
|
|
26
|
+
) =>
|
|
27
|
+
h.p([
|
|
28
|
+
"in stock: ",
|
|
29
|
+
h.span([Stream.map(resource.value.changes, (s) => String(s.units))]),
|
|
30
|
+
h.button({ type: "button", onclick: () => resource.refetch }, "Refresh"),
|
|
31
|
+
]),
|
|
32
|
+
{ fallback: h.p("loading stock…") }, // shown only on a client-first mount
|
|
33
|
+
);
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## The contract / handler split
|
|
37
|
+
|
|
38
|
+
The rpc **contract** (pure Schema) is shared with the client. The rpc **handler** — the only code that touches server-only services — lives in a Layer the client never imports; tree-shaking keeps it and its transitive imports out of the browser bundle. The split is enforced structurally by which files each entry imports, not by a bundler plugin.
|
|
39
|
+
|
|
40
|
+
```typescript
|
|
41
|
+
// data/inventory.ts
|
|
42
|
+
import { Rpc, RpcGroup } from "@effect/rpc";
|
|
43
|
+
import { Context, Effect, Layer, Schema } from "effect";
|
|
44
|
+
|
|
45
|
+
// --- Contract (shareable with the client) ---
|
|
46
|
+
export const Stock = Schema.Struct({ units: Schema.Number });
|
|
47
|
+
export const StockKey = Schema.Struct({ id: Schema.Number });
|
|
48
|
+
|
|
49
|
+
// `_tag` ("GetStock") = the stable boundary id; payload schema = the typed input.
|
|
50
|
+
export const GetStock = Rpc.make("GetStock", { payload: StockKey, success: Stock });
|
|
51
|
+
|
|
52
|
+
// The app's merged RpcGroup — shared by both the client and server router wiring.
|
|
53
|
+
export const StockRpcs = RpcGroup.make(GetStock);
|
|
54
|
+
|
|
55
|
+
// --- Handler (server-only; the client never imports this) ---
|
|
56
|
+
class Inventory extends Context.Tag("Inventory")<
|
|
57
|
+
Inventory,
|
|
58
|
+
{ readonly stockFor: (id: number) => Effect.Effect<typeof Stock.Type> }
|
|
59
|
+
>() {}
|
|
60
|
+
|
|
61
|
+
const InventoryLive = Layer.succeed(Inventory, {
|
|
62
|
+
stockFor: (id) => Effect.succeed({ units: 7 + (id % 5) }),
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// `toLayer` binds each rpc to its handler; `Layer.provide` discharges its deps so R = never.
|
|
66
|
+
export const StockLive = StockRpcs.toLayer({
|
|
67
|
+
GetStock: (payload) => Effect.flatMap(Inventory, (inv) => inv.stockFor(payload.id)),
|
|
68
|
+
}).pipe(Layer.provide(InventoryLive));
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Declare server-only services with [`ServerTag`](https://weftui.dev/docs/reference/core#servertag) (not `Context.Tag`) when they might be referenced from universal code: the brand makes a leak into `render` a compile error at the `hydrate` call site rather than a runtime surprise.
|
|
72
|
+
|
|
73
|
+
## Wiring the router
|
|
74
|
+
|
|
75
|
+
`Boundary.rpc` resolves through the ambient `AppRpcClientTag` seam, which `@weftui/router` provides on both sides. Pass the **merged group** to both, plus the **handler Layer** on the server.
|
|
76
|
+
|
|
77
|
+
```typescript
|
|
78
|
+
// entry-server.ts — in-process client over the handlers + POST /_eui/rpc endpoint
|
|
79
|
+
import { RouterServer } from "@weftui/router/server";
|
|
80
|
+
import { StockLive, StockRpcs } from "./data/inventory";
|
|
81
|
+
|
|
82
|
+
const rpc = { group: StockRpcs, handlers: StockLive } as const;
|
|
83
|
+
|
|
84
|
+
export const handler = RouterServer.toWebHandler(App, { document: documentShell, rpc });
|
|
85
|
+
export const render = (url: string) =>
|
|
86
|
+
Effect.runPromise(RouterServer.render(App, { document: documentShell, rpc, url }));
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
```typescript
|
|
90
|
+
// entry-client.ts — network client posting to /_eui/rpc
|
|
91
|
+
import { RouterApp, RouterLive } from "@weftui/router/client";
|
|
92
|
+
import { StockRpcs } from "./data/inventory";
|
|
93
|
+
|
|
94
|
+
const runtime = ManagedRuntime.make(RouterLive(App, { rpc: { group: StockRpcs } }));
|
|
95
|
+
void runtime.runPromise(hydrate(RouterApp(App), root));
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
- **Server** ([`RouterServer`](https://weftui.dev/docs/reference/router#routerserver)) mounts the handler Layer at `POST /_eui/rpc` (so a client refetch re-runs it on the server) **and** exposes an in-process client over the same handlers for SSR resolution — never a network hop.
|
|
99
|
+
- **Client** ([`RouterLive`](https://weftui.dev/docs/reference/router#routerlive)) provides a network flat rpc client over the merged group, posting to `<origin>/_eui/rpc`.
|
|
100
|
+
|
|
101
|
+
In a **router-less mount** there is no `AppRpcClientTag`, so a `Boundary.rpc` resolves to a typed, descriptive "needs router/rpc" error (not a defect).
|
|
102
|
+
|
|
103
|
+
## The four lifecycles
|
|
104
|
+
|
|
105
|
+
| Lifecycle | Trigger | What happens |
|
|
106
|
+
| ---------------------- | --------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
107
|
+
| **SSR** | server render | Resolve the rpc in-process, `successSchema`-encode the result inline as `<script type="application/json">`, render `render(seededResource)` to HTML. |
|
|
108
|
+
| **Hydrate** | `hydrate` on the client | Read the inline payload at the cursor, `successSchema`-decode it, seed the `Resource`, adopt the DOM. **Never re-calls the rpc** (replay). |
|
|
109
|
+
| **Refetch** | `resource.refetch` | Call the rpc again over `POST /_eui/rpc` (re-runs the handler on the server), patch the subtree in place (stale-on-error). |
|
|
110
|
+
| **Client-first mount** | SPA nav into a boundary with no payload | Render `options.fallback`, fork the rpc call, swap in `render(resource)` once it resolves. |
|
|
111
|
+
|
|
112
|
+
Because the SSR path seeds `value` await-first (it emits the seed immediately), the SSR HTML and the adopted DOM are byte-identical — there is **no fallback flash** on the SSR/hydrate path. `fallback` shows only on a client-first mount.
|
|
113
|
+
|
|
114
|
+
## The `Resource` handle
|
|
115
|
+
|
|
116
|
+
`render` receives a [`Resource<A>`](https://weftui.dev/docs/reference/core#resourcea) (`A` = the rpc's decoded success), not a bare value. After hydrate the region is live:
|
|
117
|
+
|
|
118
|
+
| Field | What it gives you |
|
|
119
|
+
| --------- | ---------------------------------------------------------------------------------------------------- |
|
|
120
|
+
| `value` | A `Subscribable` of the current data — seeded with the SSR payload, updated on a successful refetch. |
|
|
121
|
+
| `refetch` | An `Effect<void>` that re-resolves the rpc with a fresh `payload()` and pushes the new `value`. |
|
|
122
|
+
| `pending` | A `Subscribable<boolean>` — `true` while a refetch is in flight. |
|
|
123
|
+
| `error` | A `Subscribable<Option<unknown>>` — `Some` with the last refetch error (stale-on-error). |
|
|
124
|
+
|
|
125
|
+
```typescript
|
|
126
|
+
(resource) =>
|
|
127
|
+
h.section({ class: "product" }, [
|
|
128
|
+
h.span([Stream.map(resource.value.changes, (s) => String(s.units))]),
|
|
129
|
+
h.span([Stream.map(resource.pending.changes, (p) => (p ? "refreshing…" : ""))]),
|
|
130
|
+
h.button({ type: "button", onclick: () => resource.refetch }, "Refresh stock"),
|
|
131
|
+
]);
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Wire `refetch` to an event with `onclick: () => resource.refetch` — the handler returns the Effect, which the renderer runs in a detached fiber. A failed refetch leaves the previous `value` intact (stale-on-error); it does **not** unmount the subtree or raise into a failure `Boundary`.
|
|
135
|
+
|
|
136
|
+
### Channel algebra
|
|
137
|
+
|
|
138
|
+
```typescript
|
|
139
|
+
Boundary.rpc<R extends Rpc.Any, C extends Node<any, any>>(
|
|
140
|
+
rpc: R,
|
|
141
|
+
payload: () => Rpc.Payload<R>,
|
|
142
|
+
render: (resource: Resource<Rpc.Success<R>>) => C,
|
|
143
|
+
options?: { fallback?: Renderable },
|
|
144
|
+
): Node<Node.Error<C> | Rpc.Error<R>, Node.Context<C>>;
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
- **Error** = `render`'s error union plus the rpc's typed `Rpc.Error<R>` (`never` for an rpc with no `error` schema).
|
|
148
|
+
- **Requirement** = exactly `render`'s `R`, **untouched**. There is no `provide`/`RServer` to discharge (the handler lives in the rpc Layer) and no `Exclude` is applied — a server-only tag leaked into `render` stays in `R`, where `hydrate`'s `AssertNoServerOnly` rejects it.
|
|
149
|
+
|
|
150
|
+
## Typed-failure replay
|
|
151
|
+
|
|
152
|
+
If the rpc declares an `error` schema, a resolved rpc **error** on the SSR pass is `errorSchema`-encoded into an inline failure payload, and the nearest enclosing **failure `Boundary`** renders its fallback. On the client, `hydrate` decodes that payload and re-raises the same error into the same boundary, reproducing the identical fallback DOM — flash-free and without re-resolving the rpc (replay, never retry).
|
|
153
|
+
|
|
154
|
+
```typescript
|
|
155
|
+
Boundary.catchTag({ tag: "OutOfStock", fallback: (e) => h.p({ class: "error" }, e.reason) }, [
|
|
156
|
+
Boundary.rpc(GetStock, () => ({ id: product.id }), (resource) => /* … */),
|
|
157
|
+
]);
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
A transport **defect** (no `Cause.failureOption`), or an rpc with no `error` schema, is **not** replayed; it propagates — a server-side fallback and a client mismatch.
|
|
161
|
+
|
|
162
|
+
## When to use
|
|
163
|
+
|
|
164
|
+
- **`Boundary.rpc`** — data resolved on the server (behind a server-only service, credential, or private network) and rendered into the initial HTML, then **refreshable** on the client over the same rpc.
|
|
165
|
+
- **`Boundary.suspend`** — async data that loads purely on the client; see the [Boundary API](https://weftui.dev/docs/reference/core#boundarysuspend).
|
|
166
|
+
|
|
167
|
+
## See also
|
|
168
|
+
|
|
169
|
+
- [`Boundary.rpc` API reference](https://weftui.dev/docs/reference/core#boundaryrpc) — signature, `Resource`, `RpcOptions`, `AppRpcClientTag`
|
|
170
|
+
- [Server-Side Rendering](https://weftui.dev/docs/how-to/render-on-the-server) — the SSR + hydration model this builds on
|
|
171
|
+
- [Routing](https://weftui.dev/docs/how-to/add-routing) — `@weftui/router`, which provides the `AppRpcClientTag` seam
|
|
172
|
+
- [examples/router-ssr](https://github.com/stefvw93/weft/tree/main/examples/router-ssr) — a runnable shop with an SSR-replayed, refetchable live-stock `Boundary.rpc`
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Provide Services
|
|
3
|
+
order: 12
|
|
4
|
+
section: how-to
|
|
5
|
+
description: Provide plain and scoped Layers to a mounted app — the direct mount for value layers, mountScoped plus a shutdown signal for scoped layers, and a ManagedRuntime as an alternative.
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Provide Services
|
|
9
|
+
|
|
10
|
+
**Goal:** provide a `Layer` to the mounted app so its components can read services with `yield* Service`.
|
|
11
|
+
|
|
12
|
+
Which recipe to reach for depends on whether the layer has anything to release. A plain value layer (`Layer.succeed`, `Layer.effect` with no `acquireRelease`) can be provided directly at the mount — there is nothing to leak. A **scoped** layer (`Layer.scoped`, anything backed by `acquireRelease`) needs the mount to outlive the effect's own resolution — see [Layer lifetime at the mount](https://weftui.dev/docs/explanation/services-and-context#layer-lifetime-at-the-mount) for why.
|
|
13
|
+
|
|
14
|
+
## Recipe 1 — plain value layers with `mount`
|
|
15
|
+
|
|
16
|
+
Provide the layer directly around `mount` and run with `runPromise`. This is the common case and needs nothing else.
|
|
17
|
+
|
|
18
|
+
```typescript
|
|
19
|
+
import { mount } from "@weftui/dom/client";
|
|
20
|
+
import { Effect, pipe } from "effect";
|
|
21
|
+
import { App } from "./app";
|
|
22
|
+
import { ThemeServiceLive } from "./theme-service";
|
|
23
|
+
|
|
24
|
+
const root = document.getElementById("root")!;
|
|
25
|
+
|
|
26
|
+
const program = pipe(mount(App(), root), Effect.provide(ThemeServiceLive));
|
|
27
|
+
|
|
28
|
+
Effect.runPromise(program);
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Recipe 2 — scoped layers with `mountScoped`
|
|
32
|
+
|
|
33
|
+
Provide the scoped layer **outside** a long-lived scoped region, mount inside that region with `mountScoped`, and keep the region open with `Effect.never` or `Deferred.await` on a shutdown signal. Drive the whole thing with `runFork`, not `runPromise` — the program never settles on its own.
|
|
34
|
+
|
|
35
|
+
```typescript
|
|
36
|
+
import { mountScoped } from "@weftui/dom/client";
|
|
37
|
+
import { Deferred, Effect, Fiber, pipe } from "effect";
|
|
38
|
+
import { App } from "./app";
|
|
39
|
+
import { AppLive } from "./app-live";
|
|
40
|
+
|
|
41
|
+
const root = document.getElementById("root")!;
|
|
42
|
+
|
|
43
|
+
const program = pipe(
|
|
44
|
+
Effect.scoped(
|
|
45
|
+
Effect.gen(function* () {
|
|
46
|
+
yield* mountScoped(App(), root);
|
|
47
|
+
yield* Effect.never; // keeps the region — and AppLive — alive
|
|
48
|
+
}),
|
|
49
|
+
),
|
|
50
|
+
Effect.provide(AppLive), // OUTSIDE the scoped region: outlives initial render
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
const fiber = Effect.runFork(program);
|
|
54
|
+
|
|
55
|
+
// later, e.g. on a "sign out" action or test teardown:
|
|
56
|
+
// await Effect.runPromise(Fiber.interrupt(fiber));
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Interrupting `fiber` closes the inner scope first — running `mountScoped`'s finalizer, which calls `unmount` — and only then releases `AppLive`. Swap `Effect.never` for `Deferred.await(shutdown)` when something in the app should be able to request shutdown itself:
|
|
60
|
+
|
|
61
|
+
```typescript
|
|
62
|
+
const shutdown = await Effect.runPromise(Deferred.make<void>());
|
|
63
|
+
|
|
64
|
+
const program = pipe(
|
|
65
|
+
Effect.scoped(
|
|
66
|
+
Effect.gen(function* () {
|
|
67
|
+
yield* mountScoped(App(), root);
|
|
68
|
+
yield* Deferred.await(shutdown); // resolves when shutdown is signalled
|
|
69
|
+
}),
|
|
70
|
+
),
|
|
71
|
+
Effect.provide(AppLive),
|
|
72
|
+
);
|
|
73
|
+
Effect.runFork(program);
|
|
74
|
+
|
|
75
|
+
// elsewhere, to request shutdown:
|
|
76
|
+
// await Effect.runPromise(Deferred.succeed(shutdown, undefined));
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
`hydrateScoped` is the SSR counterpart — same composition, swap `mountScoped` for `hydrateScoped`.
|
|
80
|
+
|
|
81
|
+
## Recipe 3 — `ManagedRuntime` with plain `mount`
|
|
82
|
+
|
|
83
|
+
Build a `ManagedRuntime` from the scoped layer and mount with plain `mount`, running through the runtime instead of `Effect.runPromise` directly. The layer lives until `runtime.dispose()` — an explicit call, rather than a scope closing.
|
|
84
|
+
|
|
85
|
+
```typescript
|
|
86
|
+
import { mount } from "@weftui/dom/client";
|
|
87
|
+
import { ManagedRuntime } from "effect";
|
|
88
|
+
import { App } from "./app";
|
|
89
|
+
import { AppLive } from "./app-live";
|
|
90
|
+
|
|
91
|
+
const root = document.getElementById("root")!;
|
|
92
|
+
const runtime = ManagedRuntime.make(AppLive);
|
|
93
|
+
|
|
94
|
+
await runtime.runPromise(mount(App(), root));
|
|
95
|
+
|
|
96
|
+
// later:
|
|
97
|
+
// await runtime.dispose();
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
This reads closer to Recipe 1 at the call site and is a good fit when the surrounding app (a framework integration, a test harness) already manages a runtime's lifecycle for you.
|
|
101
|
+
|
|
102
|
+
## Anti-patterns
|
|
103
|
+
|
|
104
|
+
Both of these compile and both dispose the scoped layer while the app is still running — the mounted tree keeps its subscriptions and handlers, but they now read from a released service.
|
|
105
|
+
|
|
106
|
+
```typescript
|
|
107
|
+
// ❌ plain mount: the layer releases the instant runPromise settles
|
|
108
|
+
Effect.runPromise(mount(App(), root).pipe(Effect.provide(SomeScopedLayer)));
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
```typescript
|
|
112
|
+
// ❌ mountScoped, but the scoped region closes as soon as the mount effect
|
|
113
|
+
// resolves — nothing keeps it open, so this is no better than plain mount
|
|
114
|
+
Effect.runPromise(mountScoped(App(), root).pipe(Effect.provide(SomeScopedLayer), Effect.scoped));
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
In both cases the tell is the same: nothing in the composition keeps a scope open past the point where the mount Effect itself resolves. Recipe 2's `Effect.never` (or `Deferred.await`) is doing the one piece of work these anti-patterns are missing.
|
|
118
|
+
|
|
119
|
+
## See also
|
|
120
|
+
|
|
121
|
+
- [Layer lifetime at the mount](https://weftui.dev/docs/explanation/services-and-context#layer-lifetime-at-the-mount) — why the mount effect resolving early matters for scoped layers
|
|
122
|
+
- [Services and Context](https://weftui.dev/docs/explanation/services-and-context) — how `R` accumulates and discharges at the mount
|
|
123
|
+
- [`mountScoped` / `hydrateScoped` reference](https://weftui.dev/docs/reference/dom#mountscoped) — signatures and error unions
|
|
124
|
+
- [examples/effect-atom](https://github.com/stefvw93/weft/tree/main/examples/effect-atom) — a real scoped layer (`Registry.layer`) mounted with this composition
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Render Keyed Lists
|
|
3
|
+
order: 5
|
|
4
|
+
section: how-to
|
|
5
|
+
description: Render a reactive collection with List.each so reordering, inserting, and removing items reuses and moves existing DOM instead of rebuilding the region.
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Render Keyed Lists
|
|
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.
|
|
13
|
+
|
|
14
|
+
```typescript
|
|
15
|
+
import { h, List } from "@weftui/core";
|
|
16
|
+
import { Stream } from "effect";
|
|
17
|
+
|
|
18
|
+
declare const rows: Subscribable.Subscribable<ReadonlyArray<{ id: number; name: string }>>;
|
|
19
|
+
|
|
20
|
+
h.ul([
|
|
21
|
+
List.each(
|
|
22
|
+
{ of: rows.changes, by: (row) => row.id }, // key by stable identity
|
|
23
|
+
(row) => h.li(row.name),
|
|
24
|
+
),
|
|
25
|
+
]);
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
- **`of`** — 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).
|
|
30
|
+
|
|
31
|
+
## Why not `map`?
|
|
32
|
+
|
|
33
|
+
Mapping items by hand — `Stream.map(rows.changes, (rs) => rs.map(r => h.li(r.name)))` — produces a **new children array on every emission**, so the renderer rebuilds the whole region: every row's DOM node is recreated even if only one item moved. `List.each` reconciles by key instead, so DOM identity (and the focus/scroll/typed-input state attached to it) survives across updates.
|
|
34
|
+
|
|
35
|
+
## Refresh a row's content
|
|
36
|
+
|
|
37
|
+
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:
|
|
38
|
+
|
|
39
|
+
```typescript
|
|
40
|
+
List.each({ of: rows.changes, by: (row) => row.id }, (row) =>
|
|
41
|
+
h.li([h.span([Stream.map(row.status.changes, (s) => s)])]),
|
|
42
|
+
);
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
> **⚠️ 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`).
|
|
46
|
+
|
|
47
|
+
## See also
|
|
48
|
+
|
|
49
|
+
- [`List.each` API reference](https://weftui.dev/docs/reference/core#listeach) — full signature, `List.Options`, and the descriptor shape
|
|
50
|
+
- [Reactive Primitives](https://weftui.dev/docs/explanation/reactive-primitives) — the stream-shaped sources `of` accepts
|
|
51
|
+
- [examples/keyed-list](https://github.com/stefvw93/weft/tree/main/examples/keyed-list) — a runnable keyed list with reordering and a browser test
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Server-Side Rendering
|
|
3
|
+
order: 2
|
|
4
|
+
section: how-to
|
|
5
|
+
description: renderToString / renderToStringHydratable / streaming variants, hydrate, and the server/client split.
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Server-Side Rendering
|
|
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. [`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, replay it on the client without a second request, and then keep the region live for refetch.
|
|
11
|
+
|
|
12
|
+
## The two halves
|
|
13
|
+
|
|
14
|
+
- **Server** — `@weftui/dom/server` renders an app node to an HTML string (or stream). The _hydratable_ variants additionally emit the inline data each reactive region and `Boundary.rpc` needs to resume on the client.
|
|
15
|
+
- **Client** — `@weftui/dom/client`'s `hydrate` walks the server DOM, adopts it, wires up reactivity and event handlers, and resumes from the inline data. It does **not** re-render from scratch.
|
|
16
|
+
|
|
17
|
+
```typescript
|
|
18
|
+
// server entry
|
|
19
|
+
import { renderToStringHydratable } from "@weftui/dom/server";
|
|
20
|
+
import { Effect } from "effect";
|
|
21
|
+
import { App } from "./app";
|
|
22
|
+
|
|
23
|
+
export const render = (): Promise<string> => Effect.runPromise(renderToStringHydratable(App()));
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
```typescript
|
|
27
|
+
// client entry
|
|
28
|
+
import { hydrate } from "@weftui/dom/client";
|
|
29
|
+
import { Effect } from "effect";
|
|
30
|
+
import { App } from "./app";
|
|
31
|
+
|
|
32
|
+
const root = document.getElementById("root")!;
|
|
33
|
+
void Effect.runPromise(hydrate(App(), root));
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
The same side-effect-free `App` is imported by both entries — splice the server HTML into your template's outlet, ship it, and let the client entry hydrate it.
|
|
37
|
+
|
|
38
|
+
`@weftui/dom/server` exports four renderers:
|
|
39
|
+
|
|
40
|
+
| | String | Stream |
|
|
41
|
+
| -------------------------------------- | -------------------------- | -------------------------- |
|
|
42
|
+
| **Plain** (no JS / no hydration) | `renderToString` | `renderToStream` |
|
|
43
|
+
| **Hydratable** (emits inline payloads) | `renderToStringHydratable` | `renderToStreamHydratable` |
|
|
44
|
+
|
|
45
|
+
Use a hydratable renderer whenever the client will call `hydrate`. The plain renderers produce complete, JS-free HTML with no payload scripts.
|
|
46
|
+
|
|
47
|
+
## Loading server data with `Boundary.rpc`
|
|
48
|
+
|
|
49
|
+
SSR's natural companion is `Boundary.rpc`: it resolves an rpc **on the server**, serializes the result into the same HTML this page produces, and replays it on the client during `hydrate` — no second request, no fallback flash — then keeps the region live for `refetch`. It is the data half of the same server/client split described above: the rpc **contract** (pure Schema) is shared, while its **handler** lives in a server-only Layer the client never imports.
|
|
50
|
+
|
|
51
|
+
```typescript
|
|
52
|
+
import { Boundary, h } from "@weftui/core";
|
|
53
|
+
import { Stream } from "effect";
|
|
54
|
+
import { GetStock } from "./data/inventory";
|
|
55
|
+
|
|
56
|
+
const StockPanel = (productId: number) =>
|
|
57
|
+
Boundary.rpc(
|
|
58
|
+
GetStock,
|
|
59
|
+
() => ({ id: productId }), // a fresh typed payload per call (SSR / refetch / mount)
|
|
60
|
+
(resource) =>
|
|
61
|
+
h.p([
|
|
62
|
+
"in stock: ",
|
|
63
|
+
h.span([Stream.map(resource.value.changes, (stock) => String(stock.units))]),
|
|
64
|
+
h.button({ type: "button", onclick: () => resource.refetch }, "Refresh"),
|
|
65
|
+
]),
|
|
66
|
+
{ fallback: h.p("loading stock…") }, // shown only on a client-first SPA mount
|
|
67
|
+
);
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
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 — the contract/handler split, router wiring, the four lifecycles, the `Resource` handle, and typed-failure replay — lives in one place: the [RPC Data Boundaries guide](https://weftui.dev/docs/how-to/load-data-with-rpc). This page does not repeat it.
|
|
71
|
+
|
|
72
|
+
> **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).
|
|
73
|
+
|
|
74
|
+
## When to use
|
|
75
|
+
|
|
76
|
+
- **`Boundary.rpc`** — data that must be resolved on the server (behind a server-only service, credential, or private network) and rendered into the initial HTML, then **refreshable** on the client (refetch / client-first SPA mount) over the same rpc.
|
|
77
|
+
- **`Boundary.suspend`** — async data that loads on the client (or streams the shell then fills); see the [Boundary API](https://weftui.dev/docs/reference/core#boundarysuspend).
|
|
78
|
+
|
|
79
|
+
## See also
|
|
80
|
+
|
|
81
|
+
- [rpc data boundaries guide](https://weftui.dev/docs/how-to/load-data-with-rpc) — the full `Boundary.rpc` walkthrough: contract/handler split, router wiring, the four lifecycles, and typed-failure replay
|
|
82
|
+
- [Routing](https://weftui.dev/docs/how-to/add-routing) — `@weftui/router` builds on this SSR + hydration model for full-page nested routing
|
|
83
|
+
- [`Boundary.rpc` API reference](https://weftui.dev/docs/reference/core#boundaryrpc)
|
|
84
|
+
- [`ServerTag` API reference](https://weftui.dev/docs/reference/core#servertag)
|
|
85
|
+
- [examples/router-ssr](https://github.com/stefvw93/weft/tree/main/examples/router-ssr) — a runnable shop with an SSR-replayed, refetchable live-stock `Boundary.rpc`
|
|
86
|
+
- [examples/ssr-hydration](https://github.com/stefvw93/weft/tree/main/examples/ssr-hydration) — SSR + hydration without server data loading
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Show Navigation Progress
|
|
3
|
+
order: 7
|
|
4
|
+
section: how-to
|
|
5
|
+
description: Render a pending indicator (e.g. a top progress bar) during a deferred-commit navigation by reading the router's Router.navigating signal.
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Show Navigation Progress
|
|
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.
|
|
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 — _before_ swapping the URL, keeping the previous page mounted for the whole window. 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.
|
|
13
|
+
|
|
14
|
+
```typescript
|
|
15
|
+
import { Component, h } from "@weftui/core";
|
|
16
|
+
import { Router } from "@weftui/router";
|
|
17
|
+
import { Stream } from "effect";
|
|
18
|
+
|
|
19
|
+
const Shell = Component.gen(function* () {
|
|
20
|
+
const outlet = yield* Router.Outlet;
|
|
21
|
+
const nav = yield* Router.navigatingStream;
|
|
22
|
+
return yield* h.div({ id: "app" }, [
|
|
23
|
+
h.div({
|
|
24
|
+
id: "nav-progress",
|
|
25
|
+
"aria-hidden": "true",
|
|
26
|
+
class: Stream.map(nav.changes, (s) =>
|
|
27
|
+
s._tag === "Navigating" ? "nav-progress is-navigating" : "nav-progress",
|
|
28
|
+
),
|
|
29
|
+
}),
|
|
30
|
+
h.main([outlet]),
|
|
31
|
+
]);
|
|
32
|
+
});
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
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.
|
|
36
|
+
|
|
37
|
+
## The signal
|
|
38
|
+
|
|
39
|
+
`NavState` is a two-state machine:
|
|
40
|
+
|
|
41
|
+
```typescript
|
|
42
|
+
type NavState = { readonly _tag: "Idle" } | { readonly _tag: "Navigating"; readonly to: string };
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Read it two ways, mirroring `Router.params` / `Router.paramsStream`:
|
|
46
|
+
|
|
47
|
+
- `Router.navigating` — the `Subscribable<NavState>` on the `Router` service.
|
|
48
|
+
- `Router.navigatingStream` — an `Effect` resolving that `Subscribable`, for use in a `Component.gen` body (as above).
|
|
49
|
+
|
|
50
|
+
The `to` field on `Navigating` is the target URL, if you want to label _where_ the app is going.
|
|
51
|
+
|
|
52
|
+
## Behavior to expect
|
|
53
|
+
|
|
54
|
+
- **Only navigations with real async work flip it.** A branch with no `Router.lazy` node and a leaf whose effect resolves synchronously (no async work, or a memoized revisit) commits in the same tick, and `navigating` stays `Idle` — an entirely eager app never sees `Navigating`, and adding the reader costs nothing.
|
|
55
|
+
- **Latest-wins.** Rapid successive navigations commit only the newest; a superseded navigation never resets the signal (the newer one owns it).
|
|
56
|
+
- **Back/forward.** `popstate` into a route with async work also resolves before committing, so the indicator shows for browser back/forward too.
|
|
57
|
+
- **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.
|
|
58
|
+
- **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.
|
|
59
|
+
- **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.
|
|
60
|
+
|
|
61
|
+
## See also
|
|
62
|
+
|
|
63
|
+
- [`Router.navigating` API reference](https://weftui.dev/docs/reference/router#routernavigating)
|
|
64
|
+
- [Split Routes Lazily](https://weftui.dev/docs/how-to/split-routes-lazily) — the `Router.lazy` deferred-commit navigation this reports on
|
|
65
|
+
- [examples/router-ssr](https://github.com/stefvw93/weft/tree/main/examples/router-ssr) — wires this exact progress bar in its `Shell` (`components/shell.ts`), with a `pending-navigation.browser.test.ts`
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Split Routes Lazily
|
|
3
|
+
order: 6
|
|
4
|
+
section: how-to
|
|
5
|
+
description: Code-split a route's component into its own chunk with Router.lazy, keeping the descriptor eager so matching, href, and SSR stay unchanged.
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Split Routes Lazily
|
|
9
|
+
|
|
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
|
+
|
|
12
|
+
Wrap the route's `component` in [`Router.lazy`](https://weftui.dev/docs/reference/router#routerlazy). The route **descriptor** (its segment and param schemas) stays eager so the matcher, `href`, and the server's dispatch API still see it statically — only the component body is split into its own chunk.
|
|
13
|
+
|
|
14
|
+
```typescript
|
|
15
|
+
import { Router } from "@weftui/router";
|
|
16
|
+
import { Schema } from "effect";
|
|
17
|
+
|
|
18
|
+
Router.route("docs/:category/:slug", {
|
|
19
|
+
path: { category: Schema.String, slug: Schema.String },
|
|
20
|
+
component: Router.lazy(() => import("./doc-page").then((m) => m.DocPage)),
|
|
21
|
+
});
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
The chunk loads on the server during render and on the client on navigation; only the **matched branch's** chunks are ever fetched. `E`/`R` are preserved — a lazy route has the exact same channels as the same component declared eagerly, so an unmet service requirement is still a compile error at `Router.router(...)`.
|
|
25
|
+
|
|
26
|
+
## Make the split real
|
|
27
|
+
|
|
28
|
+
`Router.lazy` only splits if the dynamic `import()` is the **only eager path** to the heavy module. Keep the `Router.route(…)` descriptor in an eagerly-imported file, and move the component implementation (and its heavy deps) into a separate module referenced _only_ through `Router.lazy(() => import("./impl"))`:
|
|
29
|
+
|
|
30
|
+
```typescript
|
|
31
|
+
// routes.ts — eager, tiny: just the descriptor
|
|
32
|
+
export const docsRoute = Router.route("docs/:category/:slug", {
|
|
33
|
+
path: { category: Schema.String, slug: Schema.String },
|
|
34
|
+
component: Router.lazy(() => import("./doc-page-impl").then((m) => m.DocsPage)),
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// doc-page-impl.ts — heavy: pulled into its own chunk, never in the initial graph
|
|
38
|
+
export const DocsPage = Component.gen(function* () {
|
|
39
|
+
/* renderHast, code highlighting, … */
|
|
40
|
+
});
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
A descriptor file that still `import`s the impl statically gains nothing — the bundler keeps it in the initial graph.
|
|
44
|
+
|
|
45
|
+
## What you get for free
|
|
46
|
+
|
|
47
|
+
- **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.
|
|
48
|
+
- **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, so the previous page stays mounted through both 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.
|
|
49
|
+
- **Synchronous revisits.** `Router.lazy` memoizes its load per slot, so a second visit to a loaded route commits immediately.
|
|
50
|
+
|
|
51
|
+
## Edge cases
|
|
52
|
+
|
|
53
|
+
- **Lazy layouts.** A `Router.layout({ component: Router.lazy(...) })` splits too — each lazy node in the matched branch is awaited; nodes outside it never load.
|
|
54
|
+
- **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).
|
|
55
|
+
- **Not a lazy _subtree_.** Only the component is lazy; you cannot defer a whole `RouteNode` behind an `import()`, because the matcher needs every leaf's segment and param schema before anything loads.
|
|
56
|
+
|
|
57
|
+
## See also
|
|
58
|
+
|
|
59
|
+
- [`Router.lazy` API reference](https://weftui.dev/docs/reference/router#routerlazy)
|
|
60
|
+
- [Show Navigation Progress](https://weftui.dev/docs/how-to/show-navigation-progress) — the deferred-commit `Router.navigating` signal
|
|
61
|
+
- [Add Routing](https://weftui.dev/docs/how-to/add-routing) — authoring the route tree `Router.lazy` plugs into
|
|
62
|
+
- [examples/router-ssr](https://github.com/stefvw93/weft/tree/main/examples/router-ssr) — includes a `Router.lazy` page (`lazy-page.ts`) with a browser test
|