@weftui/core 0.29.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/dist/{index-4cTlhojA.d.ts → index-CX9uEejU.d.ts} +25 -20
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/types/index.d.ts +1 -1
- package/docs/explanation/boundaries-and-suspense.md +2 -2
- package/docs/how-to/add-routing.md +306 -29
- package/docs/how-to/author-components.md +2 -2
- package/docs/how-to/load-data-with-rpc.md +4 -4
- package/docs/how-to/render-keyed-lists.md +5 -5
- package/docs/how-to/render-on-the-server.md +2 -2
- package/docs/how-to/show-navigation-progress.md +2 -2
- package/docs/reference/core.md +2 -2
- package/docs/reference/router.md +2 -2
- package/package.json +1 -1
|
@@ -1,10 +1,9 @@
|
|
|
1
|
-
import { Cause, Effect, Option, Scope, Stream, SubscriptionRef } from "effect";
|
|
1
|
+
import { Cause, Effect, Option, Scope, Stream, SubscriptionRef, Types as Types$1 } from "effect";
|
|
2
2
|
declare namespace index_d_exports {
|
|
3
|
-
export { Subscribable, TypeId, changes, get, isSubscribable, make };
|
|
3
|
+
export { Subscribable, TypeId, Variance, changes, get, isSubscribable, make };
|
|
4
4
|
}
|
|
5
5
|
/**
|
|
6
|
-
* Unique brand identifying a {@link Subscribable}
|
|
7
|
-
* `get`/`changes` channels are held. Effect 4 dropped its own
|
|
6
|
+
* Unique brand identifying a {@link Subscribable}. Effect 4 dropped its own
|
|
8
7
|
* `Subscribable`/`Readable` modules, so Weft carries this reactivity interface
|
|
9
8
|
* locally; the string brand mirrors Effect 4's `"~effect/*"` TypeId convention
|
|
10
9
|
* (e.g. `SubscriptionRef`) and backs the {@link isSubscribable} guard.
|
|
@@ -15,22 +14,27 @@ declare const TypeId = "~@weftui/core/Subscribable";
|
|
|
15
14
|
*/
|
|
16
15
|
type TypeId = typeof TypeId;
|
|
17
16
|
/**
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
|
|
21
|
-
|
|
17
|
+
* Phantom variance carrier for {@link Subscribable}; all three channels are
|
|
18
|
+
* covariant, matching the `Effect`/`Stream` channels the value wraps.
|
|
19
|
+
*/
|
|
20
|
+
interface Variance<out A, out E, out R> {
|
|
21
|
+
readonly _A: Types$1.Covariant<A>;
|
|
22
|
+
readonly _E: Types$1.Covariant<E>;
|
|
23
|
+
readonly _R: Types$1.Covariant<R>;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* A hot, await-first reactive value: a current value plus a stream of every
|
|
27
|
+
* value (including the current one). The interface is brand-only; read it
|
|
28
|
+
* through the {@link get} / {@link changes} module accessors, which mirror
|
|
29
|
+
* Effect 4's `SubscriptionRef.get` / `SubscriptionRef.changes` so a
|
|
22
30
|
* `Subscribable` and a `SubscriptionRef` read the same way at a call site.
|
|
23
31
|
*
|
|
24
|
-
* This is Weft's local replacement for
|
|
25
|
-
* public API so `Source`, `Boundary`, and the DOM
|
|
26
|
-
*
|
|
32
|
+
* This is Weft's local replacement for the `Subscribable` module Effect 4
|
|
33
|
+
* removed, preserved as public API so `Source`, `Boundary`, and the DOM
|
|
34
|
+
* renderers share one reactivity surface.
|
|
27
35
|
*/
|
|
28
|
-
interface Subscribable<A, E = never, R = never> {
|
|
29
|
-
readonly [TypeId]:
|
|
30
|
-
/** Read the current value; also reachable via the {@link get} accessor. */
|
|
31
|
-
readonly get: Effect.Effect<A, E, R>;
|
|
32
|
-
/** Stream of every value; also reachable via the {@link changes} accessor. */
|
|
33
|
-
readonly changes: Stream.Stream<A, E, R>;
|
|
36
|
+
interface Subscribable<out A, out E = never, out R = never> {
|
|
37
|
+
readonly [TypeId]: Variance<A, E, R>;
|
|
34
38
|
}
|
|
35
39
|
/**
|
|
36
40
|
* Build a {@link Subscribable} from a `get` effect and a `changes` stream. The
|
|
@@ -43,13 +47,14 @@ declare const make: <A, E = never, R = never>(options: {
|
|
|
43
47
|
}) => Subscribable<A, E, R>;
|
|
44
48
|
/**
|
|
45
49
|
* Read the current value of a {@link Subscribable} as an `Effect`. Mirrors
|
|
46
|
-
* `SubscriptionRef.get
|
|
47
|
-
*
|
|
50
|
+
* `SubscriptionRef.get`; the only way to read the value, the interface exposes
|
|
51
|
+
* no members.
|
|
48
52
|
*/
|
|
49
53
|
declare const get: <A, E, R>(self: Subscribable<A, E, R>) => Effect.Effect<A, E, R>;
|
|
50
54
|
/**
|
|
51
55
|
* The `Stream` of every value of a {@link Subscribable}, starting with the
|
|
52
|
-
* current one. Mirrors `SubscriptionRef.changes
|
|
56
|
+
* current one. Mirrors `SubscriptionRef.changes`; the only way to observe
|
|
57
|
+
* changes, the interface exposes no members.
|
|
53
58
|
*/
|
|
54
59
|
declare const changes: <A, E, R>(self: Subscribable<A, E, R>) => Stream.Stream<A, E, R>;
|
|
55
60
|
/**
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { D as Subscribable, E as Source, O as index_d_exports, T as NoPropValue, a as SVGElements, r as Renderable, s as HTMLElements, t as ElementDescriptor } from "./index-
|
|
1
|
+
import { D as Subscribable, E as Source, O as index_d_exports, T as NoPropValue, a as SVGElements, r as Renderable, s as HTMLElements, t as ElementDescriptor } from "./index-CX9uEejU.js";
|
|
2
2
|
import { Cause, Context, Effect, Filter, Option, Stream } from "effect";
|
|
3
3
|
import { Rpc } from "effect/unstable/rpc";
|
|
4
4
|
//#region src/combinator/types.d.ts
|
package/dist/index.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{t as e}from"./rolldown-runtime-DK3Fl9T5.js";import{Cause as t,Context as n,Data as r,Deferred as i,Effect as a,Filter as o,Option as s,Predicate as c,Result as l,Stream as u,SubscriptionRef as d,identity as f,pipe as p}from"effect";const m=Symbol.for(`@weftui/core/ElementDescriptor`);function h(e){let t=a.succeed(e);return Object.defineProperty(t,m,{value:e,enumerable:!1}),t}function g(e){return a.isEffect(e)&&m in e?e[m]:void 0}var _=e({FAILURE_BOUNDARY:()=>v,SERVER_BOUNDARY:()=>b,SUSPENSE_BOUNDARY:()=>y,catch:()=>S,catchCause:()=>C,catchFilter:()=>E,catchIf:()=>D,catchTag:()=>w,catchTags:()=>T,rpc:()=>k,suspend:()=>O});const v=Symbol.for(`weft/FAILURE_BOUNDARY`),y=Symbol.for(`weft/SUSPENSE_BOUNDARY`),b=Symbol.for(`weft/SERVER_BOUNDARY`);function x(e,t){return h({type:v,props:{match:e,children:t}})}function S(e,n){return x(n=>{let r=t.findErrorOption(n);return s.isSome(r)?e.fallback(r.value):null},n)}function C(e,t){return x(t=>e.fallback(t),t)}function w(e,n){return x(n=>{let r=t.findErrorOption(n);if(s.isNone(r))return null;let i=r.value;return i._tag===e.tag?e.fallback(i):null},n)}function T(e,n){return x(n=>{let r=t.findErrorOption(n);if(s.isNone(r))return null;let i=r.value,a=i._tag;if(a===void 0)return null;let o=e[a];return o?o(i):null},n)}function E(e,n,r){return x(r=>{let i=t.findErrorOption(r);if(s.isNone(i))return null;let a=e(i.value);return l.isSuccess(a)?n(a.success):null},r)}function D(e,n){return x(n=>{let r=t.findErrorOption(n);if(s.isNone(r))return null;let i=r.value;return e.predicate(i)?e.fallback(i):null},n)}function O(e,t){return h({type:y,props:{...e,children:t}})}function k(e,t,n,r){let i=e;return h({type:b,props:{tag:i._tag,payloadSchema:i.payloadSchema,successSchema:i.successSchema,errorSchema:i.errorSchema,payload:t,render:n,fallback:r?.fallback}})}var A=class extends n.Service()(`@weftui/core/AppRpcClient`){};const j=e=>()=>n.Service()(e);function M(e){return typeof e==`object`&&!!e&&u.TypeId in e}function N(e){return M(e)?e:a.isEffect(e)?u.fromEffect(e):u.make(e)}var P=e({TypeId:()=>F,changes:()=>
|
|
1
|
+
import{t as e}from"./rolldown-runtime-DK3Fl9T5.js";import{Cause as t,Context as n,Data as r,Deferred as i,Effect as a,Filter as o,Option as s,Predicate as c,Result as l,Stream as u,SubscriptionRef as d,identity as f,pipe as p}from"effect";const m=Symbol.for(`@weftui/core/ElementDescriptor`);function h(e){let t=a.succeed(e);return Object.defineProperty(t,m,{value:e,enumerable:!1}),t}function g(e){return a.isEffect(e)&&m in e?e[m]:void 0}var _=e({FAILURE_BOUNDARY:()=>v,SERVER_BOUNDARY:()=>b,SUSPENSE_BOUNDARY:()=>y,catch:()=>S,catchCause:()=>C,catchFilter:()=>E,catchIf:()=>D,catchTag:()=>w,catchTags:()=>T,rpc:()=>k,suspend:()=>O});const v=Symbol.for(`weft/FAILURE_BOUNDARY`),y=Symbol.for(`weft/SUSPENSE_BOUNDARY`),b=Symbol.for(`weft/SERVER_BOUNDARY`);function x(e,t){return h({type:v,props:{match:e,children:t}})}function S(e,n){return x(n=>{let r=t.findErrorOption(n);return s.isSome(r)?e.fallback(r.value):null},n)}function C(e,t){return x(t=>e.fallback(t),t)}function w(e,n){return x(n=>{let r=t.findErrorOption(n);if(s.isNone(r))return null;let i=r.value;return i._tag===e.tag?e.fallback(i):null},n)}function T(e,n){return x(n=>{let r=t.findErrorOption(n);if(s.isNone(r))return null;let i=r.value,a=i._tag;if(a===void 0)return null;let o=e[a];return o?o(i):null},n)}function E(e,n,r){return x(r=>{let i=t.findErrorOption(r);if(s.isNone(i))return null;let a=e(i.value);return l.isSuccess(a)?n(a.success):null},r)}function D(e,n){return x(n=>{let r=t.findErrorOption(n);if(s.isNone(r))return null;let i=r.value;return e.predicate(i)?e.fallback(i):null},n)}function O(e,t){return h({type:y,props:{...e,children:t}})}function k(e,t,n,r){let i=e;return h({type:b,props:{tag:i._tag,payloadSchema:i.payloadSchema,successSchema:i.successSchema,errorSchema:i.errorSchema,payload:t,render:n,fallback:r?.fallback}})}var A=class extends n.Service()(`@weftui/core/AppRpcClient`){};const j=e=>()=>n.Service()(e);function M(e){return typeof e==`object`&&!!e&&u.TypeId in e}function N(e){return M(e)?e:a.isEffect(e)?u.fromEffect(e):u.make(e)}var P=e({TypeId:()=>F,changes:()=>z,get:()=>R,isSubscribable:()=>B,make:()=>L});const F=`~@weftui/core/Subscribable`,I=e=>e,L=e=>({[F]:F,get:e.get,changes:e.changes}),R=e=>I(e).get,z=e=>I(e).changes,B=e=>c.hasProperty(e,F);var V=class extends r.TaggedError(`NoPropValue`){};let H;(function(e){function n(e,n){return B(e)?a.succeed(e):M(e)?a.gen(function*(){let r=yield*d.make(s.none()),c=yield*i.make(),l=yield*i.make(),m=p(u.runForEach(e,e=>p(d.set(r,s.some(e)),a.andThen(i.succeed(c,e)),a.asVoid)),a.ensuring(p(d.get(r),a.flatMap(e=>s.isNone(e)?a.asVoid(i.fail(c,new V({key:n}))):a.void))),a.onError(e=>t.hasInterruptsOnly(e)?a.void:a.asVoid(i.failCause(l,e))));yield*a.forkScoped(m);let h=p(d.get(r),a.flatMap(e=>s.isSome(e)?a.succeed(e.value):i.await(c))),g=p(d.changes(r),u.filterMap(o.fromPredicateOption(f)),u.interruptWhen(i.await(l)));return L({get:h,changes:g})}):a.isEffect(e)?a.gen(function*(){let t=yield*a.cached(e),n=u.fromEffect(t);return L({get:t,changes:n})}):a.succeed(L({get:a.succeed(e),changes:u.make(e)}))}e.toSubscribable=n})(H||={});const U=Symbol(`@weftui/core/fragment`);function W(e){return((t,n)=>{let r={},i;return Array.isArray(t)||typeof t==`string`||typeof t==`number`?i=t:t!==void 0&&(r=t,n!==void 0&&(i=n)),h({type:e,props:i===void 0?r:{...r,children:i}})})}function G(e=new Map){return new Proxy({fragment(e){return h({type:U,props:{children:e}})}},{get(t,n){return n in t?t[n]:e.get(n)??e.set(n,W(n)).get(n)}})}const K=G(new Map),q=Symbol(`@weftui/core/list`);let J;(function(e){function t(e,t){return h({type:q,props:{of:e.of,by:e.by,render:t}})}e.each=t})(J||={});let Y;(function(e){function t(e){return(t,n=[])=>a.gen(function*(){return yield*e(t,n)})}e.gen=t;function n(e){return e}e.make=n})(Y||={});export{A as AppRpcClientTag,_ as Boundary,Y as Component,v as FAILURE_BOUNDARY,U as FRAGMENT,q as LIST,J as List,V as NoPropValue,b as SERVER_BOUNDARY,y as SUSPENSE_BOUNDARY,j as ServerTag,H as Source,P as Subscribable,h as elementNode,g as getElementDescriptor,K as h,M as isStream,N as toStream};
|
package/dist/types/index.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { C as StyleAttributeValue, S as HTMLAttributeSource, _ as HTMLFormMethod, a as SVGElements, b as HTMLReferrerPolicy, c as HTMLRole, d as EventHandlerFn, f as HTMLAutocapitalize, g as HTMLFormEncType, h as HTMLDir, i as SVGAttributes, l as DOMAttributes, m as HTMLCrossorigin, n as ElementType, o as HTMLAttributes, p as HTMLAutocomplete, r as Renderable, s as HTMLElements, t as ElementDescriptor, u as EventHandler, v as HTMLIframeSandbox, w as StyleProperties, x as AriaAttributes, y as HTMLLinkAs } from "../index-
|
|
1
|
+
import { C as StyleAttributeValue, S as HTMLAttributeSource, _ as HTMLFormMethod, a as SVGElements, b as HTMLReferrerPolicy, c as HTMLRole, d as EventHandlerFn, f as HTMLAutocapitalize, g as HTMLFormEncType, h as HTMLDir, i as SVGAttributes, l as DOMAttributes, m as HTMLCrossorigin, n as ElementType, o as HTMLAttributes, p as HTMLAutocomplete, r as Renderable, s as HTMLElements, t as ElementDescriptor, u as EventHandler, v as HTMLIframeSandbox, w as StyleProperties, x as AriaAttributes, y as HTMLLinkAs } from "../index-CX9uEejU.js";
|
|
2
2
|
export { AriaAttributes, DOMAttributes, ElementDescriptor, ElementType, EventHandler, EventHandlerFn, HTMLAttributeSource, HTMLAttributes, HTMLAutocapitalize, HTMLAutocomplete, HTMLCrossorigin, HTMLDir, HTMLElements, HTMLFormEncType, HTMLFormMethod, HTMLIframeSandbox, HTMLLinkAs, HTMLReferrerPolicy, HTMLRole, Renderable, SVGAttributes, SVGElements, StyleAttributeValue, StyleProperties };
|
|
@@ -81,13 +81,13 @@ On the server, `renderToStreamHydratable` emits the fallback inline and appends
|
|
|
81
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).
|
|
82
82
|
|
|
83
83
|
```typescript
|
|
84
|
-
import { Boundary, h } from "@weftui/core";
|
|
84
|
+
import { Boundary, h, Subscribable } from "@weftui/core";
|
|
85
85
|
import { Stream } from "effect";
|
|
86
86
|
|
|
87
87
|
Boundary.rpc(
|
|
88
88
|
GetStock,
|
|
89
89
|
() => ({ id: productId }),
|
|
90
|
-
(resource) => h.span([Stream.map(resource.value
|
|
90
|
+
(resource) => h.span([Stream.map(Subscribable.changes(resource.value), (s) => String(s.units))]),
|
|
91
91
|
{ fallback: h.p("loading…") },
|
|
92
92
|
);
|
|
93
93
|
```
|
|
@@ -9,10 +9,10 @@ description: "@weftui/router: universal nested routing, Router.route / Router.la
|
|
|
9
9
|
|
|
10
10
|
`@weftui/router` is a universal (server + client) nested router for Weft. It maps a URL to a rendered `Node` tree on both sides:
|
|
11
11
|
|
|
12
|
-
- **Server**: matches an incoming request path, renders
|
|
13
|
-
- **Client**: matches
|
|
12
|
+
- **Server**: matches an incoming request path, renders to hydratable HTML.
|
|
13
|
+
- **Client**: matches reactively via the History API.
|
|
14
14
|
|
|
15
|
-
The package
|
|
15
|
+
The package exports a shared (universal) root, a `./client` entry, and a `./server` entry.
|
|
16
16
|
|
|
17
17
|
```bash
|
|
18
18
|
npm install @weftui/router
|
|
@@ -20,9 +20,22 @@ npm install @weftui/router
|
|
|
20
20
|
|
|
21
21
|
## The mental model
|
|
22
22
|
|
|
23
|
-
A route's **component is its handler**. A page is a component that renders, and its `component` slot is invoked at render time
|
|
23
|
+
A route's **component is its handler**. A page is a component that renders, and its `component` slot is invoked at render time.
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
```typescript
|
|
26
|
+
const homeRoute = Router.route("", { component: Home });
|
|
27
|
+
const aboutRoute = Router.route("about", { component: About });
|
|
28
|
+
const userRoute = Router.route("users/:id", {
|
|
29
|
+
path: { id: Schema.NumberFromString },
|
|
30
|
+
component: ({ path }) => h.h1(`User ${path.id}`),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const App = Router.router(Router.layout({ component: Shell }, [homeRoute, aboutRoute, userRoute]), {
|
|
34
|
+
notFound: () => h.h1("404"),
|
|
35
|
+
});
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
You author a **nested route tree** with namespaced combinators and seal it once:
|
|
26
39
|
|
|
27
40
|
| Combinator | Builds |
|
|
28
41
|
| ----------------------------------------------------- | ---------------------------------------------------------------- |
|
|
@@ -34,7 +47,7 @@ The tree is the source of truth. The same sealed `RouterDef` drives both server
|
|
|
34
47
|
|
|
35
48
|
## Authoring routes
|
|
36
49
|
|
|
37
|
-
Every
|
|
50
|
+
Every **`ComponentSlot`** produces a `Node` when called. Use [`Component.make` / `Component.gen`](https://weftui.dev/docs/how-to/author-components) (or a plain `() => Node` thunk). The router invokes it at render time, which lets `href(…)` resolve after the tree is compiled.
|
|
38
51
|
|
|
39
52
|
```typescript
|
|
40
53
|
import { Component, h } from "@weftui/core";
|
|
@@ -54,10 +67,13 @@ const User = Router.route("users/:id", {
|
|
|
54
67
|
});
|
|
55
68
|
```
|
|
56
69
|
|
|
57
|
-
- **`segment`** is relative to the parent and may contain `:name` path-param placeholders (e.g. `"users/:id"`). A leading/trailing `/` is tolerated.
|
|
58
|
-
|
|
70
|
+
- **`segment`** is relative to the parent and may contain `:name` path-param placeholders (e.g. `"users/:id"`). A leading/trailing `/` is tolerated.
|
|
71
|
+
|
|
72
|
+
Each leaf carries its full relative path (e.g. `"users/:id/settings"`).
|
|
59
73
|
|
|
60
|
-
|
|
74
|
+
**`path` / `query`** are `Schema.Struct.Fields` (a record of `name → Schema`), declared **only on routes**. The compiler covers every `:name` placeholder in `pathSchema`, defaulting to `Schema.String` when a placeholder has no declared field. Query fields are optional by default.
|
|
75
|
+
|
|
76
|
+
> Authoring components with `Component.make` / `Component.gen` keeps every slot fully typed: Each component's `E`/`R` channels aggregate up through `Router.layout` / `Router.router` into the sealed `RouterDef`.
|
|
61
77
|
|
|
62
78
|
## Reading the match: handler-arg props vs. injection
|
|
63
79
|
|
|
@@ -97,9 +113,29 @@ const UserShell = Component.gen(function* () {
|
|
|
97
113
|
|
|
98
114
|
`Router.params(fields)` / `Router.query(fields)` read the live match and pick the requested `fields` keys (already decoded by the matcher, so no re-validation). They return the typed values. When no route matches, they fail with a tagged [`RouterParamsError`](#errors) carrying `source: "path" | "query"` and the requested `keys`.
|
|
99
115
|
|
|
100
|
-
That error bubbles into the app node's aggregate `E`, so a user may recover it with `Boundary.catchTag(
|
|
116
|
+
That error bubbles into the app node's aggregate `E`, so a user may recover it with `Boundary.catchTag(…)`.
|
|
117
|
+
|
|
118
|
+
### Reactive accessors: `paramsStream` / `queryStream`
|
|
119
|
+
|
|
120
|
+
`Router.paramsStream(fields)` / `Router.queryStream(fields)` are the reactive counterparts of `params` / `query`. Each resolves a `Subscribable` derived from `Subscribable.changes(currentMatch)`, so a component can update **in place** even when the same leaf stays mounted, the case a query-only navigation (`setQuery` / `patchQuery`, see [Programmatic navigation](#programmatic-navigation)) produces and a snapshot `Router.query` would miss:
|
|
121
|
+
|
|
122
|
+
```typescript
|
|
123
|
+
import { Component, h, Subscribable } from "@weftui/core";
|
|
124
|
+
import { Router } from "@weftui/router";
|
|
125
|
+
import { Schema, Stream } from "effect";
|
|
126
|
+
|
|
127
|
+
const sortQuery = { sort: Schema.optional(Schema.String) };
|
|
128
|
+
|
|
129
|
+
const ProductsPage = Component.gen(function* () {
|
|
130
|
+
const query = yield* Router.queryStream(sortQuery);
|
|
131
|
+
return yield* h.section([
|
|
132
|
+
h.h2("Products"),
|
|
133
|
+
h.p(["sort: ", Stream.map(Subscribable.changes(query), (q) => q.sort ?? "none")]),
|
|
134
|
+
]);
|
|
135
|
+
});
|
|
136
|
+
```
|
|
101
137
|
|
|
102
|
-
|
|
138
|
+
A `NotFound` match yields the empty subset rather than failing, so the stream stays live across navigations.
|
|
103
139
|
|
|
104
140
|
## Layouts and the outlet
|
|
105
141
|
|
|
@@ -121,7 +157,30 @@ A layout owns **no `segment` or `path`**; all path structure lives on routes. A
|
|
|
121
157
|
|
|
122
158
|
### Layout persistence
|
|
123
159
|
|
|
124
|
-
Each nesting level renders as a reactive stream child keyed by `(pattern + the param values that level depends on)` and `dedupe`d. An unchanged ancestor layout therefore **stays mounted** across a navigation that only changes a deeper level
|
|
160
|
+
Each nesting level renders as a reactive stream child keyed by `(pattern + the param values that level depends on)` and `dedupe`d. An unchanged ancestor layout therefore **stays mounted** across a navigation that only changes a deeper level: its DOM identity and any local state (a `SubscriptionRef`, a scroll position) survive while only the inner outlet swaps.
|
|
161
|
+
|
|
162
|
+
```typescript
|
|
163
|
+
import { Component, h } from "@weftui/core";
|
|
164
|
+
import { Router } from "@weftui/router";
|
|
165
|
+
import { Clock } from "effect";
|
|
166
|
+
|
|
167
|
+
// UserShell's body runs once per distinct `:id`. Navigating between
|
|
168
|
+
// /users/1/settings and /users/1/posts doesn't change `:id`, so this
|
|
169
|
+
// instance (and `sessionStart`) is never recreated: only `outlet` swaps.
|
|
170
|
+
const UserShell = Component.gen(function* () {
|
|
171
|
+
const { id } = yield* Router.params(idParam);
|
|
172
|
+
const outlet = yield* Router.Outlet;
|
|
173
|
+
const sessionStart = yield* Clock.currentTimeMillis;
|
|
174
|
+
|
|
175
|
+
return yield* h.div({ class: "user" }, [
|
|
176
|
+
h.p(`shell mounted at ${sessionStart}`),
|
|
177
|
+
h.h1(`User ${id}`),
|
|
178
|
+
outlet,
|
|
179
|
+
]);
|
|
180
|
+
});
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
Navigate from `/users/1/settings` to `/users/1/posts` and the mounted timestamp stays the same; navigate to `/users/2/settings` and it re-renders, since `:id` changed.
|
|
125
184
|
|
|
126
185
|
## Sealing the tree
|
|
127
186
|
|
|
@@ -175,7 +234,7 @@ Router.route("users/:id", {
|
|
|
175
234
|
});
|
|
176
235
|
```
|
|
177
236
|
|
|
178
|
-
`RouterNotFound` is exported, so a `Boundary.catchTag(
|
|
237
|
+
`RouterNotFound` is exported, so a `Boundary.catchTag(…)` placed inside a subtree overrides the app-level fallback for that subtree. The router's internal boundary is outermost, so a nearer user boundary wins.
|
|
179
238
|
|
|
180
239
|
> **`Schema.NumberFromString` gotcha.** Decoding no longer fails on a non-numeric segment: `/users/abc` decodes `id` to `NaN` instead of missing the route. A leaf that guards a numeric param must check `Number.isFinite(id)` itself (as above). Relying on the schema alone to 404 non-numeric input no longer works.
|
|
181
240
|
|
|
@@ -183,21 +242,103 @@ Router.route("users/:id", {
|
|
|
183
242
|
|
|
184
243
|
On the client, provide the `Router` via `RouterLive(def)` and render `RouterApp(def)`. `RouterLive` is a **scoped layer**: it owns the `popstate` listener and the same-origin link-click interceptor, so it must outlive the mount.
|
|
185
244
|
|
|
186
|
-
Give it to `WeftApp.make`. The app runtime owns it for the app's lifetime, built lazily on first hydrate and released only at `WeftApp.dispose`. Do not wrap `Effect.provide` around the mount/hydrate call; services come exclusively from the app layer.
|
|
245
|
+
Give it to `WeftApp.make`. The app runtime owns it for the app's lifetime, built lazily on first mount/hydrate and released only at `WeftApp.dispose`. Do not wrap `Effect.provide` around the mount/hydrate call; services come exclusively from the app layer. `RouterLive`'s only required argument is the sealed `App`; a second `options` argument adds an rpc group or a custom `baseUrl` when needed (see [`Boundary.rpc` interplay](#boundaryrpc-interplay)).
|
|
246
|
+
|
|
247
|
+
```typescript
|
|
248
|
+
const app = WeftApp.make(RouterLive(App));
|
|
249
|
+
void Effect.runPromise(WeftApp.mount(app, RouterApp(App), root));
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
### Client-only app
|
|
253
|
+
|
|
254
|
+
A complete, no-SSR app: three routes under one `Shell` layout, mounted directly into an empty `#root`. This is the whole file set, copy/paste runnable in a `vite` + `@weftui/router` project.
|
|
255
|
+
|
|
256
|
+
```html
|
|
257
|
+
<!-- index.html -->
|
|
258
|
+
<!doctype html>
|
|
259
|
+
<html lang="en">
|
|
260
|
+
<head>
|
|
261
|
+
<meta charset="UTF-8" />
|
|
262
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
263
|
+
<title>Weft routing demo</title>
|
|
264
|
+
</head>
|
|
265
|
+
<body>
|
|
266
|
+
<div id="root"></div>
|
|
267
|
+
<script type="module" src="/src/main.ts"></script>
|
|
268
|
+
</body>
|
|
269
|
+
</html>
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
```typescript
|
|
273
|
+
// src/app.ts
|
|
274
|
+
/**
|
|
275
|
+
* Client-only routing demo: a Shell layout with Home, About, and a dynamic
|
|
276
|
+
* User page, sealed into a single RouterDef. Side-effect-free (no mount call),
|
|
277
|
+
* so `main.ts` and any test can import `App` directly.
|
|
278
|
+
*/
|
|
279
|
+
import { Component, h } from "@weftui/core";
|
|
280
|
+
import { href, notFound, Router } from "@weftui/router";
|
|
281
|
+
import { Schema } from "effect";
|
|
282
|
+
|
|
283
|
+
const idParam = { id: Schema.NumberFromString };
|
|
284
|
+
|
|
285
|
+
const homeRoute = Router.route("", {
|
|
286
|
+
component: Component.make(() => h.section({ id: "page" }, [h.h2("Home")])),
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
const aboutRoute = Router.route("about", {
|
|
290
|
+
component: Component.make(() => h.section({ id: "page" }, [h.h2("About")])),
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
const userRoute = Router.route("users/:id", {
|
|
294
|
+
path: idParam,
|
|
295
|
+
component: ({ path }) => {
|
|
296
|
+
if (!Number.isFinite(path.id) || path.id < 0) return notFound();
|
|
297
|
+
return h.section({ id: "page" }, [h.h2(`User ${path.id}`)]);
|
|
298
|
+
},
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
const Shell = Component.gen(function* () {
|
|
302
|
+
const outlet = yield* Router.Outlet;
|
|
303
|
+
return yield* h.div({ id: "app" }, [
|
|
304
|
+
h.nav([
|
|
305
|
+
h.a({ href: href(homeRoute) }, "Home"),
|
|
306
|
+
" · ",
|
|
307
|
+
h.a({ href: href(aboutRoute) }, "About"),
|
|
308
|
+
" · ",
|
|
309
|
+
h.a({ href: href(userRoute, { path: { id: 1 } }) }, "User 1"),
|
|
310
|
+
]),
|
|
311
|
+
h.main([outlet]),
|
|
312
|
+
]);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
export const App = Router.router(
|
|
316
|
+
Router.layout({ component: Shell }, [homeRoute, aboutRoute, userRoute]),
|
|
317
|
+
{ notFound: () => h.section({ id: "page" }, [h.h2("404: page not found")]) },
|
|
318
|
+
);
|
|
319
|
+
```
|
|
187
320
|
|
|
188
321
|
```typescript
|
|
189
|
-
//
|
|
322
|
+
// src/main.ts
|
|
323
|
+
/**
|
|
324
|
+
* Browser entry: mounts the routing demo into `#root`. No server render to
|
|
325
|
+
* hydrate, so this uses `WeftApp.mount`, not `hydrate`.
|
|
326
|
+
*/
|
|
190
327
|
import { WeftApp } from "@weftui/dom/client";
|
|
191
328
|
import { RouterApp, RouterLive } from "@weftui/router/client";
|
|
192
329
|
import { Effect } from "effect";
|
|
193
330
|
import { App } from "./app";
|
|
194
331
|
|
|
195
|
-
const root = document.getElementById("root")
|
|
332
|
+
const root = document.getElementById("root");
|
|
333
|
+
if (root === null) {
|
|
334
|
+
throw new Error("#root not found");
|
|
335
|
+
}
|
|
336
|
+
|
|
196
337
|
const app = WeftApp.make(RouterLive(App));
|
|
197
|
-
void Effect.runPromise(WeftApp.
|
|
338
|
+
void Effect.runPromise(WeftApp.mount(app, RouterApp(App), root));
|
|
198
339
|
```
|
|
199
340
|
|
|
200
|
-
|
|
341
|
+
`WeftApp.mount(app, node, root)` clears `root` and renders `node` fresh, in contrast to `hydrate`, which adopts existing server-rendered DOM (see [Full SSR example](#full-ssr-example) below). Everything else, the layout, `href`, params, navigation, is identical between the two setups.
|
|
201
342
|
|
|
202
343
|
### Link interception
|
|
203
344
|
|
|
@@ -209,6 +350,11 @@ A plain `h.a({ href })` to a same-origin, route-matching URL performs SPA naviga
|
|
|
209
350
|
- same-document (hash-only) navigations
|
|
210
351
|
- hrefs that don't resolve to a route
|
|
211
352
|
|
|
353
|
+
```typescript
|
|
354
|
+
h.a({ href: "/about" }, "About"); // intercepted: SPA navigation, no reload
|
|
355
|
+
h.a({ href: "/about", target: "_blank" }, "About"); // native: falls through
|
|
356
|
+
```
|
|
357
|
+
|
|
212
358
|
You don't wire anything up. `RouterLive` installs the delegated listener for the layer's lifetime and removes it on teardown.
|
|
213
359
|
|
|
214
360
|
## Programmatic navigation
|
|
@@ -265,10 +411,98 @@ On the server, `RouterServer`:
|
|
|
265
411
|
- renders `RouterApp` to hydratable HTML inside a **document shell**
|
|
266
412
|
- reports a status (404 when no route matches or a page raises `RouterNotFound`)
|
|
267
413
|
|
|
268
|
-
The document shell is itself a `ComponentSlot` that splices the app via `yield* Router.Outlet`, exactly like a layout receives its outlet
|
|
414
|
+
The document shell is itself a `ComponentSlot` that splices the app via `yield* Router.Outlet`, exactly like a layout receives its outlet.
|
|
415
|
+
|
|
416
|
+
```typescript
|
|
417
|
+
const { html, status } = await Effect.runPromise(
|
|
418
|
+
RouterServer.render(App, { document: documentShell, url }),
|
|
419
|
+
);
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
### Full SSR example
|
|
423
|
+
|
|
424
|
+
The same three routes as the [client-only app](#client-only-app), rendered on the server as hydratable HTML and hydrated in the browser. This is the whole file set (drop it alongside a dev server that bridges `entry-server.ts`'s `handler` into Vite or any Web-platform server; see [`examples/router-ssr/server.ts`](https://github.com/stefvw93/weft/blob/main/examples/router-ssr/server.ts) for a working one).
|
|
425
|
+
|
|
426
|
+
```typescript
|
|
427
|
+
// src/app.ts
|
|
428
|
+
/**
|
|
429
|
+
* Shared, isomorphic router app: three pages under one persistent Shell
|
|
430
|
+
* layout. Side-effect-free: it never mounts or serves. `entry-server.ts`
|
|
431
|
+
* renders the matched route on the server; `entry-client.ts` hydrates over it.
|
|
432
|
+
*/
|
|
433
|
+
import { Component, h } from "@weftui/core";
|
|
434
|
+
import { href, notFound, Router } from "@weftui/router";
|
|
435
|
+
import { Schema } from "effect";
|
|
436
|
+
|
|
437
|
+
const idParam = { id: Schema.NumberFromString };
|
|
438
|
+
|
|
439
|
+
export const homeRoute = Router.route("", {
|
|
440
|
+
component: Component.make(() => h.section({ id: "page" }, [h.h2("Home")])),
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
export const aboutRoute = Router.route("about", {
|
|
444
|
+
component: Component.make(() => h.section({ id: "page" }, [h.h2("About")])),
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
export const userRoute = Router.route("users/:id", {
|
|
448
|
+
path: idParam,
|
|
449
|
+
component: ({ path }) => {
|
|
450
|
+
if (!Number.isFinite(path.id) || path.id < 0) return notFound();
|
|
451
|
+
return h.section({ id: "page" }, [h.h2(`User ${path.id}`)]);
|
|
452
|
+
},
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
const Shell = Component.gen(function* () {
|
|
456
|
+
const outlet = yield* Router.Outlet;
|
|
457
|
+
return yield* h.div({ id: "app" }, [
|
|
458
|
+
h.nav([
|
|
459
|
+
h.a({ href: href(homeRoute) }, "Home"),
|
|
460
|
+
" · ",
|
|
461
|
+
h.a({ href: href(aboutRoute) }, "About"),
|
|
462
|
+
]),
|
|
463
|
+
h.main([outlet]),
|
|
464
|
+
]);
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
export const App = Router.router(
|
|
468
|
+
Router.layout({ component: Shell }, [homeRoute, aboutRoute, userRoute]),
|
|
469
|
+
{ notFound: () => h.section({ id: "page" }, [h.h2("404: page not found")]) },
|
|
470
|
+
);
|
|
471
|
+
```
|
|
472
|
+
|
|
473
|
+
```typescript
|
|
474
|
+
// src/entry-client.ts
|
|
475
|
+
/**
|
|
476
|
+
* Client entry: hydrates the server-rendered markup in `#root`.
|
|
477
|
+
*
|
|
478
|
+
* `RouterApp(App)` is the universal router root; `RouterLive(App)` provides
|
|
479
|
+
* the History-API-backed `Router` (seeded from `window.location`, with the
|
|
480
|
+
* same-origin link click interceptor installed). `hydrate` adopts the server
|
|
481
|
+
* DOM in place and resumes the reactive outlet.
|
|
482
|
+
*/
|
|
483
|
+
import { WeftApp } from "@weftui/dom/client";
|
|
484
|
+
import { RouterApp, RouterLive } from "@weftui/router/client";
|
|
485
|
+
import { Effect } from "effect";
|
|
486
|
+
import { App } from "./app";
|
|
487
|
+
|
|
488
|
+
const root = document.getElementById("root");
|
|
489
|
+
if (root === null) {
|
|
490
|
+
throw new Error("#root not found");
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
const app = WeftApp.make(RouterLive(App));
|
|
494
|
+
void Effect.runPromise(WeftApp.hydrate(app, RouterApp(App), root));
|
|
495
|
+
```
|
|
269
496
|
|
|
270
497
|
```typescript
|
|
271
|
-
// entry-server.ts
|
|
498
|
+
// src/entry-server.ts
|
|
499
|
+
/**
|
|
500
|
+
* Server entry: renders the matched route to a hydratable HTML document.
|
|
501
|
+
*
|
|
502
|
+
* `documentShell` splices the app via `yield* Router.Outlet` (injected per
|
|
503
|
+
* request by `RouterServer`). `render` drives it for a single `url`; `handler`
|
|
504
|
+
* is a Web `fetch`-style handler ready to bridge into Vite or any Web server.
|
|
505
|
+
*/
|
|
272
506
|
import { Component, h } from "@weftui/core";
|
|
273
507
|
import { Router } from "@weftui/router";
|
|
274
508
|
import { RouterServer } from "@weftui/router/server";
|
|
@@ -287,14 +521,16 @@ const documentShell = Component.gen(function* () {
|
|
|
287
521
|
});
|
|
288
522
|
|
|
289
523
|
// { html, status }: `<!DOCTYPE html>` is prepended for you.
|
|
290
|
-
export const render = (url: string) =>
|
|
524
|
+
export const render = (url: string): Promise<{ html: string; status: number }> =>
|
|
291
525
|
Effect.runPromise(RouterServer.render(App, { document: documentShell, url }));
|
|
292
526
|
|
|
293
|
-
//
|
|
527
|
+
// A Web fetch-style handler, ready to bridge into Vite or any Web server.
|
|
294
528
|
export const handler = RouterServer.toWebHandler(App, { document: documentShell });
|
|
295
529
|
```
|
|
296
530
|
|
|
297
|
-
`render` provides both `Router.Outlet` (the app, per request) and `Router` (so the shell may read params). It renders through `renderToStringHydratable` so the client can `hydrate` in place.
|
|
531
|
+
`render` provides both `Router.Outlet` (the app, per request) and `Router` (so the shell may read params). It renders through `renderToStringHydratable` so the client can `hydrate` in place. Neither `RouterLive` nor `RouterServer` needs an `rpc` option here: it's optional and only required once a page uses [`Boundary.rpc`](#boundaryrpc-interplay).
|
|
532
|
+
|
|
533
|
+
`handler` still needs a server to call it. [`examples/router-ssr/server.ts`](https://github.com/stefvw93/weft/blob/main/examples/router-ssr/server.ts) shows the shape: a Node HTTP server that runs Vite in middleware mode, converts each request to a Web `Request`, calls `handler`, and runs HTML responses through `vite.transformIndexHtml` for HMR (non-HTML responses, like a `Boundary.rpc` refetch, are forwarded untouched). See that file and its co-located [`vite.config.ts`](https://github.com/stefvw93/weft/blob/main/examples/router-ssr/vite.config.ts) for the full dev-server wiring; it's the same shape in production behind any Web-platform host.
|
|
298
534
|
|
|
299
535
|
### `effect/unstable/httpapi` is the spine
|
|
300
536
|
|
|
@@ -305,27 +541,68 @@ The result is a single `"pages"` group with one GET endpoint per leaf at its ful
|
|
|
305
541
|
- **Server**: `RouterServer` dispatches through `HttpApiBuilder` (platform owns request→leaf matching, path/query decode, and the 404 status).
|
|
306
542
|
- **Client**: `RouterLive` derives a real `HttpApiClient` from the same `def.httpApi` (exposed as `Router.httpApiClient`) for network work. SPA URL→leaf resolution stays **local**; there is no public client-side "match this URL against my `HttpApi`" utility in platform. It is fed from the same endpoint definitions, so it never drifts from the server.
|
|
307
543
|
|
|
544
|
+
```typescript
|
|
545
|
+
import { Option } from "effect";
|
|
546
|
+
|
|
547
|
+
App.httpApi; // HttpApi.Top: one "pages" group, a GET endpoint per leaf
|
|
548
|
+
|
|
549
|
+
const { httpApiClient } = yield * Router;
|
|
550
|
+
Option.isSome(httpApiClient); // true under RouterLive, false under RouterServer
|
|
551
|
+
```
|
|
552
|
+
|
|
308
553
|
## Errors
|
|
309
554
|
|
|
310
|
-
| Error | Raised by | Recover with
|
|
311
|
-
| ------------------- | --------------------------------------------------------------------- |
|
|
312
|
-
| `RouterNotFound` | `notFound()`, or no route matched | `Boundary.catchTag(
|
|
313
|
-
| `RouterParamsError` | `Router.params` / `Router.query` on a missing/invalid key or no match | `Boundary.catchTag(
|
|
555
|
+
| Error | Raised by | Recover with |
|
|
556
|
+
| ------------------- | --------------------------------------------------------------------- | --------------------------------------------------------- |
|
|
557
|
+
| `RouterNotFound` | `notFound()`, or no route matched | `Boundary.catchTag(…)` (or the app-level `notFound` page) |
|
|
558
|
+
| `RouterParamsError` | `Router.params` / `Router.query` on a missing/invalid key or no match | `Boundary.catchTag(…)` |
|
|
314
559
|
|
|
315
560
|
Both are modeled as `Schema.TaggedErrorClass`, so they encode/decode across the wire the same way `Boundary.rpc` replays typed failures.
|
|
316
561
|
|
|
562
|
+
Recover locally by wrapping just the subtree that can fail, rather than relying on the app-level `notFound` page for everything:
|
|
563
|
+
|
|
564
|
+
```typescript
|
|
565
|
+
import { Boundary, Component, h } from "@weftui/core";
|
|
566
|
+
import { Router } from "@weftui/router";
|
|
567
|
+
|
|
568
|
+
const UserShell = Component.gen(function* () {
|
|
569
|
+
const outlet = yield* Router.Outlet;
|
|
570
|
+
return yield* h.div({ class: "user" }, [
|
|
571
|
+
Boundary.catchTag(
|
|
572
|
+
{
|
|
573
|
+
tag: "RouterParamsError",
|
|
574
|
+
fallback: () => h.p({ class: "error" }, "Couldn't read this page's params."),
|
|
575
|
+
},
|
|
576
|
+
[outlet],
|
|
577
|
+
),
|
|
578
|
+
]);
|
|
579
|
+
});
|
|
580
|
+
```
|
|
581
|
+
|
|
582
|
+
The matched tag is removed from the boundary's output `E`; an unmatched error (e.g. `RouterNotFound`) re-raises to the nearest parent boundary, which is the router's own not-found boundary if nothing closer catches it.
|
|
583
|
+
|
|
317
584
|
## `Boundary.rpc` interplay
|
|
318
585
|
|
|
319
586
|
Initial SSR navigation works end to end: the server resolves the rpc and inlines its payload, and the client replays it during `hydrate`.
|
|
320
587
|
|
|
321
588
|
**Client-side** navigation into a page containing a `Boundary.rpc` has no SSR payload, so the boundary performs a **client-first mount**. It renders the boundary's `fallback`, forks the rpc call over `POST /_eui/rpc`, and swaps in the result.
|
|
322
589
|
|
|
323
|
-
`@weftui/router` provides the `AppRpcClientTag` seam on both sides (network client on the client, in-process on the server). The same rpc backs SSR-replay, refetch, and client-first mount.
|
|
590
|
+
`@weftui/router` provides the `AppRpcClientTag` seam on both sides (network client on the client, in-process on the server). The same rpc backs SSR-replay, refetch, and client-first mount. Both `RouterLive` and `RouterServer` take an optional `{ rpc: { group } }` (server also needs `handlers`) to wire it: see the [rpc data boundaries guide](https://weftui.dev/docs/how-to/load-data-with-rpc) and [`examples/router-ssr`](https://github.com/stefvw93/weft/tree/main/examples/router-ssr) for the full contract/handler split.
|
|
591
|
+
|
|
592
|
+
```typescript
|
|
593
|
+
// client (entry-client.ts): network rpc client over the shared group
|
|
594
|
+
const app = WeftApp.make(RouterLive(App, { rpc: { group: StockRpcs } }));
|
|
595
|
+
|
|
596
|
+
// server (entry-server.ts): same group, plus its handler Layer
|
|
597
|
+
const rpc = { group: StockRpcs, handlers: StockLive };
|
|
598
|
+
export const handler = RouterServer.toWebHandler(App, { document: documentShell, rpc });
|
|
599
|
+
```
|
|
324
600
|
|
|
325
601
|
## See also
|
|
326
602
|
|
|
327
603
|
- [`@weftui/router` API reference](https://weftui.dev/docs/reference/router)
|
|
328
|
-
- [examples/router-ssr](https://github.com/stefvw93/weft/tree/main/examples/router-ssr): a runnable SSR + hydration app with nested layouts, persistent layout state, type-safe `href`s, handler-arg props, and programmatic navigation over the `effect/unstable/httpapi` spine
|
|
604
|
+
- [examples/router-ssr](https://github.com/stefvw93/weft/tree/main/examples/router-ssr): a runnable SSR + hydration app with nested layouts, persistent layout state, type-safe `href`s, handler-arg props, `Boundary.rpc`, and programmatic navigation over the `effect/unstable/httpapi` spine
|
|
605
|
+
- [examples/router-client](https://github.com/stefvw93/weft/tree/main/examples/router-client): the client-only counterpart, no server, no SSR, no `Boundary.rpc`
|
|
329
606
|
- [Component Authoring](https://weftui.dev/docs/how-to/author-components): `Component.make` / `Component.gen`, the idiomatic way to write route components
|
|
330
607
|
- [Server-Side Rendering](https://weftui.dev/docs/how-to/render-on-the-server): `renderToStringHydratable`, `hydrate`, and `Boundary.rpc`
|
|
331
608
|
- [RPC Data Boundaries](https://weftui.dev/docs/how-to/load-data-with-rpc): `Boundary.rpc`, the `Resource` handle, and the four lifecycles
|
|
@@ -193,13 +193,13 @@ When a caller passes a plain string, the component's node type has `never` for t
|
|
|
193
193
|
Splicing a `Source` straight into `h` (`[props.label]`) is enough when you only place it in the tree. When the body needs to **read or derive** from the value (combine two props, feed a stream operator, drive logic), normalize it first with [`Source.toSubscribable`](https://weftui.dev/docs/reference/core#sourcetosubscribablesource-key). It turns any `Source<A>` into an await-first, hot `Subscribable<A>`:
|
|
194
194
|
|
|
195
195
|
```typescript
|
|
196
|
-
import { Component, h, Source } from "@weftui/core";
|
|
196
|
+
import { Component, h, Source, Subscribable } from "@weftui/core";
|
|
197
197
|
import { Stream } from "effect";
|
|
198
198
|
|
|
199
199
|
const LoudLabel = Component.gen(function* (props: { label: Source.Source<string> }) {
|
|
200
200
|
const label = yield* Source.toSubscribable(props.label); // Subscribable<string>
|
|
201
201
|
// Now derive from it like any Subscribable: static, Effect, and Stream inputs all work.
|
|
202
|
-
return yield* h.strong([Stream.map(
|
|
202
|
+
return yield* h.strong([Stream.map(Subscribable.changes(label), (text) => text.toUpperCase())]);
|
|
203
203
|
});
|
|
204
204
|
```
|
|
205
205
|
|
|
@@ -14,7 +14,7 @@ description: Boundary.rpc, server-resolved and client-refreshable data; the cont
|
|
|
14
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
15
|
|
|
16
16
|
```typescript
|
|
17
|
-
import { Boundary, h } from "@weftui/core";
|
|
17
|
+
import { Boundary, h, Subscribable } from "@weftui/core";
|
|
18
18
|
import { Stream } from "effect";
|
|
19
19
|
import { GetStock } from "./data/inventory";
|
|
20
20
|
|
|
@@ -26,7 +26,7 @@ Boundary.rpc(
|
|
|
26
26
|
) =>
|
|
27
27
|
h.p([
|
|
28
28
|
"in stock: ",
|
|
29
|
-
h.span([Stream.map(resource.value
|
|
29
|
+
h.span([Stream.map(Subscribable.changes(resource.value), (s) => String(s.units))]),
|
|
30
30
|
h.button({ type: "button", onclick: () => resource.refetch }, "Refresh"),
|
|
31
31
|
]),
|
|
32
32
|
{ fallback: h.p("loading stock…") }, // shown only on a client-first mount
|
|
@@ -127,8 +127,8 @@ Because the SSR path seeds `value` await-first (it emits the seed immediately),
|
|
|
127
127
|
```typescript
|
|
128
128
|
(resource) =>
|
|
129
129
|
h.section({ class: "product" }, [
|
|
130
|
-
h.span([Stream.map(resource.value
|
|
131
|
-
h.span([Stream.map(resource.pending
|
|
130
|
+
h.span([Stream.map(Subscribable.changes(resource.value), (s) => String(s.units))]),
|
|
131
|
+
h.span([Stream.map(Subscribable.changes(resource.pending), (p) => (p ? "refreshing…" : ""))]),
|
|
132
132
|
h.button({ type: "button", onclick: () => resource.refetch }, "Refresh stock"),
|
|
133
133
|
]);
|
|
134
134
|
```
|
|
@@ -12,14 +12,14 @@ description: Render a reactive collection with List.each so reordering, insertin
|
|
|
12
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
13
|
|
|
14
14
|
```typescript
|
|
15
|
-
import { h, List } from "@weftui/core";
|
|
15
|
+
import { h, List, Subscribable } from "@weftui/core";
|
|
16
16
|
import { Stream } from "effect";
|
|
17
17
|
|
|
18
18
|
declare const rows: Subscribable.Subscribable<ReadonlyArray<{ id: number; name: string }>>;
|
|
19
19
|
|
|
20
20
|
h.ul([
|
|
21
21
|
List.each(
|
|
22
|
-
{ of:
|
|
22
|
+
{ of: Subscribable.changes(rows), by: (row) => row.id }, // key by stable identity
|
|
23
23
|
(row) => h.li(row.name),
|
|
24
24
|
),
|
|
25
25
|
]);
|
|
@@ -30,7 +30,7 @@ h.ul([
|
|
|
30
30
|
|
|
31
31
|
## Why not `map`?
|
|
32
32
|
|
|
33
|
-
Mapping items by hand (`Stream.map(
|
|
33
|
+
Mapping items by hand (`Stream.map(Subscribable.changes(rows), (rs) => rs.map(r => h.li(r.name)))`) produces a **new children array on every emission**. The renderer then rebuilds the whole region: every row's DOM node is recreated even if only one item moved.
|
|
34
34
|
|
|
35
35
|
`List.each` reconciles by key instead, so DOM identity (and the focus/scroll/typed-input state attached to it) survives across updates.
|
|
36
36
|
|
|
@@ -39,8 +39,8 @@ Mapping items by hand (`Stream.map(rows.changes, (rs) => rs.map(r => h.li(r.name
|
|
|
39
39
|
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:
|
|
40
40
|
|
|
41
41
|
```typescript
|
|
42
|
-
List.each({ of:
|
|
43
|
-
h.li([h.span([Stream.map(row.status
|
|
42
|
+
List.each({ of: Subscribable.changes(rows), by: (row) => row.id }, (row) =>
|
|
43
|
+
h.li([h.span([Stream.map(Subscribable.changes(row.status), (s) => s)])]),
|
|
44
44
|
);
|
|
45
45
|
```
|
|
46
46
|
|
|
@@ -54,7 +54,7 @@ Use a hydratable renderer whenever the client will call `hydrate`. The plain ren
|
|
|
54
54
|
It follows the same server/client split: the rpc **contract** (pure Schema) is shared, while its **handler** lives in a server-only Layer the client never imports.
|
|
55
55
|
|
|
56
56
|
```typescript
|
|
57
|
-
import { Boundary, h } from "@weftui/core";
|
|
57
|
+
import { Boundary, h, Subscribable } from "@weftui/core";
|
|
58
58
|
import { Stream } from "effect";
|
|
59
59
|
import { GetStock } from "./data/inventory";
|
|
60
60
|
|
|
@@ -65,7 +65,7 @@ const StockPanel = (productId: number) =>
|
|
|
65
65
|
(resource) =>
|
|
66
66
|
h.p([
|
|
67
67
|
"in stock: ",
|
|
68
|
-
h.span([Stream.map(resource.value
|
|
68
|
+
h.span([Stream.map(Subscribable.changes(resource.value), (stock) => String(stock.units))]),
|
|
69
69
|
h.button({ type: "button", onclick: () => resource.refetch }, "Refresh"),
|
|
70
70
|
]),
|
|
71
71
|
{ fallback: h.p("loading stock…") }, // shown only on a client-first SPA mount
|
|
@@ -14,7 +14,7 @@ When you navigate to a route, the router is **deferred-commit**. It resolves the
|
|
|
14
14
|
That resolve window is exposed as a reactive signal, [`Router.navigating`](https://weftui.dev/docs/reference/router#routernavigating), that you read to render pending UI.
|
|
15
15
|
|
|
16
16
|
```typescript
|
|
17
|
-
import { Component, h } from "@weftui/core";
|
|
17
|
+
import { Component, h, Subscribable } from "@weftui/core";
|
|
18
18
|
import { Router } from "@weftui/router";
|
|
19
19
|
import { Stream } from "effect";
|
|
20
20
|
|
|
@@ -25,7 +25,7 @@ const Shell = Component.gen(function* () {
|
|
|
25
25
|
h.div({
|
|
26
26
|
id: "nav-progress",
|
|
27
27
|
"aria-hidden": "true",
|
|
28
|
-
class: Stream.map(
|
|
28
|
+
class: Stream.map(Subscribable.changes(nav), (s) =>
|
|
29
29
|
s._tag === "Navigating" ? "nav-progress is-navigating" : "nav-progress",
|
|
30
30
|
),
|
|
31
31
|
}),
|
package/docs/reference/core.md
CHANGED
|
@@ -358,9 +358,9 @@ The keyed-list combinator. It is the opt-in alternative to wholesale child rebui
|
|
|
358
358
|
> elements.
|
|
359
359
|
|
|
360
360
|
```typescript
|
|
361
|
-
import { h, List } from "@weftui/core";
|
|
361
|
+
import { h, List, Subscribable } from "@weftui/core";
|
|
362
362
|
|
|
363
|
-
h.ul([List.each({ of:
|
|
363
|
+
h.ul([List.each({ of: Subscribable.changes(rows), by: (row) => row.id }, (row) => h.li(row.name))]);
|
|
364
364
|
```
|
|
365
365
|
|
|
366
366
|
#### `List.each`
|
package/docs/reference/router.md
CHANGED
|
@@ -111,7 +111,7 @@ Router.params<F extends Fields>(fields: F): Effect<FieldsType<F>, RouterParamsEr
|
|
|
111
111
|
Router.query<F extends Fields>(fields: F): Effect<FieldsType<F>, RouterParamsError, Router>;
|
|
112
112
|
```
|
|
113
113
|
|
|
114
|
-
Snapshot accessors that read the **live match** (`
|
|
114
|
+
Snapshot accessors that read the **live match** (`Subscribable.get(currentMatch)`) and pick the requested `fields` keys from the decoded path/query. The matcher already decoded the values against the leaf's full schema, so they are returned directly (no re-validation).
|
|
115
115
|
|
|
116
116
|
Readable from **any** component, not just the leaf: this is the dependency-injection path layouts and deep nodes use (leaves can instead take [handler-arg props](#routerroute)). They fail with a [`RouterParamsError`](#routerparamserror) (`source: "path" | "query"`, plus the requested `keys`) when no route matches.
|
|
117
117
|
|
|
@@ -122,7 +122,7 @@ Router.paramsStream<F extends Fields>(fields: F): Effect<Subscribable<FieldsType
|
|
|
122
122
|
Router.queryStream<F extends Fields>(fields: F): Effect<Subscribable<FieldsType<F>>, never, Router>;
|
|
123
123
|
```
|
|
124
124
|
|
|
125
|
-
The **reactive** counterparts. Each resolves a `Subscribable<FieldsType<F>>` derived from `
|
|
125
|
+
The **reactive** counterparts. Each resolves a `Subscribable<FieldsType<F>>` derived from `Subscribable.changes(currentMatch)`. A component can render `[Subscribable.changes(yield* Router.queryStream(fields))]` and update **in place** even when the outlet keeps the same leaf mounted. That is exactly the query-only case (`setQuery` / `patchQuery`) a snapshot `Router.query` would miss.
|
|
126
126
|
|
|
127
127
|
Resilient across navigations: a `NotFound` match yields the empty subset rather than failing, so the stream stays live.
|
|
128
128
|
|