@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.
Files changed (38) hide show
  1. package/README.md +3 -3
  2. package/dist/client/index.d.ts +19 -19
  3. package/dist/client/index.js +16 -16
  4. package/dist/{compile-DySac7KN.d.ts → compile-DFlX4Atp.d.ts} +39 -59
  5. package/dist/{href-uvJ6b7zz.js → href-wIrP-_-3.js} +7 -10
  6. package/dist/index.d.ts +2 -2
  7. package/dist/index.js +2 -2
  8. package/dist/{outlet-saKduDFz.d.ts → outlet-CWpiNOxW.d.ts} +1 -1
  9. package/dist/{outlet-C9N4a4_F.js → outlet-Crd9mxZA.js} +49 -55
  10. package/dist/server/index.d.ts +9 -9
  11. package/dist/server/index.js +10 -10
  12. package/docs/explanation/boundaries-and-suspense.md +39 -20
  13. package/docs/explanation/combinator-api.md +14 -12
  14. package/docs/explanation/reactive-primitives.md +14 -14
  15. package/docs/explanation/rendering-model.md +20 -18
  16. package/docs/explanation/services-and-context.md +35 -21
  17. package/docs/how-to/add-routing.md +361 -60
  18. package/docs/how-to/author-components.md +42 -30
  19. package/docs/how-to/compose-behavior-and-markup.md +144 -0
  20. package/docs/how-to/handle-forms.md +6 -6
  21. package/docs/how-to/load-async-data.md +15 -13
  22. package/docs/how-to/load-data-with-rpc.md +34 -32
  23. package/docs/how-to/provide-services.md +20 -18
  24. package/docs/how-to/render-keyed-lists.md +14 -12
  25. package/docs/how-to/render-on-the-server.md +18 -14
  26. package/docs/how-to/show-navigation-progress.md +12 -10
  27. package/docs/how-to/split-routes-lazily.md +16 -14
  28. package/docs/how-to/style-reactively.md +13 -13
  29. package/docs/how-to/use-element-refs.md +10 -8
  30. package/docs/index.md +20 -18
  31. package/docs/reference/core.md +46 -42
  32. package/docs/reference/dom.md +274 -58
  33. package/docs/reference/router.md +69 -47
  34. package/docs/tutorial/01-your-first-app.md +7 -9
  35. package/docs/tutorial/02-reactivity.md +8 -6
  36. package/docs/tutorial/03-services-and-async.md +10 -6
  37. package/docs/tutorial/04-errors-and-server.md +14 -5
  38. 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 either no route is matched, or a requested
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 its `patternPrefix` is the
97
- * longest common path prefix of that layout's subtree leaves then assembles each
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 whose endpoints are GET endpoints — one per leaf at each leaf's full path
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** a lazy slot
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 the render
259
- * body and its module's deps is split into the chunk `load` resolves. The router invokes
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) the shape `component:` already accepts so its `E`/`R` channels
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 the subtree's real channels are aggregated
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 reads `yield* Router` then `currentMatch.get` and returns the
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 (the cast is sound the picked
315
- * subset is the `Type` side of `fields`). Fails with a {@link RouterParamsError}
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 match = yield* (yield* Router).currentMatch.get;
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 match = yield* (yield* Router).currentMatch.get;
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(currentMatch.get, select),
355
- changes: Stream.map(currentMatch.changes, select)
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 `currentMatch.changes`. It
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)).changes]`
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).changes]` to render pending UI during a
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 the resolved-commit
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: called by `navigate` /
423
- * popstate with the pre-run's `Exit`, immediately before the URL ref is set,
424
- * so the outlet emission triggered by that commit finds it (AC-R1/AC-R2).
425
- * Overwrites any stale entry latest-wins already guarantees only the newest
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), else
434
- * `undefined` a URL mismatch (stale entry) or an absent slot (server render,
435
- * hydration, non-navigation re-emission) falls through to the ordinary slot
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 `currentMatch.get` resolves to the **target** match the
448
- * URL ref has not moved yet, and one-shot reads (`Router.params`,
449
- * `Router.query`, `currentMatch.get`) inside the pre-running component body
450
- * must decode the destination, not the page being left. `currentMatch.changes`
451
- * (and `navigate` / `httpApiClient` / `navigating`) delegate to the live
452
- * service, so reactive subscriptions — which occur at render/mount time,
453
- * post-commit — observe the committed match onward.
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.changes
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
- * target match's handler-arg props (`{ path, query }` exactly what
467
- * `renderLevel` passes), under the {@link stageMatch | staged view}, and
468
- * captures the outcome as an `Exit` (AC-R1/AC-R7). The returned effect never
469
- * fails — failures are folded into the `Exit` for stash-and-replay but it
470
- * is **interruptible**: a superseding navigation interrupts the whole pre-run
471
- * fiber (AC-R6). Requires the caller's runtime context (the `RouterLive`
472
- * layer's — `Router`, `AppRpcClientTag`, app services), which is what makes
473
- * the pre-run possible at all (Feasibility §1).
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.changes, Stream.map((match) => [keyOf(index, match), match]), Stream.changesWith((a, b) => a[0] === b[0]), Stream.map(([, match]) => renderLevel(def, router, index, match)));
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
@@ -1,4 +1,4 @@
1
- import { S as Router, i as RouterDef, l as ComponentSlot } from "../compile-DySac7KN.js";
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 a no-match (platform's
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 so there is no render-time status side-channel.
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) `toWebHandler` serves it at `POST /_eui/rpc`, and an in-process
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} its aggregate `R` minus
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` then `POST /_eui/rpc` is not
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 `Router` and `Router.Outlet` (provided by the outlet /
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 a loosely-typed `RouterDef<any, any>`. */
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
@@ -1,4 +1,4 @@
1
- import { a as Router, f as isRouterNotFound, n as outletNode, u as RouterNotFound } from "../outlet-C9N4a4_F.js";
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 the rpc
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 a `Boundary.rpc` in an
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 with `app` spliced via `Router.Outlet` to a
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 which replaces the whole outlet subtree so the
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) matching what the client outlet produces for an unmatched URL — at
77
- * HTTP 404.
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 the client and the spec read it) extended with a second `"fallback"`
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 when nothing matches to the
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 failure-catch variants, Boundary.suspend, and Boundary.rpc, and how their E/R channels behave.
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 an error, a pending async child, or a server-resolved value and decides what the DOM shows in its place. Because a boundary is itself a `Node<E, R>` ([nodes are Effects](https://weftui.dev/docs/explanation/rendering-model)), it composes exactly like any other element: you nest it, and its children's channels flow through it under a transformation the boundary defines.
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`: it wraps children and, if one of them fails, renders a fallback instead of letting the failure propagate to the mount.
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: `catchTag("Foo", …)` removes `Foo` from the children's `E` and adds whatever the fallback needs so the type of the boundary node reflects exactly which failures are still live and which were handled. 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.
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 an attribute, child, or list stream, or a hydrated equivalent keeps running for the lifetime of its scope, and it can still fail later: a `Stream` backing a `Boundary.rpc` resource might raise `RouterNotFound` after a client-side navigation, for instance. If a `BoundaryContext` encloses the region, the failure routes to it exactly as above, and the boundary's fallback swaps in.
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
- 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. When that exit is a failure whose cause is not interruption-only, Weft reports it explicitly via `Effect.logError(exit.cause)`, 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.
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: rather than leaving the failure to whatever the Effect runtime would otherwise do with an unobserved fiber exit, Weft observes and logs it itself, so visibility is 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. 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.
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, then swaps atomically either everything is visible or nothing is. This prevents partial flicker when sibling async regions resolve at different times.
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>` 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_.
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; on the client, `hydrate` sees through the boundary and adopts the already-resolved DOM directly.
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 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).
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: it resolves one `Rpc` on the server, serializes the result into the HTML, replays it on the client during `hydrate` (no second request, no flash), and then keeps the region live for `refetch`. 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**, and instead of a children array it takes a `render` function that receives a reactive [`Resource`](https://weftui.dev/docs/reference/core#resourcea).
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.changes, (s) => String(s.units))]),
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: it resolves through the ambient [`AppRpcClientTag`](https://weftui.dev/docs/reference/core#apprpcclienttag) seam that `@weftui/router` provides on both sides. 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).
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, each with channel behavior you can read off its type. That is why they nest freely — a `Boundary.catchTag` can wrap a `Boundary.rpc` to catch its typed failure, and a `Boundary.suspend` can wrap async siblings that themselves contain rpc boundaries.
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) why a boundary is just a node in a static tree
89
- - [`Boundary` API reference](https://weftui.dev/docs/reference/core#boundary-namespace) every variant's signature and channel algebra
90
- - [Load Data with RPC](https://weftui.dev/docs/how-to/load-data-with-rpc) the full `Boundary.rpc` walkthrough and its four lifecycles
91
- - [Render on the Server](https://weftui.dev/docs/how-to/render-on-the-server) how suspense and rpc boundaries stream and hydrate
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 visible to the type checker, satisfiable at the mount boundary. JSX collapses every component's return type to an opaque `JSX.Element`, erasing both channels; the combinator API exists specifically to keep them intact.
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 R propagates into the generator's context
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 chain Effect operators directly
31
+ // pipe: chain Effect operators directly
30
32
  const provided = pipe(h.div(userStream), Effect.provide(UserServiceLive));
31
33
 
32
- // Effect.flatMap sequence node creation with async logic
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> R comes from the stream prop
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 including the array returned by a function-children call accumulate on the resulting node.
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 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.
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 see the [core reference](https://weftui.dev/docs/reference/core#boundarysuspend) for the full `Boundary.*` surface, including the failure-catch variants and `Boundary.rpc`.
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) why a `Node` is an `Effect` and how the tree renders
161
- - [Reactive Primitives](https://weftui.dev/docs/explanation/reactive-primitives) the `Source` vocabulary that reactive props and children accept
162
- - [Boundaries and Suspense](https://weftui.dev/docs/explanation/boundaries-and-suspense) the boundary combinators as tree nodes
163
- - [Author Components](https://weftui.dev/docs/how-to/author-components) `Component.gen` / `Component.make` in practice
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)