@weftui/router 0.28.0 → 0.30.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/README.md +3 -3
- package/dist/client/index.d.ts +19 -19
- package/dist/client/index.js +16 -16
- package/dist/{compile-DySac7KN.d.ts → compile-DFlX4Atp.d.ts} +39 -59
- package/dist/{href-uvJ6b7zz.js → href-wIrP-_-3.js} +7 -10
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/{outlet-saKduDFz.d.ts → outlet-CWpiNOxW.d.ts} +1 -1
- package/dist/{outlet-C9N4a4_F.js → outlet-Crd9mxZA.js} +49 -55
- package/dist/server/index.d.ts +9 -9
- package/dist/server/index.js +10 -10
- package/docs/explanation/boundaries-and-suspense.md +39 -20
- package/docs/explanation/combinator-api.md +14 -12
- package/docs/explanation/reactive-primitives.md +14 -14
- package/docs/explanation/rendering-model.md +20 -18
- package/docs/explanation/services-and-context.md +35 -21
- package/docs/how-to/add-routing.md +361 -60
- package/docs/how-to/author-components.md +42 -30
- package/docs/how-to/compose-behavior-and-markup.md +144 -0
- package/docs/how-to/handle-forms.md +6 -6
- package/docs/how-to/load-async-data.md +15 -13
- package/docs/how-to/load-data-with-rpc.md +34 -32
- package/docs/how-to/provide-services.md +20 -18
- package/docs/how-to/render-keyed-lists.md +14 -12
- package/docs/how-to/render-on-the-server.md +18 -14
- package/docs/how-to/show-navigation-progress.md +12 -10
- package/docs/how-to/split-routes-lazily.md +16 -14
- package/docs/how-to/style-reactively.md +13 -13
- package/docs/how-to/use-element-refs.md +10 -8
- package/docs/index.md +20 -18
- package/docs/reference/core.md +46 -42
- package/docs/reference/dom.md +274 -58
- package/docs/reference/router.md +69 -47
- package/docs/tutorial/01-your-first-app.md +7 -9
- package/docs/tutorial/02-reactivity.md +8 -6
- package/docs/tutorial/03-services-and-async.md +10 -6
- package/docs/tutorial/04-errors-and-server.md +14 -5
- package/package.json +4 -4
|
@@ -27,7 +27,7 @@ const notFound = (path) => Effect.fail(new RouterNotFound({ path }));
|
|
|
27
27
|
const isRouterNotFound = (u) => typeof u === "object" && u !== null && "_tag" in u && u._tag === "RouterNotFound";
|
|
28
28
|
/**
|
|
29
29
|
* Tagged error raised by `Router.params` / `Router.query` when the live match does
|
|
30
|
-
* not satisfy the requested fields
|
|
30
|
+
* not satisfy the requested fields: either no route is matched, or a requested
|
|
31
31
|
* key is missing / fails its schema's `Type`-side validation. `source` records
|
|
32
32
|
* whether the failure was on the path params or the query, and `keys` lists the
|
|
33
33
|
* requested field names for diagnostics.
|
|
@@ -93,9 +93,9 @@ function longestCommonSegmentPrefix(partsList) {
|
|
|
93
93
|
* Pass 1 walks the tree: only **routes** contribute path parts (layouts own no
|
|
94
94
|
* path), so each leaf's `parts` come solely from the route segments on its branch,
|
|
95
95
|
* and its ancestor `LayoutNode`s are recorded in order. Pass 2 derives one shared
|
|
96
|
-
* {@link CompiledLayout} per distinct layout node
|
|
97
|
-
* longest common path prefix of that layout's subtree leaves
|
|
98
|
-
* leaf's `layoutChain` (root → parent) and merged path schema.
|
|
96
|
+
* {@link CompiledLayout} per distinct layout node, whose `patternPrefix` is the
|
|
97
|
+
* longest common path prefix of that layout's subtree leaves. It then assembles
|
|
98
|
+
* each leaf's `layoutChain` (root → parent) and merged path schema.
|
|
99
99
|
*/
|
|
100
100
|
function compile(def) {
|
|
101
101
|
const leafWorks = [];
|
|
@@ -156,7 +156,7 @@ function compile(def) {
|
|
|
156
156
|
}
|
|
157
157
|
/**
|
|
158
158
|
* Builds the authoritative `HttpApi` for a compiled tree (S4): a single `"pages"`
|
|
159
|
-
* group
|
|
159
|
+
* group holding one GET endpoint per leaf, at each leaf's full path
|
|
160
160
|
* pattern, carrying `params: pathSchema`, `query: querySchema`, a
|
|
161
161
|
* `Schema.String` (text/HTML) success, and a `RouterNotFound → 404` error. The tree
|
|
162
162
|
* (not `HttpApi`) is the authoring surface; this is the single source of truth the
|
|
@@ -237,7 +237,7 @@ function makeLayout(config, children) {
|
|
|
237
237
|
};
|
|
238
238
|
}
|
|
239
239
|
/**
|
|
240
|
-
* Brand key marking a {@link ComponentSlot} as **preloadable
|
|
240
|
+
* Brand key marking a {@link ComponentSlot} as **preloadable**: a lazy slot
|
|
241
241
|
* ({@link lazyComponent}) carries a `preload()` under this key. Internal to
|
|
242
242
|
* `@weftui/router` (read by the client `navigate` to resolve a matched branch's
|
|
243
243
|
* chunks before commit; see `pending-navigation.specs.md`); not public API.
|
|
@@ -255,24 +255,16 @@ function getPreload(slot) {
|
|
|
255
255
|
}
|
|
256
256
|
/**
|
|
257
257
|
* Wraps a dynamic-import `load` as a lazy {@link ComponentSlot}: the route's descriptor
|
|
258
|
-
* (`segment`, `path`/`query`) stays eager and matchable, while the component
|
|
259
|
-
* body and its module's deps
|
|
258
|
+
* (`segment`, `path`/`query`) stays eager and matchable, while the component (the render
|
|
259
|
+
* body and its module's deps) is split into the chunk `load` resolves. The router invokes
|
|
260
260
|
* the returned slot at render time; it awaits `load` then renders the resolved component,
|
|
261
261
|
* adopting the server DOM in place on hydration (flash-free) and fetching the chunk on
|
|
262
262
|
* client navigation. Exposed as {@link Router.lazy}. See `lazy-component.specs.md`.
|
|
263
263
|
*
|
|
264
264
|
* The resolved value is a component slot (`Component.gen` / `Component.make`, or a
|
|
265
|
-
* `() => Node` thunk)
|
|
265
|
+
* `() => Node` thunk), the shape `component:` already accepts, so its `E`/`R` channels
|
|
266
266
|
* are recovered via {@link SlotNode} and propagate up the tree exactly as an eager
|
|
267
267
|
* component's do.
|
|
268
|
-
*
|
|
269
|
-
* @example
|
|
270
|
-
* ```ts
|
|
271
|
-
* Router.route("docs/:category/:slug", {
|
|
272
|
-
* path: { category: Schema.String, slug: Schema.String },
|
|
273
|
-
* component: Router.lazy(() => import("./doc-page").then((m) => m.DocPage)),
|
|
274
|
-
* });
|
|
275
|
-
* ```
|
|
276
268
|
*/
|
|
277
269
|
function lazyComponent(load) {
|
|
278
270
|
let cached;
|
|
@@ -296,7 +288,7 @@ var Router = class extends Context.Service()("@weftui/router/Router") {};
|
|
|
296
288
|
* reads it with `yield* Router.Outlet`.
|
|
297
289
|
*
|
|
298
290
|
* Typed **opaque** as `Node<never, never>` so splicing `[outlet]` adds nothing to
|
|
299
|
-
* a layout's local channels
|
|
291
|
+
* a layout's local channels: the subtree's real channels are aggregated
|
|
300
292
|
* structurally by {@link makeLayout} / {@link makeRouter}, never inferred across
|
|
301
293
|
* this DI boundary. Re-exported on the namespace as `Router.Outlet`.
|
|
302
294
|
*/
|
|
@@ -309,15 +301,16 @@ function pick(fields, record) {
|
|
|
309
301
|
}
|
|
310
302
|
/**
|
|
311
303
|
* Reads the live match's **path params** for the requested `fields`. Snapshot
|
|
312
|
-
* semantics
|
|
304
|
+
* semantics (reads `yield* Router`, then `Subscribable.get(currentMatch)`), returning the
|
|
313
305
|
* already-decoded values **directly**: the matcher decoded them against the leaf's
|
|
314
|
-
* full path schema, so no re-validation is needed
|
|
315
|
-
* subset is the `Type` side of `fields
|
|
306
|
+
* full path schema, so no re-validation is needed. The cast is sound because the
|
|
307
|
+
* picked subset is the `Type` side of `fields`. Fails with a {@link RouterParamsError}
|
|
316
308
|
* (`source: "path"`) only when no route is matched. Re-exported as `Router.params`.
|
|
317
309
|
*/
|
|
318
310
|
function readParams(fields) {
|
|
319
311
|
return Effect.gen(function* () {
|
|
320
|
-
const
|
|
312
|
+
const router = yield* Router;
|
|
313
|
+
const match = yield* Subscribable.get(router.currentMatch);
|
|
321
314
|
if (match._tag === "NotFound") return yield* Effect.fail(new RouterParamsError({
|
|
322
315
|
source: "path",
|
|
323
316
|
keys: Object.keys(fields)
|
|
@@ -333,7 +326,8 @@ function readParams(fields) {
|
|
|
333
326
|
*/
|
|
334
327
|
function readQuery(fields) {
|
|
335
328
|
return Effect.gen(function* () {
|
|
336
|
-
const
|
|
329
|
+
const router = yield* Router;
|
|
330
|
+
const match = yield* Subscribable.get(router.currentMatch);
|
|
337
331
|
if (match._tag === "NotFound") return yield* Effect.fail(new RouterParamsError({
|
|
338
332
|
source: "query",
|
|
339
333
|
keys: Object.keys(fields)
|
|
@@ -351,15 +345,15 @@ function readQuery(fields) {
|
|
|
351
345
|
function selectStream(currentMatch, fields, source) {
|
|
352
346
|
const select = (m) => pick(fields, m._tag === "Matched" ? m[source] : {});
|
|
353
347
|
return Subscribable.make({
|
|
354
|
-
get: Effect.map(
|
|
355
|
-
changes: Stream.map(
|
|
348
|
+
get: Effect.map(Subscribable.get(currentMatch), select),
|
|
349
|
+
changes: Stream.map(Subscribable.changes(currentMatch), select)
|
|
356
350
|
});
|
|
357
351
|
}
|
|
358
352
|
/**
|
|
359
353
|
* Reactive counterpart to {@link readParams}: a {@link Subscribable} of the live
|
|
360
|
-
* match's **path params** for `fields`, derived from `
|
|
354
|
+
* match's **path params** for `fields`, derived from `Subscribable.changes(currentMatch)`. It
|
|
361
355
|
* re-emits on every navigation and stays live across `NotFound` (yielding the empty
|
|
362
|
-
* subset), so a component can render `[(yield* Router.paramsStream(fields))
|
|
356
|
+
* subset), so a component can render `[Subscribable.changes(yield* Router.paramsStream(fields))]`
|
|
363
357
|
* and update in place even when the outlet keeps the same leaf mounted. Re-exported
|
|
364
358
|
* as `Router.paramsStream`.
|
|
365
359
|
*/
|
|
@@ -378,7 +372,7 @@ function subscribeQuery(fields) {
|
|
|
378
372
|
}
|
|
379
373
|
/**
|
|
380
374
|
* Reactive {@link Subscribable} of the client {@link NavState}. A component reads
|
|
381
|
-
* `[(yield* Router.navigatingStream)
|
|
375
|
+
* `[Subscribable.changes(yield* Router.navigatingStream)]` to render pending UI during a
|
|
382
376
|
* deferred-commit navigation (`pending-navigation.specs.md`,
|
|
383
377
|
* `resolve-before-commit.specs.md`). Re-exported as `Router.navigatingStream`.
|
|
384
378
|
*/
|
|
@@ -398,7 +392,7 @@ const subscribeNavigating = Effect.map(Router, (router) => router.navigating);
|
|
|
398
392
|
//#endregion
|
|
399
393
|
//#region src/resolved-commit.ts
|
|
400
394
|
/**
|
|
401
|
-
* Internal seams for resolve-before-commit navigation
|
|
395
|
+
* Internal seams for resolve-before-commit navigation: the resolved-commit
|
|
402
396
|
* stash and the staged match view. See `resolve-before-commit.specs.md`.
|
|
403
397
|
*
|
|
404
398
|
* **Not public API.** This module is deliberately not re-exported from
|
|
@@ -419,10 +413,10 @@ const subscribeNavigating = Effect.map(Router, (router) => router.navigating);
|
|
|
419
413
|
*/
|
|
420
414
|
const ResolvedCommit = Symbol.for("@weftui/router/resolved-commit");
|
|
421
415
|
/**
|
|
422
|
-
* Writes the stash on the (client) router instance
|
|
423
|
-
* popstate with the pre-run's `Exit`, immediately before the URL ref is set,
|
|
424
|
-
*
|
|
425
|
-
* Overwrites any stale entry
|
|
416
|
+
* Writes the stash on the (client) router instance. Called by `navigate` /
|
|
417
|
+
* popstate with the pre-run's `Exit`, immediately before the URL ref is set, so
|
|
418
|
+
* the outlet emission triggered by that commit finds it (AC-R1/AC-R2).
|
|
419
|
+
* Overwrites any stale entry: latest-wins already guarantees only the newest
|
|
426
420
|
* navigation reaches the commit step (AC-R6).
|
|
427
421
|
*/
|
|
428
422
|
function setResolvedCommit(router, entry) {
|
|
@@ -430,10 +424,10 @@ function setResolvedCommit(router, entry) {
|
|
|
430
424
|
}
|
|
431
425
|
/**
|
|
432
426
|
* Consumes the stash: returns the entry when its `url` equals the emission's
|
|
433
|
-
* `match.url` and **clears the slot** (consume-exactly-once, AC-R2)
|
|
434
|
-
* `undefined
|
|
435
|
-
* hydration, non-navigation re-emission) falls through to the ordinary
|
|
436
|
-
* invocation in `renderLevel`.
|
|
427
|
+
* `match.url` and **clears the slot** (consume-exactly-once, AC-R2). Otherwise
|
|
428
|
+
* returns `undefined`, so a URL mismatch (stale entry) or an absent slot (server
|
|
429
|
+
* render, hydration, non-navigation re-emission) falls through to the ordinary
|
|
430
|
+
* slot invocation in `renderLevel`.
|
|
437
431
|
*/
|
|
438
432
|
function takeResolvedCommit(router, url) {
|
|
439
433
|
const slot = router;
|
|
@@ -444,33 +438,33 @@ function takeResolvedCommit(router, url) {
|
|
|
444
438
|
}
|
|
445
439
|
/**
|
|
446
440
|
* The staged `Router` view the pre-run executes under (AC-R4): identical to
|
|
447
|
-
* `router` except `
|
|
448
|
-
*
|
|
449
|
-
* `
|
|
450
|
-
*
|
|
451
|
-
*
|
|
452
|
-
*
|
|
453
|
-
*
|
|
441
|
+
* `router` except `Subscribable.get(currentMatch)` resolves to the **target** match. The URL
|
|
442
|
+
* ref has not moved yet, and one-shot reads (`Router.params`, `Router.query`,
|
|
443
|
+
* `Subscribable.get(currentMatch)`) inside the pre-running component body must decode the
|
|
444
|
+
* destination, not the page being left. `Subscribable.changes(currentMatch)` (and `navigate` /
|
|
445
|
+
* `httpApiClient` / `navigating`) delegate to the live service, so reactive
|
|
446
|
+
* subscriptions, which occur at render/mount time post-commit, observe the
|
|
447
|
+
* committed match onward.
|
|
454
448
|
*/
|
|
455
449
|
function stageMatch(router, target) {
|
|
456
450
|
return {
|
|
457
451
|
...router,
|
|
458
452
|
currentMatch: Subscribable.make({
|
|
459
453
|
get: Effect.succeed(target),
|
|
460
|
-
changes: router.currentMatch
|
|
454
|
+
changes: Subscribable.changes(router.currentMatch)
|
|
461
455
|
})
|
|
462
456
|
};
|
|
463
457
|
}
|
|
464
458
|
/**
|
|
465
|
-
* Pre-runs a matched leaf's component effect: invokes the slot with the
|
|
466
|
-
*
|
|
467
|
-
*
|
|
468
|
-
*
|
|
469
|
-
*
|
|
470
|
-
*
|
|
471
|
-
*
|
|
472
|
-
*
|
|
473
|
-
*
|
|
459
|
+
* Pre-runs a matched leaf's component effect: invokes the slot with the target
|
|
460
|
+
* match's handler-arg props (`{ path, query }`, exactly what `renderLevel`
|
|
461
|
+
* passes), under the {@link stageMatch | staged view}, and captures the outcome
|
|
462
|
+
* as an `Exit` (AC-R1/AC-R7). The returned effect never fails, since failures
|
|
463
|
+
* are folded into the `Exit` for stash-and-replay, but it is **interruptible**:
|
|
464
|
+
* a superseding navigation interrupts the whole pre-run fiber (AC-R6). Requires
|
|
465
|
+
* the caller's runtime context (the `RouterLive` layer's: `Router`,
|
|
466
|
+
* `AppRpcClientTag`, app services), which is what makes the pre-run possible at
|
|
467
|
+
* all (Feasibility §1).
|
|
474
468
|
*/
|
|
475
469
|
function preRunLeaf(router, target) {
|
|
476
470
|
return Effect.suspend(() => {
|
|
@@ -532,7 +526,7 @@ function renderLevel(def, router, index, match) {
|
|
|
532
526
|
* reactive child region.
|
|
533
527
|
*/
|
|
534
528
|
function levelStream(def, router, index) {
|
|
535
|
-
return pipe(router.currentMatch
|
|
529
|
+
return pipe(Subscribable.changes(router.currentMatch), Stream.map((match) => [keyOf(index, match), match]), Stream.changesWith((a, b) => a[0] === b[0]), Stream.map(([, match]) => renderLevel(def, router, index, match)));
|
|
536
530
|
}
|
|
537
531
|
/**
|
|
538
532
|
* The bare nested-outlet node for a router definition: a fragment whose single
|
package/dist/server/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { S as Router, i as RouterDef, l as ComponentSlot } from "../compile-
|
|
1
|
+
import { S as Router, i as RouterDef, l as ComponentSlot } from "../compile-DFlX4Atp.js";
|
|
2
2
|
import { Effect, Layer } from "effect";
|
|
3
3
|
import { AppRpcClientTag } from "@weftui/core";
|
|
4
4
|
import { RpcGroup } from "effect/unstable/rpc";
|
|
@@ -8,15 +8,15 @@ import { RpcGroup } from "effect/unstable/rpc";
|
|
|
8
8
|
* authoritative `HttpApi` spine via `HttpApiBuilder`: platform owns request→leaf
|
|
9
9
|
* matching and path/query decode, then each leaf handler builds a fixed-match
|
|
10
10
|
* server `Router`, renders the universal outlet to hydratable HTML, and replies
|
|
11
|
-
* `text/html`. Status comes from the platform pipeline
|
|
11
|
+
* `text/html`. Status comes from the platform pipeline: a no-match (platform's
|
|
12
12
|
* `RouteNotFound`) and a page-raised `RouterNotFound` both render the configured
|
|
13
|
-
* `notFound` page at HTTP 404
|
|
13
|
+
* `notFound` page at HTTP 404, so there is no render-time status side-channel.
|
|
14
14
|
*/
|
|
15
15
|
declare namespace RouterServer {
|
|
16
16
|
/**
|
|
17
17
|
* The app's `Boundary.rpc` data foundation: the merged `RpcGroup` contract plus
|
|
18
18
|
* its server-only handler `Layer`. Wired explicitly (no co-located `load`, no
|
|
19
|
-
* registry)
|
|
19
|
+
* registry): `toWebHandler` serves it at `POST /_eui/rpc`, and an in-process
|
|
20
20
|
* client over the same handlers resolves SSR boundaries in-process.
|
|
21
21
|
*/
|
|
22
22
|
export interface RpcOptions {
|
|
@@ -27,7 +27,7 @@ declare namespace RouterServer {
|
|
|
27
27
|
}
|
|
28
28
|
/**
|
|
29
29
|
* Shared server options. The document shell is a {@link ComponentSlot} that splices
|
|
30
|
-
* the app via `yield* Router.Outlet` (the router provides it per request)
|
|
30
|
+
* the app via `yield* Router.Outlet` (the router provides it per request),
|
|
31
31
|
* typically `<html><head>…</head><body><div id="root">{app}</div><script …></body></html>`.
|
|
32
32
|
* The router provides both `Router.Outlet` (the app, per request) and `Router` (to read
|
|
33
33
|
* params), so the document may use either. Mirrors the route/layout `component` slot,
|
|
@@ -37,7 +37,7 @@ declare namespace RouterServer {
|
|
|
37
37
|
* `context` is the render-time provide seam (spec:
|
|
38
38
|
* `ambient-context-propagation.specs.md`): an app-wide `Layer` provided to the
|
|
39
39
|
* document shell **and** every route/layout leaf. It is required exactly when the
|
|
40
|
-
* def carries residual app services ({@link AppServices}
|
|
40
|
+
* def carries residual app services ({@link AppServices}: its aggregate `R` minus
|
|
41
41
|
* the `Router` / `Router.Outlet` / `AppRpcClientTag` the router already threads) and
|
|
42
42
|
* disallowed otherwise, so a missing provide is a compile error and `rpc`-only /
|
|
43
43
|
* no-service apps stay unchanged (surfaced via {@link ContextOption} at each entry).
|
|
@@ -47,7 +47,7 @@ declare namespace RouterServer {
|
|
|
47
47
|
readonly document: ComponentSlot;
|
|
48
48
|
/**
|
|
49
49
|
* The app's `Boundary.rpc` foundation (contract + server handlers). Optional:
|
|
50
|
-
* omit when the app has no `Boundary.rpc
|
|
50
|
+
* omit when the app has no `Boundary.rpc`. Then `POST /_eui/rpc` is not
|
|
51
51
|
* served (it falls through to page dispatch) and a stray `Boundary.rpc`
|
|
52
52
|
* fails with a descriptive error instead of rendering.
|
|
53
53
|
*/
|
|
@@ -56,12 +56,12 @@ declare namespace RouterServer {
|
|
|
56
56
|
/**
|
|
57
57
|
* The residual app services a caller must still provide through the {@link Options.context}
|
|
58
58
|
* seam: a def's aggregate requirement `R` **minus** the services the router already
|
|
59
|
-
* threads in per render
|
|
59
|
+
* threads in per render: `Router` and `Router.Outlet` (provided by the outlet /
|
|
60
60
|
* document plumbing) and `AppRpcClientTag` (provided from the `rpc` option). When this
|
|
61
61
|
* resolves to `never` the app has no app-wide service to inject and `context` is disallowed.
|
|
62
62
|
*/
|
|
63
63
|
export type AppServices<R> = Exclude<R, Router | Router.Outlet | AppRpcClientTag>;
|
|
64
|
-
/** True only for the exact `any` type
|
|
64
|
+
/** True only for the exact `any` type: a loosely-typed `RouterDef<any, any>`. */
|
|
65
65
|
type IsAny<T> = 0 extends 1 & T ? true : false;
|
|
66
66
|
/**
|
|
67
67
|
* Conditionally shapes the `context` field at each entry point: **required** (a
|
package/dist/server/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { a as Router, f as isRouterNotFound, n as outletNode, u as RouterNotFound } from "../outlet-
|
|
1
|
+
import { a as Router, f as isRouterNotFound, n as outletNode, u as RouterNotFound } from "../outlet-Crd9mxZA.js";
|
|
2
2
|
import { HttpApiBuilder, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi";
|
|
3
3
|
import { Cause, Effect, Exit, Layer, Option, Schema, Scope, Stream } from "effect";
|
|
4
4
|
import { AppRpcClientTag, Subscribable } from "@weftui/core";
|
|
@@ -36,19 +36,19 @@ let RouterServer;
|
|
|
36
36
|
/**
|
|
37
37
|
* In-process {@link AppRpcClientTag} Layer over the app's handler Layer
|
|
38
38
|
* ({@link RpcTest.makeClient}, flat, no protocol/serialization). SSR
|
|
39
|
-
* `Boundary.rpc` resolution calls `call(tag, payload())` against this
|
|
39
|
+
* `Boundary.rpc` resolution calls `call(tag, payload())` against this: the rpc
|
|
40
40
|
* runs in-process, never over the network.
|
|
41
41
|
*
|
|
42
42
|
* The render path requires the tag unconditionally, so with no `rpc` configured
|
|
43
|
-
* a stub is provided whose `call` fails descriptively
|
|
44
|
-
* rpc-less app surfaces the misconfiguration instead of dying opaquely.
|
|
43
|
+
* a stub is provided whose `call` fails descriptively. A `Boundary.rpc` in an
|
|
44
|
+
* rpc-less app then surfaces the misconfiguration instead of dying opaquely.
|
|
45
45
|
*/
|
|
46
46
|
function appRpcClientLayer(rpc) {
|
|
47
47
|
if (rpc === void 0) return Layer.succeed(AppRpcClientTag, AppRpcClientTag.of({ call: (tag) => Effect.fail(/* @__PURE__ */ new Error(`Boundary.rpc "${tag}" cannot resolve: no \`rpc\` option was passed to RouterServer`)) }));
|
|
48
48
|
return Layer.effect(AppRpcClientTag, Effect.map(RpcTest.makeClient(rpc.group, { flatten: true }), (flat) => AppRpcClientTag.of({ call: (tag, payload) => flat(tag, payload) }))).pipe(Layer.provide(rpc.handlers));
|
|
49
49
|
}
|
|
50
50
|
/**
|
|
51
|
-
* Renders the document shell
|
|
51
|
+
* Renders the document shell (with `app` spliced via `Router.Outlet`) to a
|
|
52
52
|
* hydratable HTML string. The whole tree (shell + every route/layout leaf) drains
|
|
53
53
|
* in this one `renderToStringHydratable` context, so the app-wide `options.context`
|
|
54
54
|
* Layer provided here reaches the leaves too (the render-time provide seam). No
|
|
@@ -60,7 +60,7 @@ let RouterServer;
|
|
|
60
60
|
/**
|
|
61
61
|
* Renders the configured `notFound` page **directly** in the shell (no nested
|
|
62
62
|
* outlet, no reactive-region markers) at `status`. Mirrors the client's internal
|
|
63
|
-
* not-found boundary fallback
|
|
63
|
+
* not-found boundary fallback, which replaces the whole outlet subtree, so the
|
|
64
64
|
* page-raised-404 HTML aligns for hydration.
|
|
65
65
|
*/
|
|
66
66
|
function renderNotFoundDirect(def, options, url, status) {
|
|
@@ -73,8 +73,8 @@ let RouterServer;
|
|
|
73
73
|
/**
|
|
74
74
|
* Renders the no-match case: the bare {@link outletNode} with a `NotFound` match,
|
|
75
75
|
* so the `notFound` page renders **inside** the level-0 reactive region (markers
|
|
76
|
-
* present)
|
|
77
|
-
*
|
|
76
|
+
* present) at HTTP 404, matching what the client outlet produces for an
|
|
77
|
+
* unmatched URL.
|
|
78
78
|
*/
|
|
79
79
|
function renderNoMatch(def, options, url) {
|
|
80
80
|
const router = serverRouter({
|
|
@@ -142,9 +142,9 @@ let RouterServer;
|
|
|
142
142
|
/**
|
|
143
143
|
* Builds (and memoizes) the platform `(Request) => Promise<Response>` handler for
|
|
144
144
|
* `def`. Dispatch runs through a **server-local** `HttpApi`: `def.httpApi`
|
|
145
|
-
* (pristine
|
|
145
|
+
* (pristine: the client and the spec read it) extended with a second `"fallback"`
|
|
146
146
|
* group holding one catch-all `"*"` endpoint. Platform owns matching: a request
|
|
147
|
-
* routes to the specific leaf endpoint, or
|
|
147
|
+
* routes to the specific leaf endpoint, or, when nothing matches, to the
|
|
148
148
|
* catch-all, which renders the configured not-found page at 404. (Platform's own
|
|
149
149
|
* unmatched path resolves a default empty 404 before any response hook can rewrite
|
|
150
150
|
* it, so the catch-all is the route that keeps no-match rendering ours.)
|
|
@@ -2,18 +2,20 @@
|
|
|
2
2
|
title: Boundaries and Suspense
|
|
3
3
|
order: 4
|
|
4
4
|
section: explanation
|
|
5
|
-
description: How Weft models failure, async, and server data as boundary nodes in the same tree
|
|
5
|
+
description: How Weft models failure, async, and server data as boundary nodes in the same tree. Covers failure-catch variants, Boundary.suspend, and Boundary.rpc, and how their E/R channels behave.
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# Boundaries and Suspense
|
|
9
9
|
|
|
10
|
-
A **boundary** is a node that intercepts something flowing through the tree
|
|
10
|
+
A **boundary** is a node that intercepts something flowing through the tree: an error, a pending async child, or a server-resolved value. It decides what the DOM shows in its place.
|
|
11
|
+
|
|
12
|
+
A boundary is itself a `Node<E, R>` ([nodes are Effects](https://weftui.dev/docs/explanation/rendering-model)), so it composes exactly like any other element. You nest it, and its children's channels flow through it under a transformation the boundary defines.
|
|
11
13
|
|
|
12
14
|
The `Boundary` namespace has three kinds. This page is the conceptual map; the [core reference](https://weftui.dev/docs/reference/core#boundary-namespace) has the full signatures.
|
|
13
15
|
|
|
14
16
|
## Failure boundaries
|
|
15
17
|
|
|
16
|
-
A component's `E` channel accumulates up the tree. A **failure boundary** is where you _discharge_ some of that `E
|
|
18
|
+
A component's `E` channel accumulates up the tree. A **failure boundary** is where you _discharge_ some of that `E`. It wraps children and, if one of them fails, renders a fallback instead of letting the failure propagate to the mount.
|
|
17
19
|
|
|
18
20
|
```typescript
|
|
19
21
|
import { Boundary, h } from "@weftui/core";
|
|
@@ -32,19 +34,25 @@ There are six failure-catch variants, mirroring Effect's own error operators so
|
|
|
32
34
|
| `catchTag` / `catchTags` | one / several tagged errors by `_tag` |
|
|
33
35
|
| `catchFilter` / `catchIf` | a selected subset, by `Filter` / predicate |
|
|
34
36
|
|
|
35
|
-
The channel algebra is the whole reason they exist
|
|
37
|
+
The channel algebra is the whole reason they exist. `catchTag("Foo", …)` removes `Foo` from the children's `E` and adds whatever the fallback needs. The type of the boundary node therefore reflects exactly which failures are still live and which were handled.
|
|
38
|
+
|
|
39
|
+
An unhandled failure re-raises to the **nearest enclosing** boundary; if none catches it at **mount time**, mounting fails. Boundaries nest, so an inner `catchTag` can handle a specific case while an outer `catch` sweeps the rest.
|
|
36
40
|
|
|
37
41
|
### Post-mount failures with no enclosing boundary
|
|
38
42
|
|
|
39
|
-
The routing above describes what happens while a node is being built. Once mounted, a reactive region
|
|
43
|
+
The routing above describes what happens while a node is being built. Once mounted, a reactive region (an attribute, child, or list stream, or a hydrated equivalent) keeps running for the lifetime of its scope. It can still fail later: a `Stream` backing a `Boundary.rpc` resource might raise `RouterNotFound` after a client-side navigation. If a `BoundaryContext` encloses the region, the failure routes to it exactly as above, and the boundary's fallback swaps in.
|
|
44
|
+
|
|
45
|
+
If no boundary encloses it, there is nothing to swap to. Weft does not synthesize one. The region's DOM keeps its last rendered content, and a watcher fiber (forked into the same scope alongside the subscription itself) observes its exit directly.
|
|
40
46
|
|
|
41
|
-
|
|
47
|
+
When that exit is a failure whose cause is not interruption-only, Weft reports it explicitly via `Effect.logError(exit.cause)`. The log is annotated with `weft.region` to identify the failing region by kind and identity (e.g. `attribute:class`, `child:stream-3`, `list:stream-2`, `hydrate:stream-1 (/products/42)`). This fires for typed failures and defects alike, in both dev and prod, exactly once per failing region, at the `"Error"` level. Interruption (the ordinary case of unmount tearing down the region's scope) is never reported; only genuine failures are.
|
|
42
48
|
|
|
43
|
-
This is deliberate
|
|
49
|
+
This is deliberate. Rather than leave the failure to whatever the Effect runtime would do with an unobserved fiber exit, Weft observes and logs it itself. Visibility is therefore controlled by the same knobs any Effect program uses: `References.MinimumLogLevel` (provided via `Effect.provideService`) to filter it, or a custom `Logger` to route it elsewhere.
|
|
50
|
+
|
|
51
|
+
A stream that can fail and has no enclosing boundary is a stream whose failures you've chosen not to route into the UI. The log is what tells you that decision has consequences at runtime.
|
|
44
52
|
|
|
45
53
|
## Suspense boundaries
|
|
46
54
|
|
|
47
|
-
`Boundary.suspend` wraps async children and shows a `fallback` until **all** of them have emitted their first value
|
|
55
|
+
`Boundary.suspend` wraps async children and shows a `fallback` until **all** of them have emitted their first value. Then it swaps atomically: either everything is visible or nothing is. This prevents partial flicker when sibling async regions resolve at different times.
|
|
48
56
|
|
|
49
57
|
```typescript
|
|
50
58
|
import { Boundary, h } from "@weftui/core";
|
|
@@ -55,37 +63,48 @@ Boundary.suspend({ fallback: h.div({ class: "spinner" }, "Loading…") }, [
|
|
|
55
63
|
]);
|
|
56
64
|
```
|
|
57
65
|
|
|
58
|
-
A suspense boundary is transparent to the type channels: its node is `Node<ChildrenE, ChildrenR
|
|
66
|
+
A suspense boundary is transparent to the type channels: its node is `Node<ChildrenE, ChildrenR>`. The children's `E`/`R` pass straight through, exactly as they would for a plain `h.*` parent. It changes _timing_ (when the children become visible), not _types_.
|
|
59
67
|
|
|
60
|
-
On the server, `renderToStreamHydratable` emits the fallback inline and appends patch scripts as children resolve
|
|
68
|
+
On the server, `renderToStreamHydratable` emits the fallback inline and appends patch scripts as children resolve. On the client, `hydrate` sees through the boundary and adopts the already-resolved DOM directly.
|
|
61
69
|
|
|
62
|
-
> **Note.** There is no `Suspense` export
|
|
70
|
+
> **Note.** There is no `Suspense` export; the API is `Boundary.suspend(props, children)`. Reach for it for async that loads **on the client**. For data that must resolve on the **server** and hydrate without a second request, use `Boundary.rpc` (below).
|
|
63
71
|
|
|
64
72
|
## The rpc boundary
|
|
65
73
|
|
|
66
|
-
`Boundary.rpc` is the server-data boundary:
|
|
74
|
+
`Boundary.rpc` is the server-data boundary. It:
|
|
75
|
+
|
|
76
|
+
- resolves one `Rpc` on the server
|
|
77
|
+
- serializes the result into the HTML
|
|
78
|
+
- replays it on the client during `hydrate` (no second request, no flash)
|
|
79
|
+
- keeps the region live for `refetch`
|
|
80
|
+
|
|
81
|
+
Conceptually it is the same idea as the other boundaries: a node that decides what renders in a subtree. But the thing it intercepts is a **round-trip to a server handler**. Instead of a children array, it takes a `render` function that receives a reactive [`Resource`](https://weftui.dev/docs/reference/core#resourcea).
|
|
67
82
|
|
|
68
83
|
```typescript
|
|
69
|
-
import { Boundary, h } from "@weftui/core";
|
|
84
|
+
import { Boundary, h, Subscribable } from "@weftui/core";
|
|
70
85
|
import { Stream } from "effect";
|
|
71
86
|
|
|
72
87
|
Boundary.rpc(
|
|
73
88
|
GetStock,
|
|
74
89
|
() => ({ id: productId }),
|
|
75
|
-
(resource) => h.span([Stream.map(resource.value
|
|
90
|
+
(resource) => h.span([Stream.map(Subscribable.changes(resource.value), (s) => String(s.units))]),
|
|
76
91
|
{ fallback: h.p("loading…") },
|
|
77
92
|
);
|
|
78
93
|
```
|
|
79
94
|
|
|
80
|
-
Unlike the failure and suspense boundaries, `Boundary.rpc` is not self-contained
|
|
95
|
+
Unlike the failure and suspense boundaries, `Boundary.rpc` is not self-contained. It resolves through the ambient [`AppRpcClientTag`](https://weftui.dev/docs/reference/core#apprpcclienttag) seam that `@weftui/router` provides on both sides.
|
|
96
|
+
|
|
97
|
+
Its channel behavior is also distinct. The rpc's typed `error` schema joins the node's `E` (replayable through an enclosing failure boundary), while `render`'s `R` passes through untouched. The full model (the contract/handler split, the four lifecycles, typed-failure replay) is a **how-to**, not repeated here: [Load Data with RPC](https://weftui.dev/docs/how-to/load-data-with-rpc).
|
|
81
98
|
|
|
82
99
|
## One tree, three interceptors
|
|
83
100
|
|
|
84
|
-
The unifying idea: failure, async pending state, and server data are not three separate subsystems bolted onto the renderer. They are three **boundary nodes** in the one tree, each intercepting a different thing flowing through it
|
|
101
|
+
The unifying idea: failure, async pending state, and server data are not three separate subsystems bolted onto the renderer. They are three **boundary nodes** in the one tree, each intercepting a different thing flowing through it. Each has channel behavior you can read off its type.
|
|
102
|
+
|
|
103
|
+
That is why they nest freely. A `Boundary.catchTag` can wrap a `Boundary.rpc` to catch its typed failure. A `Boundary.suspend` can wrap async siblings that themselves contain rpc boundaries.
|
|
85
104
|
|
|
86
105
|
## See also
|
|
87
106
|
|
|
88
|
-
- [The Rendering Model](https://weftui.dev/docs/explanation/rendering-model)
|
|
89
|
-
- [`Boundary` API reference](https://weftui.dev/docs/reference/core#boundary-namespace)
|
|
90
|
-
- [Load Data with RPC](https://weftui.dev/docs/how-to/load-data-with-rpc)
|
|
91
|
-
- [Render on the Server](https://weftui.dev/docs/how-to/render-on-the-server)
|
|
107
|
+
- [The Rendering Model](https://weftui.dev/docs/explanation/rendering-model): why a boundary is just a node in a static tree
|
|
108
|
+
- [`Boundary` API reference](https://weftui.dev/docs/reference/core#boundary-namespace): every variant's signature and channel algebra
|
|
109
|
+
- [Load Data with RPC](https://weftui.dev/docs/how-to/load-data-with-rpc): the full `Boundary.rpc` walkthrough and its four lifecycles
|
|
110
|
+
- [Render on the Server](https://weftui.dev/docs/how-to/render-on-the-server): how suspense and rpc boundaries stream and hydrate
|
|
@@ -7,7 +7,9 @@ description: How h, h.fragment, and Component.gen / Component.make work; why Nod
|
|
|
7
7
|
|
|
8
8
|
# The Combinator API
|
|
9
9
|
|
|
10
|
-
Weft builds UI trees by calling builder functions. Because component return types stay as generic `Effect.Effect<ElementDescriptor, E, R>`, the error channel (`E`) and requirements channel (`R`) propagate through the entire tree
|
|
10
|
+
Weft builds UI trees by calling builder functions. Because component return types stay as generic `Effect.Effect<ElementDescriptor, E, R>`, the error channel (`E`) and requirements channel (`R`) propagate through the entire tree: visible to the type checker, satisfiable at the mount boundary.
|
|
11
|
+
|
|
12
|
+
JSX collapses every component's return type to an opaque `JSX.Element`, erasing both channels. The combinator API exists specifically to keep them intact.
|
|
11
13
|
|
|
12
14
|
## Nodes are Effects
|
|
13
15
|
|
|
@@ -23,13 +25,13 @@ Nodes are first-class Effects. Everything in the Effect ecosystem works on them
|
|
|
23
25
|
import { h } from "@weftui/core";
|
|
24
26
|
import { Effect } from "effect";
|
|
25
27
|
|
|
26
|
-
// yield* in Effect.gen
|
|
28
|
+
// yield* in Effect.gen: R propagates into the generator's context
|
|
27
29
|
const node = yield * h.div({ class: "container" }, "Hello");
|
|
28
30
|
|
|
29
|
-
// pipe
|
|
31
|
+
// pipe: chain Effect operators directly
|
|
30
32
|
const provided = pipe(h.div(userStream), Effect.provide(UserServiceLive));
|
|
31
33
|
|
|
32
|
-
// Effect.flatMap
|
|
34
|
+
// Effect.flatMap: sequence node creation with async logic
|
|
33
35
|
const card = pipe(
|
|
34
36
|
fetchCard(id),
|
|
35
37
|
Effect.flatMap((data) => h.div({ class: "card" }, data.title)),
|
|
@@ -78,7 +80,7 @@ Reactive prop values (any `Stream`, `Effect`, or `Subscribable`) contribute thei
|
|
|
78
80
|
```typescript
|
|
79
81
|
declare const colorStream: Stream.Stream<string, never, ThemeService>;
|
|
80
82
|
|
|
81
|
-
// Node<never, ThemeService
|
|
83
|
+
// Node<never, ThemeService>: R comes from the stream prop
|
|
82
84
|
const box = h.div({ style: { color: colorStream } }, "Hello");
|
|
83
85
|
```
|
|
84
86
|
|
|
@@ -130,7 +132,7 @@ declare const labelStream: Stream.Stream<string, never, I18nService>;
|
|
|
130
132
|
const btn = Button({ label: labelStream });
|
|
131
133
|
```
|
|
132
134
|
|
|
133
|
-
Components also accept an optional `children` argument, either as `readonly Renderable[]` or as a `(input) => readonly Renderable[]` function (render-prop pattern). `E`/`R` from children
|
|
135
|
+
Components also accept an optional `children` argument, either as `readonly Renderable[]` or as a `(input) => readonly Renderable[]` function (render-prop pattern). `E`/`R` from children, including the array returned by a function-children call, accumulate on the resulting node.
|
|
134
136
|
|
|
135
137
|
Without `Component`, a plain function's return type is fixed at definition time and does not reflect the caller's reactive prop types.
|
|
136
138
|
|
|
@@ -149,16 +151,16 @@ Boundary.suspend({ fallback: h.div({ class: "spinner" }, "Loading...") }, [
|
|
|
149
151
|
]);
|
|
150
152
|
```
|
|
151
153
|
|
|
152
|
-
The fallback is replaced atomically
|
|
154
|
+
The fallback is replaced atomically: either all children are visible or none are. This prevents partial flicker when multiple async siblings resolve at different times. The boundary's node type is `Node<ChildrenE, ChildrenR>`: the children's `E`/`R` channels accumulate onto it, exactly as they would for a plain `h.*` parent.
|
|
153
155
|
|
|
154
156
|
On the server, `renderToStreamHydratable` emits the fallback inline and appends patch scripts as children resolve. On the client, `hydrate` sees through `Boundary.suspend` boundaries and adopts the already-resolved DOM directly.
|
|
155
157
|
|
|
156
|
-
`Boundary.suspend` is one of the boundary combinators
|
|
158
|
+
`Boundary.suspend` is one of the boundary combinators. See the [core reference](https://weftui.dev/docs/reference/core#boundarysuspend) for the full `Boundary.*` surface, including the failure-catch variants and `Boundary.rpc`.
|
|
157
159
|
|
|
158
160
|
## See also
|
|
159
161
|
|
|
160
|
-
- [The Rendering Model](https://weftui.dev/docs/explanation/rendering-model)
|
|
161
|
-
- [Reactive Primitives](https://weftui.dev/docs/explanation/reactive-primitives)
|
|
162
|
-
- [Boundaries and Suspense](https://weftui.dev/docs/explanation/boundaries-and-suspense)
|
|
163
|
-
- [Author Components](https://weftui.dev/docs/how-to/author-components)
|
|
162
|
+
- [The Rendering Model](https://weftui.dev/docs/explanation/rendering-model): why a `Node` is an `Effect` and how the tree renders
|
|
163
|
+
- [Reactive Primitives](https://weftui.dev/docs/explanation/reactive-primitives): the `Source` vocabulary that reactive props and children accept
|
|
164
|
+
- [Boundaries and Suspense](https://weftui.dev/docs/explanation/boundaries-and-suspense): the boundary combinators as tree nodes
|
|
165
|
+
- [Author Components](https://weftui.dev/docs/how-to/author-components): `Component.gen` / `Component.make` in practice
|
|
164
166
|
- [`@weftui/core` reference](https://weftui.dev/docs/reference/core)
|