@weftui/core 0.28.0 → 0.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -8
- package/dist/{index-Bsh2WLtx.d.ts → index-CX9uEejU.d.ts} +70 -51
- package/dist/index.d.ts +68 -119
- package/dist/index.js +1 -1
- package/dist/types/index.d.ts +1 -1
- package/docs/explanation/boundaries-and-suspense.md +39 -20
- package/docs/explanation/combinator-api.md +14 -12
- package/docs/explanation/reactive-primitives.md +14 -14
- package/docs/explanation/rendering-model.md +20 -18
- package/docs/explanation/services-and-context.md +35 -21
- package/docs/how-to/add-routing.md +361 -60
- package/docs/how-to/author-components.md +42 -30
- package/docs/how-to/compose-behavior-and-markup.md +144 -0
- package/docs/how-to/handle-forms.md +6 -6
- package/docs/how-to/load-async-data.md +15 -13
- package/docs/how-to/load-data-with-rpc.md +34 -32
- package/docs/how-to/provide-services.md +20 -18
- package/docs/how-to/render-keyed-lists.md +14 -12
- package/docs/how-to/render-on-the-server.md +18 -14
- package/docs/how-to/show-navigation-progress.md +12 -10
- package/docs/how-to/split-routes-lazily.md +16 -14
- package/docs/how-to/style-reactively.md +13 -13
- package/docs/how-to/use-element-refs.md +10 -8
- package/docs/index.md +20 -18
- package/docs/reference/core.md +46 -42
- package/docs/reference/dom.md +274 -58
- package/docs/reference/router.md +69 -47
- package/docs/tutorial/01-your-first-app.md +7 -9
- package/docs/tutorial/02-reactivity.md +8 -6
- package/docs/tutorial/03-services-and-async.md +10 -6
- package/docs/tutorial/04-errors-and-server.md +14 -5
- package/package.json +2 -2
package/docs/reference/router.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
title: "@weftui/router"
|
|
3
3
|
order: 3
|
|
4
4
|
section: reference
|
|
5
|
-
description: Full API surface for @weftui/router
|
|
5
|
+
description: "Full API surface for @weftui/router: universal nested routing, type-safe href, layouts, and SSR entry points."
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# @weftui/router API Reference
|
|
@@ -23,9 +23,9 @@ Three entry points mirror `@weftui/dom`:
|
|
|
23
23
|
|
|
24
24
|
The service value carries:
|
|
25
25
|
|
|
26
|
-
- **`currentMatch
|
|
27
|
-
- **`navigate(to, options?)
|
|
28
|
-
- **`httpApiClient
|
|
26
|
+
- **`currentMatch`**: the current match as a hot `Subscribable<RouteMatch>`; drives the outlet.
|
|
27
|
+
- **`navigate(to, options?)`**: navigates to a path (with optional query). `options.replace` swaps `pushState` for `replaceState`. On the client this updates History state and re-renders the affected outlet; on the server it is a no-op.
|
|
28
|
+
- **`httpApiClient`**: the derived [`RouterHttpApiClient`](#types) as an `Option`. It is `Option.some` on the client (`RouterLive`) for network work (route prefetch, future loaders), `Option.none` on the server (it is itself the origin). SPA URL→leaf resolution does **not** use this; it stays local via the shared matcher.
|
|
29
29
|
|
|
30
30
|
### `Router.route`
|
|
31
31
|
|
|
@@ -36,22 +36,24 @@ Router.route<Path, Query, S>(
|
|
|
36
36
|
): RouteNode<Path, Query, E, R>;
|
|
37
37
|
```
|
|
38
38
|
|
|
39
|
-
Declares a leaf page. `segment` is relative to the parent and may contain `:name` placeholders. `component` is a [`ComponentSlot`](#types)
|
|
39
|
+
Declares a leaf page. `segment` is relative to the parent and may contain `:name` placeholders. `component` is a [`ComponentSlot`](#types); its `E`/`R` channels are recovered and propagate up the tree.
|
|
40
|
+
|
|
41
|
+
The returned `RouteNode` is also the reference passed to [`href`](#href).
|
|
40
42
|
|
|
41
43
|
A leaf `component` reads the live match in **either** of two forms:
|
|
42
44
|
|
|
43
|
-
- **Handler-arg props
|
|
44
|
-
- **Dependency injection
|
|
45
|
+
- **Handler-arg props**: declare `(props: `[`RouteHandlerProps<Path, Query>`](#types)`)` and the router passes the decoded `{ path, query }` straight in. `path`/`query` are inferred from the route's `path`/`query` fields. A plain zero-arg thunk works too, ignoring the props.
|
|
46
|
+
- **Dependency injection**: a `Component.make` / `Component.gen` component reading the live match via [`Router.params`](#routerparams--routerquery) / `Router.query`. Required for layouts/deep nodes, which can't take handler args.
|
|
45
47
|
|
|
46
48
|
```typescript
|
|
47
|
-
// Handler-arg props
|
|
49
|
+
// Handler-arg props: decoded { path, query } passed in directly.
|
|
48
50
|
const userRoute = Router.route("users/:id", {
|
|
49
51
|
path: { id: Schema.NumberFromString },
|
|
50
52
|
query: { tab: Schema.optional(Schema.String) },
|
|
51
53
|
component: ({ path, query }) => h.div(`User ${path.id} (${query.tab ?? "info"})`),
|
|
52
54
|
});
|
|
53
55
|
|
|
54
|
-
// Dependency injection
|
|
56
|
+
// Dependency injection: read the match anywhere via Router.params / Router.query.
|
|
55
57
|
const userRouteDI = Router.route("users/:id", {
|
|
56
58
|
path: { id: Schema.NumberFromString },
|
|
57
59
|
component: Component.gen(function* () {
|
|
@@ -67,7 +69,9 @@ const userRouteDI = Router.route("users/:id", {
|
|
|
67
69
|
Router.layout<C, S>(config: { component: S }, children: C): LayoutNode<E, R>;
|
|
68
70
|
```
|
|
69
71
|
|
|
70
|
-
Declares a layout
|
|
72
|
+
Declares a layout: purely UI nesting, owning **no path or segment**. `component` splices the injected outlet via `yield* Router.Outlet`.
|
|
73
|
+
|
|
74
|
+
`Router.Outlet` is excluded from the layout's aggregate requirement channel (the router discharges it per render); the subtree's real channels are unioned in.
|
|
71
75
|
|
|
72
76
|
### `Router.router`
|
|
73
77
|
|
|
@@ -85,12 +89,14 @@ Router.lazy<S extends ComponentSlot>(
|
|
|
85
89
|
): () => Node<Node.Error<SlotNode<S>>, Node.Context<SlotNode<S>>>;
|
|
86
90
|
```
|
|
87
91
|
|
|
88
|
-
Wraps a dynamic-import loader as a component slot
|
|
92
|
+
Wraps a dynamic-import loader as a component slot. The route's **component** is code-split into its own chunk while the **descriptor** (segment + param schemas) stays eager.
|
|
93
|
+
|
|
94
|
+
`load` returns a `Promise` resolving the component, typically `() => import("./page").then((m) => m.Page)`. Drops directly into `Router.route({ component })` and `Router.layout({ component })`.
|
|
89
95
|
|
|
90
|
-
- **Channels preserved.** The returned slot's `E`/`R` equal the resolved component's, so a lazy route has the identical type to declaring it eagerly
|
|
91
|
-
- **Only the matched branch loads**, on the server during render and on the client on navigation. Client navigation is **deferred-commit**: the chunk **and the leaf component's own effect** resolve _before_ the URL commits (see [`Router.navigating`](#routernavigating) and [Blocking vs streaming data](#blocking-vs-streaming-data))
|
|
92
|
-
- **A rejected `load` is a defect** (`Effect.promise` dies)
|
|
93
|
-
- The resolved value should be a `Component` (`Component.make`/`Component.gen`); a bare `() => Node` thunk loses its channels through the loader `Promise
|
|
96
|
+
- **Channels preserved.** The returned slot's `E`/`R` equal the resolved component's, so a lazy route has the identical type to declaring it eagerly. An unmet requirement is still a compile error at `Router.router`.
|
|
97
|
+
- **Only the matched branch loads**, on the server during render and on the client on navigation. Client navigation is **deferred-commit**: the chunk **and the leaf component's own effect** resolve _before_ the URL commits (see [`Router.navigating`](#routernavigating) and [Blocking vs streaming data](#blocking-vs-streaming-data)). The previous page stays mounted through both the fetch and any data the leaf awaits, so the swap is blank-free. The load `Promise` is memoized per slot, so revisits are synchronous.
|
|
98
|
+
- **A rejected `load` is a defect** (`Effect.promise` dies): a deploy-skew/offline condition surfaced through normal defect handling, kept off the `E` channel. The rejection is memoized (no silent retry).
|
|
99
|
+
- The resolved value should be a `Component` (`Component.make`/`Component.gen`); a bare `() => Node` thunk loses its channels through the loader `Promise`, so wrap it in `Component.make`.
|
|
94
100
|
|
|
95
101
|
See the [Split Routes Lazily](https://weftui.dev/docs/how-to/split-routes-lazily) how-to and `packages/router/src/lazy-component.specs.md`.
|
|
96
102
|
|
|
@@ -105,7 +111,9 @@ Router.params<F extends Fields>(fields: F): Effect<FieldsType<F>, RouterParamsEr
|
|
|
105
111
|
Router.query<F extends Fields>(fields: F): Effect<FieldsType<F>, RouterParamsError, Router>;
|
|
106
112
|
```
|
|
107
113
|
|
|
108
|
-
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
|
+
|
|
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.
|
|
109
117
|
|
|
110
118
|
### `Router.paramsStream` / `Router.queryStream`
|
|
111
119
|
|
|
@@ -114,7 +122,9 @@ Router.paramsStream<F extends Fields>(fields: F): Effect<Subscribable<FieldsType
|
|
|
114
122
|
Router.queryStream<F extends Fields>(fields: F): Effect<Subscribable<FieldsType<F>>, never, Router>;
|
|
115
123
|
```
|
|
116
124
|
|
|
117
|
-
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
|
+
|
|
127
|
+
Resilient across navigations: a `NotFound` match yields the empty subset rather than failing, so the stream stays live.
|
|
118
128
|
|
|
119
129
|
### `Router.navigating`
|
|
120
130
|
|
|
@@ -130,24 +140,28 @@ readonly navigating: Subscribable.Subscribable<NavState>;
|
|
|
130
140
|
Router.navigatingStream: Effect<Subscribable<NavState>, never, Router>;
|
|
131
141
|
```
|
|
132
142
|
|
|
133
|
-
The reactive navigation-state signal, for rendering pending UI (a top progress bar, dimmed outlet) during a **deferred-commit** navigation. It transitions `Idle → Navigating{ to }` while the router resolves the target branch's [`Router.lazy`](#routerlazy) chunk **and** the matched leaf's own component effect,
|
|
143
|
+
The reactive navigation-state signal, for rendering pending UI (a top progress bar, dimmed outlet) during a **deferred-commit** navigation. It transitions `Idle → Navigating{ to }` while the router resolves the target branch's [`Router.lazy`](#routerlazy) chunk **and** the matched leaf's own component effect, then back to `Idle` on commit. One signal covers both the code and data windows.
|
|
134
144
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
- **
|
|
145
|
+
`NavState` is exported from `@weftui/router` and `@weftui/router/client`.
|
|
146
|
+
|
|
147
|
+
- **Eager navigations never flip it**: a branch with no lazy node and a synchronously-resolving leaf commits synchronously and `navigating` stays `Idle`. Reading it costs nothing in an eager app.
|
|
148
|
+
- **Synchronous resolutions never emit `Navigating`**: a revisit with both the chunk and the leaf's effect memoized (or an eager leaf with no async work) still commits in the same tick; the signal only flips when a resolution is genuinely async.
|
|
149
|
+
- **Latest-wins** across rapid navigations: a superseded navigation never resets it and its pre-run is interrupted.
|
|
150
|
+
- **popstate** (back/forward) into a lazy or data-fetching route also reports.
|
|
151
|
+
- A **rejected chunk load or a failing leaf pre-run** (typed error or defect) resets it to `Idle` before the failure surfaces through the normal render error path.
|
|
138
152
|
- **Server-side it is a constant `Idle`** (server render is buffered), so a component reading it type-checks and renders on both sides.
|
|
139
153
|
|
|
140
154
|
See the [Show Navigation Progress](https://weftui.dev/docs/how-to/show-navigation-progress) how-to, the [Blocking vs streaming data](#blocking-vs-streaming-data) section below, and `packages/router/src/resolve-before-commit.specs.md`.
|
|
141
155
|
|
|
142
156
|
### Blocking vs streaming data
|
|
143
157
|
|
|
144
|
-
A leaf `component` is already an `Effect
|
|
158
|
+
A leaf `component` is already an `Effect`; there is no separate loader. Where you put an await decides whether it blocks the navigation commit or streams in afterward:
|
|
145
159
|
|
|
146
|
-
- **`yield*` in the component body** → commit-blocking. The pre-run executes the leaf's effect to completion before the URL commits
|
|
160
|
+
- **`yield*` in the component body** → commit-blocking. The pre-run executes the leaf's effect to completion before the URL commits. The previous page stays mounted for the whole window, and `Router.navigating` reports `Navigating{ to }` for its duration.
|
|
147
161
|
- **An `Effect`/`Stream` placed as a child node** → streaming. The commit is not delayed; the leaf mounts immediately and the child region fills in place once its own effect resolves.
|
|
148
162
|
|
|
149
163
|
```typescript
|
|
150
|
-
// Blocking
|
|
164
|
+
// Blocking: the await is in the leaf's own body; navigation waits for it.
|
|
151
165
|
const DocPage = Component.gen(function* () {
|
|
152
166
|
const { category, slug } = yield* Router.params({ category: Schema.String, slug: Schema.String });
|
|
153
167
|
const docs = yield* Docs;
|
|
@@ -155,7 +169,7 @@ const DocPage = Component.gen(function* () {
|
|
|
155
169
|
return yield* h.article([h.h1(doc.title), h.div({ innerHTML: doc.html })]);
|
|
156
170
|
});
|
|
157
171
|
|
|
158
|
-
// Streaming
|
|
172
|
+
// Streaming: the await lives on a child; the leaf commits immediately.
|
|
159
173
|
const DocPage = Component.gen(function* () {
|
|
160
174
|
const { category, slug } = yield* Router.params({ category: Schema.String, slug: Schema.String });
|
|
161
175
|
const docs = yield* Docs;
|
|
@@ -171,9 +185,11 @@ const DocPage = Component.gen(function* () {
|
|
|
171
185
|
});
|
|
172
186
|
```
|
|
173
187
|
|
|
174
|
-
Choose blocking for primary content the page is meaningless without (an article body, a product's price)
|
|
188
|
+
Choose blocking for primary content the page is meaningless without (an article body, a product's price); the old page stays visible with no blank or skeleton. Choose streaming for secondary or slow regions where partial content is still useful (a comments panel, a "related" rail); the commit isn't held hostage by one slow fetch.
|
|
189
|
+
|
|
190
|
+
Only the matched **leaf** is pre-run this way. Layout components in the branch get their chunks preloaded, but their bodies still run at render, post-commit. Unchanged layouts don't re-render across navigations, so this rarely matters in practice.
|
|
175
191
|
|
|
176
|
-
|
|
192
|
+
Pre-run failures (`notFound()`, a typed error, or a defect) still commit the URL. They replay through the normal render error path (the nearest `Boundary`, or the router's 404 boundary) without re-running the component.
|
|
177
193
|
|
|
178
194
|
See [Load Async Data](https://weftui.dev/docs/how-to/load-async-data#blocking-on-navigation-vs-streaming-in-place) and [RPC Data Boundaries](https://weftui.dev/docs/how-to/load-data-with-rpc) for the streaming patterns in full.
|
|
179
195
|
|
|
@@ -201,9 +217,9 @@ href(postsRoute, { path: { id: 1 }, query: { sort: "new" } }); // "/users/1/post
|
|
|
201
217
|
RouterApp<E, R>(def: RouterDef<E, R>): Node<Exclude<E, RouterNotFound>, R | Router>;
|
|
202
218
|
```
|
|
203
219
|
|
|
204
|
-
The universal router root node
|
|
220
|
+
The universal router root node; render this on both server and client. Wraps the nested outlet in the router's internal not-found boundary, so a `RouterNotFound` raised by a page renders the configured `notFound` page in place. Server dispatch runs through `HttpApiBuilder`: a page-raised `RouterNotFound` and a no-match surface their 404 through the platform request pipeline.
|
|
205
221
|
|
|
206
|
-
`RouterApp` requires `Router` in its environment
|
|
222
|
+
`RouterApp` requires `Router` in its environment. Provide it via `RouterLive` (client) or `RouterServer` (server), not `Effect.provide` at the node level. Services under `WeftApp` come exclusively from the app's layer, not ambient `Effect.provide`.
|
|
207
223
|
|
|
208
224
|
### `outletNode` (a.k.a. `RouterOutlet`)
|
|
209
225
|
|
|
@@ -211,9 +227,9 @@ The universal router root node — render this on both server and client. Wraps
|
|
|
211
227
|
outletNode<E, R>(def: RouterDef<E, R>): Node<E | RouterNotFound, R | Router>;
|
|
212
228
|
```
|
|
213
229
|
|
|
214
|
-
The bare nested-outlet node without the internal not-found boundary
|
|
230
|
+
The bare nested-outlet node without the internal not-found boundary, for callers placing their own not-found handling. Re-exported from `@weftui/router/client` as `RouterOutlet`.
|
|
215
231
|
|
|
216
|
-
## Client
|
|
232
|
+
## Client: `@weftui/router/client`
|
|
217
233
|
|
|
218
234
|
### `RouterLive`
|
|
219
235
|
|
|
@@ -224,7 +240,11 @@ RouterLive(
|
|
|
224
240
|
): Layer.Layer<Router | AppRpcClientTag>;
|
|
225
241
|
```
|
|
226
242
|
|
|
227
|
-
The client `Router` layer, backed by the History API. Seeds a `SubscriptionRef` from `window.location`, listens for `popstate`, installs the same-origin link-click interceptor, and derives the `HttpApiClient` exposed as `Router.httpApiClient` (over `FetchHttpClient`; `baseUrl` defaults to same-origin).
|
|
243
|
+
The client `Router` layer, backed by the History API. Seeds a `SubscriptionRef` from `window.location`, listens for `popstate`, installs the same-origin link-click interceptor, and derives the `HttpApiClient` exposed as `Router.httpApiClient` (over `FetchHttpClient`; `baseUrl` defaults to same-origin).
|
|
244
|
+
|
|
245
|
+
Alongside `Router` it also provides the core [`AppRpcClientTag`](https://weftui.dev/docs/reference/core#apprpcclienttag) seam: a **network** flat rpc client over the app's merged `RpcGroup` (`RpcClient.make` → `POST /_eui/rpc`). This lets `@weftui/dom` resolve a [`Boundary.rpc`](https://weftui.dev/docs/reference/core#boundaryrpc) (hydrated refetch and client-first SPA mount) without depending on this package or `effect/unstable/rpc`. Pass the same merged `group` the server wires into [`RouterServer`](#routerserver).
|
|
246
|
+
|
|
247
|
+
**Scoped**: it must outlive the mount. Give it to `WeftApp.make` and the app runtime owns its lifetime (built lazily on first mount, released at `WeftApp.dispose`):
|
|
228
248
|
|
|
229
249
|
```typescript
|
|
230
250
|
const app = WeftApp.make(RouterLive(App, { rpc: { group: StockRpcs } }));
|
|
@@ -245,10 +265,10 @@ patchQuery(partial: Record<string, unknown>, options?: NavigateOptions): Effect<
|
|
|
245
265
|
|
|
246
266
|
Typed programmatic navigation, all run within the `RouterLive` layer (except `back`/`forward`, which only touch `window.history`):
|
|
247
267
|
|
|
248
|
-
- **`navigate(ref, args)
|
|
249
|
-
- **`push` / `replace
|
|
250
|
-
- **`back` / `forward
|
|
251
|
-
- **`setQuery` / `patchQuery
|
|
268
|
+
- **`navigate(ref, args)`**: go to a leaf route `ref` with typed `{ path, query }`. Builds the URL via [`href`](#href) (so it round-trips with `match`) and pushes the History entry, or replaces it with `options.replace`. Same requiredness rules as `href`: `path` required when the route has path params, `query` optional when every field is optional.
|
|
269
|
+
- **`push` / `replace`**: go to a raw `path + search` string, pushing or replacing the History entry.
|
|
270
|
+
- **`back` / `forward`**: step through History (`history.go(±1)`); the `popstate` handler resyncs.
|
|
271
|
+
- **`setQuery` / `patchQuery`**: change the current route's query in place, re-encoding through the matched leaf's `querySchema`. The path is kept, so the leaf stays mounted and reactive `queryStream` readers update. `setQuery` replaces the query; `patchQuery` merges. No-op when no route is matched.
|
|
252
272
|
|
|
253
273
|
```typescript
|
|
254
274
|
import { navigate, patchQuery, push } from "@weftui/router/client";
|
|
@@ -260,7 +280,9 @@ yield * patchQuery({ sort: "old" }); // keeps the current path + other query fie
|
|
|
260
280
|
|
|
261
281
|
### Scroll reset
|
|
262
282
|
|
|
263
|
-
Every client-committed navigation whose path differs from the previously committed path
|
|
283
|
+
Every client-committed navigation whose path differs from the previously committed path (`navigate`, `push`, `replace`, and the link interceptor) resets `window.scrollTo(0, 0)` synchronously at commit. Query-only navigations (`setQuery` / `patchQuery`, or any push/replace to the same path) preserve scroll.
|
|
284
|
+
|
|
285
|
+
`popstate` (`back` / `forward`) never resets; the browser's own `history.scrollRestoration: "auto"` restores the prior offset for those entries. Server rendering is unaffected. Not configurable: there is no `NavigateOptions` field to opt out.
|
|
264
286
|
|
|
265
287
|
### `installLinkInterceptor`
|
|
266
288
|
|
|
@@ -268,9 +290,9 @@ Every client-committed navigation whose path differs from the previously committ
|
|
|
268
290
|
installLinkInterceptor(def: RouterDef, navigate: (to: string) => Effect<void>): Effect<void, never, Scope>;
|
|
269
291
|
```
|
|
270
292
|
|
|
271
|
-
The delegated click interceptor `RouterLive` installs for you. Exposed for advanced/manual wiring. Intercepts plain same-origin clicks whose href resolves to a route (resolved against `def`)
|
|
293
|
+
The delegated click interceptor `RouterLive` installs for you. Exposed for advanced/manual wiring. Intercepts plain same-origin clicks whose href resolves to a route (resolved against `def`). It leaves modified clicks, `target=_blank`, `download`, external origins, same-document navigations, and non-matching hrefs to the browser.
|
|
272
294
|
|
|
273
|
-
## Server
|
|
295
|
+
## Server: `@weftui/router/server`
|
|
274
296
|
|
|
275
297
|
### `RouterServer`
|
|
276
298
|
|
|
@@ -281,14 +303,14 @@ RouterServer.render(def, options: { document; rpc; url }): Effect<{ html; status
|
|
|
281
303
|
RouterServer.toWebHandler(def, options: { document; rpc }): (request: Request) => Promise<Response>;
|
|
282
304
|
```
|
|
283
305
|
|
|
284
|
-
Dispatch runs through the `def.httpApi` spine via `HttpApiBuilder`: platform owns request→leaf matching and path/query decode
|
|
306
|
+
Dispatch runs through the `def.httpApi` spine via `HttpApiBuilder`: platform owns request→leaf matching and path/query decode. Each leaf handler then builds a fixed-match server `Router` and renders the universal outlet to hydratable HTML.
|
|
285
307
|
|
|
286
308
|
- **`toWebHandler`** returns a Web `fetch`-style handler `(Request) => Promise<Response>` that dispatches through `HttpApiBuilder.toWebHandler` and replies `text/html`. Suitable for bridging into a dev server (Vite) or any Web-platform server.
|
|
287
|
-
- **`render`** drives that handler for a single `url`, returning `{ html, status }` with `<!DOCTYPE html>` prepended. `status` is sourced from the platform pipeline
|
|
309
|
+
- **`render`** drives that handler for a single `url`, returning `{ html, status }` with `<!DOCTYPE html>` prepended. `status` is sourced from the platform pipeline: `200`, or `404` for a no-match / a page-raised `RouterNotFound`.
|
|
288
310
|
- **`document`** is a [`ComponentSlot`](#types) that splices the app via `yield* Router.Outlet`; the router provides both `Router.Outlet` (the app, per request) and `Router`.
|
|
289
|
-
- **`rpc`** is the app's [`Boundary.rpc`](https://weftui.dev/docs/reference/core#boundaryrpc) data foundation: `{ group: RpcGroup<any>; handlers: Layer<any, never, never> }
|
|
311
|
+
- **`rpc`** is the app's [`Boundary.rpc`](https://weftui.dev/docs/reference/core#boundaryrpc) data foundation: `{ group: RpcGroup<any>; handlers: Layer<any, never, never> }`. That is the merged `RpcGroup` (shared with the client) plus its server-only handler Layer (`group.toLayer(...)` ⊕ its dependencies). `toWebHandler` serves the handlers at `POST /_eui/rpc`, so a client refetch / client-first mount re-runs them on the server. An in-process client over the same handlers (backing the [`AppRpcClientTag`](https://weftui.dev/docs/reference/core#apprpcclienttag) seam) resolves SSR boundaries in-process, never over the network.
|
|
290
312
|
|
|
291
|
-
> The `HttpApi` is not generated on the server
|
|
313
|
+
> The `HttpApi` is not generated on the server. It is built once when the tree is sealed (`buildHttpApi` during `Router.router`) and lives on `def.httpApi`. That is the single source of truth both the server dispatch and the client matcher / derived `HttpApiClient` read from.
|
|
292
314
|
|
|
293
315
|
## Errors
|
|
294
316
|
|
|
@@ -338,7 +360,7 @@ These power the runtime and are exported for tooling/tests; most apps never touc
|
|
|
338
360
|
| `RouteHandlerProps<Path, Query>` | The `{ path, query }` decoded match a leaf `component` may declare as handler-arg props. |
|
|
339
361
|
| `RouteMatch` | `{ _tag: "Matched"; leaf; path; query; url }` or `{ _tag: "NotFound"; url }`. |
|
|
340
362
|
| `HrefArgs<Path, Query>` | The `href` argument object; `path`/`query` become optional when their decoded type has no required keys. |
|
|
341
|
-
| `NavigateOptions` | `{ replace?: boolean }
|
|
363
|
+
| `NavigateOptions` | `{ replace?: boolean }`, for `navigate` / `setQuery` / `patchQuery` and `Router.navigate`. |
|
|
342
364
|
| `RouterHttpApiClient` | The platform `HttpApiClient` derived from a router's `HttpApi` spine (carried opaquely on `Router.httpApiClient`). |
|
|
343
365
|
| `Fields` / `FieldsType<F>` | `Schema.Struct.Fields` and the `Type` side of its `Schema.Struct`. |
|
|
344
366
|
| `Compiled` / `CompiledLeaf` / `CompiledLayout` | The compiled tree shapes. |
|
|
@@ -346,7 +368,7 @@ These power the runtime and are exported for tooling/tests; most apps never touc
|
|
|
346
368
|
|
|
347
369
|
## See also
|
|
348
370
|
|
|
349
|
-
- [Add Routing](https://weftui.dev/docs/how-to/add-routing)
|
|
350
|
-
- [Split Routes Lazily](https://weftui.dev/docs/how-to/split-routes-lazily) · [Show Navigation Progress](https://weftui.dev/docs/how-to/show-navigation-progress)
|
|
371
|
+
- [Add Routing](https://weftui.dev/docs/how-to/add-routing): the narrative guide to authoring a route tree
|
|
372
|
+
- [Split Routes Lazily](https://weftui.dev/docs/how-to/split-routes-lazily) · [Show Navigation Progress](https://weftui.dev/docs/how-to/show-navigation-progress): `Router.lazy` and `Router.navigating`
|
|
351
373
|
- [`@weftui/core` reference](https://weftui.dev/docs/reference/core) · [`@weftui/dom` reference](https://weftui.dev/docs/reference/dom)
|
|
352
|
-
- [`packages/router/router.specs.md`](https://github.com/stefvw93/weft/blob/main/packages/router/router.specs.md)
|
|
374
|
+
- [`packages/router/router.specs.md`](https://github.com/stefvw93/weft/blob/main/packages/router/router.specs.md): the full specification
|
|
@@ -2,14 +2,12 @@
|
|
|
2
2
|
title: Your First App
|
|
3
3
|
order: 1
|
|
4
4
|
section: tutorial
|
|
5
|
-
description: Install Weft, build a component with the h namespace, and mount it
|
|
5
|
+
description: "Install Weft, build a component with the h namespace, and mount it: the smallest possible Weft app."
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# Your First App
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
We assume you know [Effect](https://effect.website/docs/getting-started/introduction) fundamentals — Weft is Effect for the UI, and we will not re-explain `Effect.gen`, services, or streams from scratch.
|
|
10
|
+
We assume you know [Effect](https://effect.website/docs/getting-started/introduction) fundamentals. Weft is Effect for the UI, so we will not re-explain `Effect.gen`, services, or streams from scratch.
|
|
13
11
|
|
|
14
12
|
## Install
|
|
15
13
|
|
|
@@ -23,7 +21,7 @@ Weft tracks Effect 4's beta line. This release is built and tested against `effe
|
|
|
23
21
|
|
|
24
22
|
## Build a component
|
|
25
23
|
|
|
26
|
-
A **component is a plain function you call
|
|
24
|
+
A **component is a plain function you call**. There is no JSX and no `<Component/>` deferral. It returns a `Node`, which is just an `Effect` that produces a DOM node:
|
|
27
25
|
|
|
28
26
|
```typescript
|
|
29
27
|
import { h } from "@weftui/core";
|
|
@@ -42,10 +40,10 @@ The `h` namespace is the entry point: every property (`h.div`, `h.h1`, `h.button
|
|
|
42
40
|
|
|
43
41
|
## What just happened
|
|
44
42
|
|
|
45
|
-
- `App()` returns a **`Node<never, never
|
|
46
|
-
- `WeftApp.make()` creates a Weft app
|
|
47
|
-
- The component function runs **once**. Nothing here re-runs on a timer or a state change
|
|
43
|
+
- `App()` returns a **`Node<never, never>`**. The two type parameters are the error channel (`E`) and the requirement channel (`R`), both `never` here because this component neither fails nor needs a service. As your app grows, those channels accumulate what it can fail with and what it depends on. That is the whole point of Weft's types: see [The Rendering Model](https://weftui.dev/docs/explanation/rendering-model).
|
|
44
|
+
- `WeftApp.make()` creates a Weft app synchronously, with no layer to build yet. `WeftApp.mount(app, node, target)` renders the node into `target`, building real DOM and starting any reactive streams. It returns `Effect<RootHandle, …>` with `R = never`, so a bare `Effect.runPromise` runs it with no `Effect.provide` needed. You will give `WeftApp.make` a `Layer` once components need services: see [Services and Async](https://weftui.dev/docs/tutorial/03-services-and-async).
|
|
45
|
+
- The component function runs **once**. Nothing here re-runs on a timer or a state change, because there is no state yet. That comes next.
|
|
48
46
|
|
|
49
47
|
## Next
|
|
50
48
|
|
|
51
|
-
- [Reactivity →](https://weftui.dev/docs/tutorial/02-reactivity)
|
|
49
|
+
- [Reactivity →](https://weftui.dev/docs/tutorial/02-reactivity): make the UI change over time with `SubscriptionRef` and streams
|
|
@@ -7,7 +7,7 @@ description: Add component-local state with SubscriptionRef and weave its stream
|
|
|
7
7
|
|
|
8
8
|
# Reactivity
|
|
9
9
|
|
|
10
|
-
[Previously](https://weftui.dev/docs/tutorial/01-your-first-app) we mounted a static component. Now we make it change over time
|
|
10
|
+
[Previously](https://weftui.dev/docs/tutorial/01-your-first-app) we mounted a static component. Now we make it change over time. This is the defining move in Weft: **weave a stream through the tree, and only that point updates.**
|
|
11
11
|
|
|
12
12
|
## Local state with `SubscriptionRef`
|
|
13
13
|
|
|
@@ -33,15 +33,17 @@ const app = WeftApp.make();
|
|
|
33
33
|
void Effect.runPromise(WeftApp.mount(app, Counter(), document.getElementById("root")!));
|
|
34
34
|
```
|
|
35
35
|
|
|
36
|
-
`Effect.gen` lets you `yield*` the `SubscriptionRef` to set up state **before** building the tree. Because a `Node` is an `Effect`, the component body is an ordinary generator
|
|
36
|
+
`Effect.gen` lets you `yield*` the `SubscriptionRef` to set up state **before** building the tree. Because a `Node` is an `Effect`, the component body is an ordinary generator: no hooks, no dependency arrays.
|
|
37
37
|
|
|
38
38
|
## The key idea: the body runs once
|
|
39
39
|
|
|
40
|
-
The `Counter` function runs **exactly once**. It creates the ref, builds the tree, and returns. After that, nothing re-invokes it
|
|
40
|
+
The `Counter` function runs **exactly once**. It creates the ref, builds the tree, and returns. After that, nothing re-invokes it. The only thing that changes the DOM is the `SubscriptionRef.changes(count)` stream woven into the `h.span`.
|
|
41
|
+
|
|
42
|
+
When you click `+`, `SubscriptionRef.update` pushes a new value, the stream emits, and the renderer patches _just that span's text_ in place. No diff, no re-render, no sibling touched.
|
|
41
43
|
|
|
42
44
|
This is what "streams are the weft" means in practice: reactivity is local to exactly where you thread a stream. Everything else is static. The full model is [The Rendering Model](https://weftui.dev/docs/explanation/rendering-model); the vocabulary of stream-shaped values is [Reactive Primitives](https://weftui.dev/docs/explanation/reactive-primitives).
|
|
43
45
|
|
|
44
|
-
> **Note.** `[SubscriptionRef.changes(count)]`
|
|
46
|
+
> **Note.** In `[SubscriptionRef.changes(count)]` the stream is passed as a child array. Static values (`"Hello"`, `5`) work in the same position and simply never change. The rule is uniform: a plain value is static, a stream-shaped value is reactive.
|
|
45
47
|
|
|
46
48
|
## Deriving values
|
|
47
49
|
|
|
@@ -51,8 +53,8 @@ Because `SubscriptionRef.changes(count)` returns a `Stream`, you shape reactive
|
|
|
51
53
|
h.span([Stream.map(SubscriptionRef.changes(count), (n) => `Count: ${n}`)]);
|
|
52
54
|
```
|
|
53
55
|
|
|
54
|
-
Anywhere you would compute a derived value, map the stream instead
|
|
56
|
+
Anywhere you would compute a derived value, map the stream instead. The derivation stays reactive.
|
|
55
57
|
|
|
56
58
|
## Next
|
|
57
59
|
|
|
58
|
-
- [Services and Async →](https://weftui.dev/docs/tutorial/03-services-and-async)
|
|
60
|
+
- [Services and Async →](https://weftui.dev/docs/tutorial/03-services-and-async): pull dependencies from the environment and render async loading states
|
|
@@ -7,11 +7,11 @@ description: Give handlers access to services from the environment, and render a
|
|
|
7
7
|
|
|
8
8
|
# Services and Async
|
|
9
9
|
|
|
10
|
-
[So far](https://weftui.dev/docs/tutorial/02-reactivity) our state has been self-contained. Real apps talk to services and wait on async work. Both fall out of the same fact
|
|
10
|
+
[So far](https://weftui.dev/docs/tutorial/02-reactivity) our state has been self-contained. Real apps talk to services and wait on async work. Both fall out of the same fact (a `Node` is an `Effect`), so both use plain Effect.
|
|
11
11
|
|
|
12
12
|
## Handlers that use services
|
|
13
13
|
|
|
14
|
-
An event handler can **return an Effect
|
|
14
|
+
An event handler can **return an Effect**. That Effect runs in the app's environment, so it can read any service the app's layer provides:
|
|
15
15
|
|
|
16
16
|
```typescript
|
|
17
17
|
import { h } from "@weftui/core";
|
|
@@ -38,12 +38,14 @@ const LogButton = () =>
|
|
|
38
38
|
"Log",
|
|
39
39
|
);
|
|
40
40
|
|
|
41
|
-
// Give the app the layer
|
|
41
|
+
// Give the app the layer: every handler in every root can now read Logger.
|
|
42
42
|
const app = WeftApp.make(LoggerLive);
|
|
43
43
|
void Effect.runPromise(WeftApp.mount(app, LogButton(), document.getElementById("root")!));
|
|
44
44
|
```
|
|
45
45
|
|
|
46
|
-
`Logger` entered the tree's requirement channel the moment `LogButton` read it
|
|
46
|
+
`Logger` entered the tree's requirement channel the moment `LogButton` read it. You discharged it **once**, by passing `LoggerLive` to `WeftApp.make`. Provide too little and it is a compile error. The type of `app` (and so of `WeftApp.mount(app, LogButton(), …)`) names exactly which service is missing.
|
|
47
|
+
|
|
48
|
+
Services come exclusively from the app's layer: an `Effect.provide` wrapped around the `mount` call does **not** reach components or handlers. This is Weft's entire dependency-injection story; it is just Effect's. The deeper treatment is [Services and Context](https://weftui.dev/docs/explanation/services-and-context).
|
|
47
49
|
|
|
48
50
|
## Async loading states
|
|
49
51
|
|
|
@@ -71,8 +73,10 @@ void Effect.runPromise(
|
|
|
71
73
|
);
|
|
72
74
|
```
|
|
73
75
|
|
|
74
|
-
The stream emits the loading node first, then the resolved node
|
|
76
|
+
The stream emits the loading node first, then the resolved node. The renderer swaps the DOM in place on the second emission.
|
|
77
|
+
|
|
78
|
+
This is the raw mechanism. To coordinate _several_ async regions with a single fallback, reach for [`Boundary.suspend`](https://weftui.dev/docs/explanation/boundaries-and-suspense), which you will meet in the next step.
|
|
75
79
|
|
|
76
80
|
## Next
|
|
77
81
|
|
|
78
|
-
- [Errors and Server Rendering →](https://weftui.dev/docs/tutorial/04-errors-and-server)
|
|
82
|
+
- [Errors and Server Rendering →](https://weftui.dev/docs/tutorial/04-errors-and-server): catch failures with boundaries and render on the server
|
|
@@ -2,12 +2,12 @@
|
|
|
2
2
|
title: Errors and Server Rendering
|
|
3
3
|
order: 4
|
|
4
4
|
section: tutorial
|
|
5
|
-
description: Catch rendering-path failures with Boundary, then render on the server and hydrate
|
|
5
|
+
description: Catch rendering-path failures with Boundary, then render on the server and hydrate. The last step of the tutorial.
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# Errors and Server Rendering
|
|
9
9
|
|
|
10
|
-
The final step. [We can now](https://weftui.dev/docs/tutorial/03-services-and-async) use services and async
|
|
10
|
+
The final step. [We can now](https://weftui.dev/docs/tutorial/03-services-and-async) use services and async. Here we handle what happens when async work **fails**, and how the same tree renders on the server.
|
|
11
11
|
|
|
12
12
|
## Error boundaries
|
|
13
13
|
|
|
@@ -25,11 +25,13 @@ const SafeWidget = () =>
|
|
|
25
25
|
]);
|
|
26
26
|
```
|
|
27
27
|
|
|
28
|
-
There are six failure-catch variants
|
|
28
|
+
There are six failure-catch variants, mirroring Effect's own error operators: `catch`, `catchCause`, `catchTag`, `catchTags`, `catchFilter`, `catchIf`. A failure that a boundary does not match re-raises to the **nearest enclosing** boundary. If none catches it, the mount fails.
|
|
29
|
+
|
|
30
|
+
The conceptual model (and why the boundary's type reflects exactly which failures are handled) is [Boundaries and Suspense](https://weftui.dev/docs/explanation/boundaries-and-suspense).
|
|
29
31
|
|
|
30
32
|
## Render on the server
|
|
31
33
|
|
|
32
|
-
The same component tree renders to HTML on the server and **hydrates in place** on the client
|
|
34
|
+
The same component tree renders to HTML on the server and **hydrates in place** on the client: no re-render, no flash. The server produces markup (plus inline data). `hydrate` adopts that existing DOM and resumes reactivity:
|
|
33
35
|
|
|
34
36
|
```typescript
|
|
35
37
|
// server entry
|
|
@@ -50,7 +52,14 @@ const app = WeftApp.make();
|
|
|
50
52
|
void Effect.runPromise(WeftApp.hydrate(app, App(), document.getElementById("root")!));
|
|
51
53
|
```
|
|
52
54
|
|
|
53
|
-
The same side-effect-free `App` is imported by both entries.
|
|
55
|
+
The same side-effect-free `App` is imported by both entries.
|
|
56
|
+
|
|
57
|
+
For server-resolved data that replays into the client without a second request, `Boundary.rpc` extends this model:
|
|
58
|
+
|
|
59
|
+
1. Resolve an rpc on the server.
|
|
60
|
+
2. Serialize its result into the HTML.
|
|
61
|
+
3. Replay it on hydrate.
|
|
62
|
+
4. Keep the region live for refetch.
|
|
54
63
|
|
|
55
64
|
## You're done
|
|
56
65
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@weftui/core",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Element builders and combinators for Weft
|
|
3
|
+
"version": "0.30.0",
|
|
4
|
+
"description": "Element builders and combinators for Weft: reactive UI, woven from Effect",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"combinators",
|
|
7
7
|
"dom",
|