@webjsdev/mcp 0.1.10 → 0.1.11
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/package.json +2 -2
- package/resources/AGENTS.md +113 -42
- package/resources/SKILL.md +33 -16
- package/resources/references/auth-and-sessions.md +86 -21
- package/resources/references/built-ins.md +55 -9
- package/resources/references/client-router-and-streaming.md +59 -9
- package/resources/references/components.md +117 -8
- package/resources/references/data-and-actions.md +63 -12
- package/resources/references/muscle-memory-gotchas.md +104 -3
- package/resources/references/optimistic-ui.md +49 -10
- package/resources/references/routing-and-pages.md +62 -18
- package/resources/references/runtime.md +10 -1
- package/resources/references/service-worker.md +5 -3
- package/resources/references/styling.md +116 -2
- package/resources/references/testing.md +67 -9
- package/resources/references/typescript.md +75 -6
- package/resources/references/ui-kit.md +18 -10
|
@@ -17,6 +17,16 @@ Read this when a task touches client navigation, prefetch, partial-page swaps, s
|
|
|
17
17
|
|
|
18
18
|
The router auto-enables the moment `@webjsdev/core` loads in the browser, which is any page that ships a component. There is nothing to import or opt into. It intercepts same-origin `<a>` clicks (including inside shadow DOM), fetches the target HTML, and replaces only the inside of the deepest shared layout. Outer header, sidenav, and footer DOM is never re-rendered, so scroll positions, input values, and `<details>` state survive a navigation.
|
|
19
19
|
|
|
20
|
+
**The nav parse must preserve comments.** SSR wraps each layout's children AND the page itself in a KEYED boundary comment pair (open `<!--wj:children:<segment>:<route-key>-->`, close `<!--/wj:children:<segment>-->`, #1015). The route-key is the region's resolved concrete path with each substituted param value percent-encoded (so a user-controlled value can never terminate the comment or collide with the `:` delimiter). The router STRICTLY scans both the live and incoming DOM into segment maps: a close must id-match its innermost open, and ANY truncation, mispair, duplicate, or legacy anonymous open poisons the whole scan. The swap decision is two-tier with Next.js remount parity: a CHANGED route-key REPLACES (a fresh remount, permanents regrafted) at the PARENT of the shallowest changed boundary (a layout's boundary wraps only its children, so its own param-derived markup lives in the parent's range; anchoring there remounts the layout chrome too, exactly like Next re-rendering the layout with new params), else MORPH (the keyed state-preserving reconcile) at the deepest shared boundary when it is the leaf on both sides. The X-Webjs-Have header carries `segment:route-key` entries so the server re-renders (and re-ships) a dynamic layout the client holds for other params instead of short-circuiting past it. A poisoned scan or no shared segment degrades to a FULL PAGE LOAD (dev logs the cause), never a guessed recovery, so silent DOM corruption is structurally impossible. Hydration keys off another comment (`<!--webjs-hydrate-->`, which `__isHydrating()` reads as a component's first child). So the router and hydration both ride on comments SURVIVING the parse that turns a navigation response into a Document, which makes that parse a load-bearing correctness boundary rather than an implementation detail.
|
|
21
|
+
|
|
22
|
+
`Document.parseHTMLUnsafe` STRIPS every comment in Chromium 150 (#1007). No other parse API does: `DOMParser`, `setHTMLUnsafe`, `template.innerHTML`, and plain `innerHTML` all preserve them, and so does the document's own navigation parser, which is why a hard refresh always looked correct and only soft nav broke. With the boundaries gone the router degrades to a full page load (correct, just not soft); with `webjs-hydrate` gone a slotted light-DOM component misses the hydration adopt path. `parseHTML` therefore PROBES `parseHTMLUnsafe` once for losslessness instead of sniffing versions, uses it when it is lossless (it is the only single-pass API that also processes Declarative Shadow DOM), and otherwise parses with `DOMParser`, which preserves comments. A fixed browser silently returns to the fast path.
|
|
23
|
+
|
|
24
|
+
On that fallback, Declarative Shadow DOM is left UNPROCESSED (`DOMParser` does not attach it), a deliberate limitation tracked in #1011, because both ways of adding it back are worse than the gap. Re-serializing via `body.setHTMLUnsafe(body.innerHTML)` is not idempotent (Chromium omits the spec's LF-compensation, so a leading newline in `pre` / `textarea` is silently eaten, which in a `textarea` is form-data corruption), and attaching each root by hand yields a NON-declarative root, which makes any element whose constructor unconditionally calls `attachShadow()` throw `NotSupportedError` on upgrade. The gap costs a JS-less DSD-dependent element its shadow content on a full-body-swap nav, on a stripping browser only; a `static shadow = true` component attaches and renders its own root on upgrade, and a soft nav runs JS by definition.
|
|
25
|
+
|
|
26
|
+
Note for anyone testing this: **the Chromium web-test-runner currently resolves (148) is LOSSLESS, so CI cannot observe the bug at all** (and `playwright` is a caret range, so that version moves on any dependency refresh). A test that merely asserts "markers survive" passes there whether or not the fix exists. The guard in `packages/core/test/routing/browser/comment-preserving-parse.test.js` SIMULATES a stripping parser so it is provable on every engine.
|
|
27
|
+
|
|
28
|
+
**There is NO dropped-marker recovery (#1015 replaced #994's).** The pre-#1015 router "recovered" an orphaned open marker by guessing where its children ended (bounded by the other side's trailing-sibling count), which could guess wrong and corrupt silently. Keyed closes make a mispair DETECTABLE instead, and every integrity violation now degrades to a bounded, correct full page load. The historical producers of lost comments (our own comment-stripping parse #1007, mid-parse soft navs #1008) are fixed upstream, so the degradation is a rare backstop, not a common path. Wrapping `${children}` in a container element (the shipped idiom, `<main>${children}</main>` with the footer a sibling outside it) remains a fine layout pattern, though no correctness now depends on it.
|
|
29
|
+
|
|
20
30
|
**Opting out.** App-wide with config, or per moment at runtime.
|
|
21
31
|
|
|
22
32
|
```jsonc
|
|
@@ -25,10 +35,13 @@ The router auto-enables the moment `@webjsdev/core` loads in the browser, which
|
|
|
25
35
|
```
|
|
26
36
|
|
|
27
37
|
```js
|
|
28
|
-
import { disableClientRouter } from '@webjsdev/core';
|
|
29
|
-
disableClientRouter();
|
|
38
|
+
import { disableClientRouter, enableClientRouter } from '@webjsdev/core';
|
|
39
|
+
disableClientRouter(); // stop intercepting document <a> / <form> (plain links resume full loads)
|
|
40
|
+
enableClientRouter(); // turn soft navigation back on
|
|
30
41
|
```
|
|
31
42
|
|
|
43
|
+
`disableClientRouter()` / `enableClientRouter()` are a runtime pair that toggle only the document-level `<a>` / `<form>` interception. An explicit `navigate(url)` call still does a soft navigation either way (it is not gated by the toggle).
|
|
44
|
+
|
|
32
45
|
Per link, opt out with `data-no-router` (auth flows like `/logout`, OAuth redirects, print views, an experimental route with a different runtime). Cross-origin hrefs, `download`, a non-`_self` target, pure same-page hash jumps, and non-HTML extensions are auto-skipped.
|
|
33
46
|
|
|
34
47
|
**Programmatic navigation and cache eviction.**
|
|
@@ -52,11 +65,24 @@ document.addEventListener('webjs:navigation-error', (e) => {
|
|
|
52
65
|
});
|
|
53
66
|
```
|
|
54
67
|
|
|
68
|
+
**Observing a degradation.** Some conditions make a soft nav impossible, and the router then degrades to a full page load rather than risk a corrupt DOM (the #1015 integrity model). Every such path dispatches `webjs:navigation-fallback` on `document`, in ALL environments including production, with `detail { cause, href, willReload }`. Causes: `no-shared-boundary`, `live-boundaries-malformed`, `incoming-boundaries-malformed`, `readyState-loading`, `deploy-mismatch`, `deploy-mismatch-reload-suppressed`, `navigation-error-unrecoverable`, `revalidation-discarded`, `pre-boot-navigation`. `willReload` is false for a degradation that does NOT reload (a dropped background revalidation), so a listener can tell "this click became a document load" from "a background op was skipped". Not cancelable: by the time it fires the degradation is the only safe option. In dev a deduped console warning also prints.
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
document.addEventListener('webjs:navigation-fallback', (e) => {
|
|
72
|
+
// A full document load on a click is a UX regression worth knowing about in prod.
|
|
73
|
+
if (e.detail.willReload) analytics.track('router_full_load', e.detail);
|
|
74
|
+
});
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
**`pre-boot-navigation` reports ABOUT a load, not during one (#1118).** The boot is a module script, which the HTML spec defers until parsing finishes, while the links it will intercept are clickable from first paint. A click in that window is a plain browser navigation, and the ARRIVING document reports it with `willReload: false` (the load already happened). The window is a few tens of milliseconds warm and network-sized on a cold, throttled first visit, which is why `@webjsdev/core` is hinted in the head with `<link rel="modulepreload">` (emitted only when the page actually ships a boot module) instead of being discovered a round trip later. Read the cause as a RATE: the check knows only that this document arrived by a same-origin navigation that was not a soft nav, so a `data-no-router` link, a `target="_blank"` open, a cross-document form post, and a `clientRouter: false` app all land here too. Excluded: a reload, a back/forward restore, an external or typed entry, and a full load the router itself chose (already reported under its own cause). The report rides the router's own boot, so a fully elided page that ships no client runtime reports nothing.
|
|
78
|
+
|
|
55
79
|
**Form state.** A form submitting through the router gets `aria-busy="true"` for the in-flight duration, plus bubbling `webjs:submit-start` and `webjs:submit-end` (detail `{ form, url, ok }`) events. Style `form[aria-busy="true"]` in pure CSS or listen for the events.
|
|
56
80
|
|
|
81
|
+
**Inline scripts in a swapped range re-execute, so write them to be re-runnable (#1102).** A script the swap brings in runs again on every navigation that swaps its range, whether it sits inside the swapped content or is a top-level node of the range itself (a layout emitting its enhancement script as a sibling of `${children}`). A script parsed out of the response carries the HTML spec's already-started flag and is inert, so the router replaces it with a fresh clone, and the clone is what runs; the clone carries the page-load CSP nonce rather than the one the response was rendered with. Giving the script an `id` does NOT make it run once: the keyed differ reuses the live element and the router still re-emits it. So a script that installs a listener or a `MutationObserver` must be idempotent or guard on a flag it sets the first time. The alternative default, running once and then never again, is the failure this replaced (a progressive-enhancement highlighter that stopped working after the first soft nav). When work genuinely must happen once, put it in the ROOT layout, whose markup is never swapped. `data-webjs-permanent` splits into two cases (#1252). A script that IS the marked element is re-emitted like any other, so the attribute is not an escape hatch for a script itself: its regraft only fires when the node exists on both sides, so exempting it would leave a script that runs on a cold load and never on a soft nav. A script INSIDE a marked element the swap actually preserved is left alone, because the attribute is SUBTREE-scoped and that node survived by identity. The exemption is conditional on real preservation, so a permanent element arriving for the first time, or one with no `id` (which can never be regrafted), still runs its scripts.
|
|
82
|
+
|
|
57
83
|
## Link Prefetch
|
|
58
84
|
|
|
59
|
-
Same-origin in-app links prefetch speculatively so a click resolves from a warm cache. On by default, no per-link opt-in needed. The default strategy is DEVICE-ADAPTIVE, because one strategy cannot serve both input modalities. On a hover-capable fine pointer the default is `intent` (warm on hover/focus after a ~100ms dwell). On touch the default is `viewport` (warm as links settle on-screen), because touch has no hover. Modality is detected with `matchMedia('(hover: hover) and (pointer: fine)')`, never a UA sniff.
|
|
85
|
+
Same-origin in-app links prefetch speculatively so a click resolves from a warm cache. A reduced fragment is served `private`, so no shared cache can store it even if the CDN ignores `Vary` (Cloudflare honours only `Accept-Encoding`); the `Vary: X-Webjs-Have` marking stays as belt-and-braces rather than as the guarantee (#1140). A full document keeps whatever `metadata.cacheControl` declared, so page-level edge caching is unaffected. Router fetches (navigation and prefetch alike) are sent with `cache: 'no-cache'`, so a page cached in the browser with a `max-age` is revalidated rather than replayed: the deploy check reads `x-webjs-build` / `x-webjs-src` off these responses, and a cached response would hand it pre-deploy ids and hide a deploy for the whole freshness window (#1131). The revalidation is answered with a cheap 304, so the cost is a conditional round-trip rather than a re-download: on a page that opted into caching a fragment is `private` but still carries a validator, since `private` forbids only SHARED storage and has no bearing on whether a response can be validated (#1140); a default `no-store` page has nothing to validate either way. On by default, no per-link opt-in needed. The default strategy is DEVICE-ADAPTIVE, because one strategy cannot serve both input modalities. On a hover-capable fine pointer the default is `intent` (warm on hover/focus after a ~100ms dwell). On touch the default is `viewport` (warm as links settle on-screen), because touch has no hover. Modality is detected with `matchMedia('(hover: hover) and (pointer: fine)')`, never a UA sniff.
|
|
60
86
|
|
|
61
87
|
Override per link with the `data-prefetch` attribute.
|
|
62
88
|
|
|
@@ -72,6 +98,8 @@ Next-style aliases work (`true` = `render`, `auto` = `viewport`, `false` = `none
|
|
|
72
98
|
|
|
73
99
|
A prefetch issues a real GET, so any mutating endpoint MUST be a POST or a `<form>` submission (which the router never prefetches), never a GET link. A `webjs:prefetch` event fires on `document` when a fragment lands in the cache.
|
|
74
100
|
|
|
101
|
+
**The cache is ANCHOR-VALIDATED, not just URL-keyed (#1114).** A prefetched fragment is a reduced response: the request carries `X-Webjs-Have` (the boundaries the client already holds) and the server returns only the divergent part from the deepest boundary it short-circuited on. That boundary is the fragment's ANCHOR, and the fragment applies to any live DOM that still offers it with the same route-key. So on consume the router checks the anchor, not the whole `have` string: a root-anchored fragment survives an unrelated navigation and stays a cache hit, while one anchored at `/docs` is discarded once you leave /docs, because applying it would hand the swap a tree sharing no boundary with the live DOM, which correctly degrades to a full page load. A discard costs one round-trip. The router also never prefetches the page it is already on (#1106), since that request cannot serve any later navigation and only occupies a capped cache slot; a hover's intent timer routinely fires after the click it belongs to has already swapped, which is when that happens. Both behaviours are internal; nothing to configure.
|
|
102
|
+
|
|
75
103
|
## `<webjs-frame>` Partial-Swap Regions
|
|
76
104
|
|
|
77
105
|
`<webjs-frame>` is WebJs's take on Turbo Frames, so most `<turbo-frame>` muscle memory transfers. It is a lazy, URL-addressable region that swaps on its own, driven by a link or form targeting its id, and it ships zero component JS. Use it for a region that loads or refreshes INDEPENDENTLY of a full-page navigation (a marketing widget, tabbed UI, a filtered results panel), which a page or layout cannot express.
|
|
@@ -103,7 +131,16 @@ The router can wrap a navigation's DOM mutation in the native View Transitions A
|
|
|
103
131
|
<meta name="view-transition" content="same-origin">
|
|
104
132
|
```
|
|
105
133
|
|
|
106
|
-
|
|
134
|
+
A page (or layout) does not write raw `<head>` markup, so emit that meta through the `other` metadata field, which scopes it to the page that declares it:
|
|
135
|
+
|
|
136
|
+
```ts
|
|
137
|
+
// app/gallery/page.ts
|
|
138
|
+
export const metadata = { other: { 'view-transition': 'same-origin' } };
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
The accepted value is `same-origin`. When enabled it wraps every swap path (the two-tier boundary swap, the `<webjs-frame>` swap, and the background-revalidation full-body path). When `startViewTransition` is unavailable the swap runs synchronously with no flash and no throw. To persist a live element (a playing `<audio>`, an open menu) across a swap by node identity, mark it `data-webjs-permanent` and give it an `id`. The attribute is SUBTREE-scoped, so once the element has actually been preserved, a `<script>` inside it is not re-emitted and does not re-run (#1252); a permanent element arriving for the first time, or one with no `id`, is ordinary new content and runs its scripts.
|
|
142
|
+
|
|
143
|
+
The opt-in is **per page**, so it is a page-scoped meta: put it on a page's metadata to animate that page, or on the root layout to animate the whole app. Navigating to a page that does NOT declare it turns transitions back off, because the soft-nav head merge reconciles page-scoped `<meta>` tags (a stale one the previous page declared is removed, not left to leak, #1046). View transitions **compose with Suspense streaming**: a streamed boundary (a `loading.{js,ts}` skeleton or a `<webjs-suspense>` region) navigated to under an active transition still resolves its content progressively, because the streamed resolve waits for the transition's DOM swap to commit before it applies (#1048).
|
|
107
144
|
|
|
108
145
|
## `<webjs-stream>` Surgical Updates
|
|
109
146
|
|
|
@@ -120,17 +157,18 @@ Actions: `append` / `prepend` (child of the target id), `before` / `after` (sibl
|
|
|
120
157
|
```ts
|
|
121
158
|
// app/post/[id]/route.ts
|
|
122
159
|
import { stream, streamResponse, acceptsStream, broadcast } from '@webjsdev/server';
|
|
160
|
+
import { escapeText } from '@webjsdev/core';
|
|
123
161
|
|
|
124
162
|
export async function POST(req: Request, { params }) {
|
|
125
163
|
const comment = await addComment(params.id, await req.formData());
|
|
126
|
-
const parts = stream.append('comments', `<li>${
|
|
164
|
+
const parts = stream.append('comments', `<li>${escapeText(comment.text)}</li>`);
|
|
127
165
|
broadcast(`post:${params.id}`, parts); // fan out to every viewer
|
|
128
166
|
if (acceptsStream(req)) return streamResponse(parts);
|
|
129
|
-
return Response.redirect(`/post/${params.id}`, 303); // no-JS fallback
|
|
167
|
+
return Response.redirect(new URL(`/post/${params.id}`, req.url), 303); // no-JS fallback
|
|
130
168
|
}
|
|
131
169
|
```
|
|
132
170
|
|
|
133
|
-
`stream.*` escapes the target id but NOT the content, so escape any user substring yourself, exactly like an `html` hole.
|
|
171
|
+
`stream.*` escapes the target id but NOT the content, so escape any user substring yourself with `escapeText` (from `@webjsdev/core`), exactly like an `html` hole.
|
|
134
172
|
|
|
135
173
|
## Streaming (Suspense and RPC)
|
|
136
174
|
|
|
@@ -176,13 +214,25 @@ export function WS(ws, req, { params }) {
|
|
|
176
214
|
}
|
|
177
215
|
```
|
|
178
216
|
|
|
179
|
-
**Client.** `connectWS(url, handlers)` from `@webjsdev/core` auto-reconnects with exponential backoff, handles JSON parse/stringify, and queues sends while disconnected.
|
|
217
|
+
**Client.** `connectWS(url, handlers)` from `@webjsdev/core` auto-reconnects with exponential backoff, handles JSON parse/stringify, and queues sends while disconnected. The handler set is `{ onOpen, onMessage, onClose }`, and it RETURNS a connection handle with `.send(data)` and `.close()`. Open it in `connectedCallback` and close it in `disconnectedCallback`, driving a connection-status signal from `onOpen` / `onClose`:
|
|
180
218
|
|
|
181
219
|
```js
|
|
182
220
|
import { connectWS, renderStream } from '@webjsdev/core';
|
|
183
|
-
|
|
221
|
+
|
|
222
|
+
connectedCallback() {
|
|
223
|
+
super.connectedCallback();
|
|
224
|
+
this.conn = connectWS('/feed', {
|
|
225
|
+
onOpen: () => (this.online = true),
|
|
226
|
+
onClose: () => (this.online = false),
|
|
227
|
+
onMessage: (m) => renderStream(m), // apply a server-pushed <webjs-stream> payload
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
disconnectedCallback() { super.disconnectedCallback(); this.conn?.close(); }
|
|
231
|
+
send(text) { this.conn.send(text); }
|
|
184
232
|
```
|
|
185
233
|
|
|
234
|
+
**Gotcha: a component re-render clobbers surgical `renderStream()` updates.** `renderStream()` (and `<webjs-stream>` in general) mutates the DOM out of band, appending rows the component's own `render()` does not know about. If the component then re-renders, `render()` re-runs and wipes those out-of-band rows. So render the target container ONCE and drive any mutation counter with a PLAIN instance field, never a signal or reactive prop that `render()` reads (a read would re-render and blow away the streamed-in DOM).
|
|
235
|
+
|
|
186
236
|
**Broadcast.** `broadcast(path, data)` from `@webjsdev/server` fans a message to every connected client on that path (single-instance). For multi-instance, add Redis pub/sub yourself, there is no framework magic.
|
|
187
237
|
|
|
188
238
|
## Navigation-Loading Indicator (opt-in)
|
|
@@ -3,11 +3,13 @@
|
|
|
3
3
|
## What This Covers
|
|
4
4
|
|
|
5
5
|
- Declaring reactive properties through the `WebComponent({ ... })` factory and `prop()`, with options (`reflect`, `state`, `attribute`, `default`, `converter`, `hasChanged`)
|
|
6
|
-
- Signals as the default state primitive for component-local and shared state
|
|
6
|
+
- Signals as the default state primitive for component-local and shared state, plus `effect` / `batch`
|
|
7
7
|
- The Lit-aligned lifecycle and exactly which hooks SSR runs versus skips
|
|
8
8
|
- Light DOM (default) versus shadow DOM, and the light-host `display: block` rule
|
|
9
9
|
- Slots with full shadow-DOM parity in both DOM modes
|
|
10
10
|
- `async render()`: SSR-blocking first paint, client stale-while-revalidate, `renderFallback()` / `renderError()`
|
|
11
|
+
- `Task` for client-only async data, and context (`createContext` / `ContextProvider` / `ContextConsumer`) to avoid attribute drilling
|
|
12
|
+
- The lit-html directive set (`repeat`, `watch`, `live`, `keyed`, `guard`, `cache`, `until`, `unsafeHTML`, `ref`, `asyncAppend` / `asyncReplace`, `templateContent`)
|
|
11
13
|
- Display-only elision (when a component is stripped from the browser)
|
|
12
14
|
- Inherited members app code must NOT shadow (`title`, `remove`, `render`, ...)
|
|
13
15
|
|
|
@@ -46,7 +48,7 @@ The bare form is shorthand: `count: Number` means `prop(Number)`. Use `prop()` t
|
|
|
46
48
|
| Option | Default | Meaning |
|
|
47
49
|
|---|---|---|
|
|
48
50
|
| `type` | `String` | Constructor feeding the default attribute converter |
|
|
49
|
-
| `reflect` | `false` | Property changes write back to the HTML attribute |
|
|
51
|
+
| `reflect` | `false` | Property changes write back to the HTML attribute (a function value removes it instead, see below) |
|
|
50
52
|
| `state` | `false` | Internal-only. No attribute, not observed |
|
|
51
53
|
| `attribute` | derived from name | The HTML attribute name the property rides |
|
|
52
54
|
| `default` | none | Declarative initial value (a function runs per instance for a fresh object / array) |
|
|
@@ -55,6 +57,8 @@ The bare form is shorthand: `count: Number` means `prop(Number)`. Use `prop()` t
|
|
|
55
57
|
|
|
56
58
|
For an array-typed prop pass `Array`, not `Object` (`array-prop-uses-array-type` flags the `Object` form). For anything the built-in converters cannot parse (Date, Map, Set) supply a `converter`.
|
|
57
59
|
|
|
60
|
+
**A `reflect: true` property holding a FUNCTION drops its attribute instead of writing one, and so does one holding an array that carries a function, unless the prop is `Object` or `Array` typed.** A function has no HTML attribute representation, and the serializations it would otherwise get are both useless and dangerous. `String(fn)` is the function's SOURCE, so a reflected `'use server'` action would ship its whole body, closure secrets included, to every visitor, and `JSON.stringify(fn)` is `undefined`, which lands in the attribute as the literal four-character string. So the reflection path treats a function like `null`, removes the attribute, and warns naming the property, the tag, and the attribute. This holds on both sides, since SSR and the client-side setter run the same path, and it holds for every property name (the leak was never specific to one called `action`). Two exceptions. A property with a custom `converter.toAttribute` runs that converter first and is left alone, because an author who writes one has taken responsibility for serializing whatever they are handed. And an `Object` or `Array` typed property CARRYING a function keeps its data, because `JSON.stringify` drops the function to `null` and omits the key, so `[1, 2, fn]` reflects as `[1,2,null]` with no source and nothing else lost. If you need a function on a component, use a plain property or a signal and do not mark it `reflect`.
|
|
61
|
+
|
|
58
62
|
**Never use a class-field declaration OR initializer** (`count = 0`, `student: Student = {...}`, `todos!: Todo[]`). Under `useDefineForClassFields` even a type-only `todos!: Todo[]` compiles to define an own property after `super()`, which clobbers the prototype's reactive accessor and silently breaks reactivity. Only declare props in the factory and read/write them off `this`. The `reactive-props-no-class-field` rule catches this.
|
|
59
63
|
|
|
60
64
|
## Signals are the default state primitive
|
|
@@ -70,6 +74,19 @@ const count = computed(() => cart.get().length); // derived
|
|
|
70
74
|
|
|
71
75
|
Read with `signal.get()` inside `render()`; the built-in `SignalWatcher` tracks the read and re-renders on change. An instance signal created in the constructor is component-local. For a fine-grained DOM swap use `${watch(signal)}` from `@webjsdev/core/directives`.
|
|
72
76
|
|
|
77
|
+
Two more signal primitives from `@webjsdev/core` cover client-side reactions and batched writes:
|
|
78
|
+
|
|
79
|
+
- `effect(fn)` runs `fn` now and re-runs it whenever a signal it read changes. It is a BROWSER-ONLY side-effect primitive (a subscription, a `document.title` sync, an analytics ping), not a render path. It returns a disposer, so create it in `connectedCallback` and call the disposer in `disconnectedCallback` to avoid a leak.
|
|
80
|
+
- `batch(fn)` coalesces several `.set()` writes inside `fn` into ONE re-render instead of one per write. Reach for it when a handler updates multiple signals at once.
|
|
81
|
+
|
|
82
|
+
```ts
|
|
83
|
+
import { signal, effect, batch } from '@webjsdev/core';
|
|
84
|
+
const open = signal(false), count = signal(0);
|
|
85
|
+
connectedCallback() { super.connectedCallback(); this.dispose = effect(() => { document.title = `(${count.get()})`; }); }
|
|
86
|
+
disconnectedCallback() { super.disconnectedCallback(); this.dispose?.(); }
|
|
87
|
+
reset() { batch(() => { open.set(false); count.set(0); }); } // one re-render, not two
|
|
88
|
+
```
|
|
89
|
+
|
|
73
90
|
## Lifecycle (Lit-aligned) and what SSR runs
|
|
74
91
|
|
|
75
92
|
Each update cycle runs these in order; each receives a `changedProperties` Map.
|
|
@@ -103,7 +120,13 @@ class Panel extends WebComponent({ label: String }) {
|
|
|
103
120
|
|
|
104
121
|
## Slots
|
|
105
122
|
|
|
106
|
-
The full `<slot>` surface works in light DOM with shadow-DOM parity; migrating modes never requires a template rewrite.
|
|
123
|
+
The full `<slot>` surface works in light DOM with shadow-DOM parity; migrating modes never requires a template rewrite. A forwarded slot projects its content everywhere (client, SSR, hydration).
|
|
124
|
+
|
|
125
|
+
**A tag name inside an HTML comment is not instantiated.** `<!-- <my-card> is the wrapper -->` documents the template and renders no component, the same as in a browser. That holds for component tags, `<slot>`, and `<webjs-suspense>`. It extends to attribute values and to every element whose content the HTML parser reads as text rather than markup: `<script>`, `<style>`, `<iframe>`, `<xmp>`, `<noembed>`, `<noframes>`, `<plaintext>`, `<textarea>`, `<title>`. (Before #1128 a commented tag was constructed as a real element and ate the rest of the comment along with the markup after it, so an ordinary explanatory comment could silently delete part of the page.)
|
|
126
|
+
|
|
127
|
+
Two elements are deliberately excluded, and the second matters more. `<template>` content IS parsed and legitimately carries components, which is what Declarative Shadow DOM and streamed swaps rely on. `<noscript>` content is also parsed, because a browser with scripting disabled reads it as markup, and that is the case a progressive-enhancement framework exists to serve, so components inside `<noscript>` render normally.
|
|
128
|
+
|
|
129
|
+
Element nesting respects comments too: a comment holding the open or close tag of the element it sits inside (`<my-card>kid<!-- </my-card> --></my-card>`) rides along as content, and the element still ends at its real close tag. Script bodies follow the parser's double-escape rule, so the legacy `<!-- <script>... -->` wrapper pattern stays text to its true end.
|
|
107
130
|
|
|
108
131
|
```ts
|
|
109
132
|
class MyCard extends WebComponent {
|
|
@@ -116,7 +139,21 @@ class MyCard extends WebComponent {
|
|
|
116
139
|
}
|
|
117
140
|
```
|
|
118
141
|
|
|
119
|
-
Named slots, the default slot (unnamed children, text, comments), fallback content (a slot's inner markup when nothing matches), first-wins resolution
|
|
142
|
+
Named slots, the default slot (unnamed children, text, comments), fallback content (a slot's inner markup when nothing matches), and first-wins resolution all behave per spec. The DOM API mirrors shadow slots: `assignedNodes` / `assignedElements` (with `{ flatten: true }`), `element.assignedSlot`, and the `slotchange` event. Both modes are SSR'd (light DOM places children into `<slot data-webjs-light data-projection="actual">`, shadow DOM via Declarative Shadow DOM), so slotted content renders with no JS.
|
|
143
|
+
|
|
144
|
+
**Light-DOM slots ARE the native DOM slot API (#1021, full shadow parity).** There is no WebJs-specific slot API. Post-mount writes are live exactly as in shadow DOM, and moving a component between `static shadow = false` and `true` never needs a rewrite:
|
|
145
|
+
|
|
146
|
+
```ts
|
|
147
|
+
const card = document.querySelector('my-card');
|
|
148
|
+
card.appendChild(node); // live, projected
|
|
149
|
+
card.querySelector('[slot=old]').slot = 'new'; // flip re-projects
|
|
150
|
+
card.innerHTML = '<p>replaced</p>'; // replaces slotted content
|
|
151
|
+
card.querySelector('slot').assignedNodes(); // read, mirrors shadow
|
|
152
|
+
node.assignedSlot;
|
|
153
|
+
card.querySelector('slot').addEventListener('slotchange', ...); // async + coalesced
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Things to internalize. (1) Every native mutation is live: `appendChild` / `insertBefore` / `removeChild` / `el.remove()` / `innerHTML` / `el.slot=` flip / `HTMLSlotElement.assign()`. Reorder-by-append moves a child to the end (native semantics), a fragment expands and drains, and `insertBefore` against a renderer/non-child ref throws `NotFoundError`. One caveat rides `assign()`: the light-DOM version is an EXTENSION (an element-bound overlay while name matching keeps working), and native shadow `assign()` needs `slotAssignment: 'manual'` which WebJs does not set, so `assign()` is the one write that does NOT survive flipping to `static shadow = true`; avoid it in mode-portable components. (2) Four inherent gaps (from light DOM having no shadow boundary). The gaps: structural host reads (`host.children` / `host.childNodes` / `querySelector(':scope > ...')` / the `innerHTML` GETTER read the rendered template, not the authored children, so read slotted content with `assignedNodes()`); `assignedChild.parentNode` is the `<slot>`; `::slotted()` CSS is shadow-only (style slotted content with normal selectors / Tailwind); and initial-projection lifecycle timing (`firstUpdated` sees the `<slot>` element with EMPTY `assignedNodes()`, because the first light-DOM projection lands one microtask after the first render, where shadow DOM projects natively before it; read assigned content from a `slotchange` listener or after a microtask). (3) Conditional-on-slot at render time does not exist in EITHER mode (a shadow template can't branch on light-child presence at render time either); use CSS `:has()` / `slot:empty` or a `slotchange` listener. (4) The name `default` is a reserved alias for the default slot; do not name a slot `default`. (5) A display-only slotted wrapper still elides; a component whose slots are mutated at runtime is already shipped because a consumer references its tag (force a ship with `static interactive = true` only for a dynamically-resolved reference the analyser cannot see). (6) A generic DOM library should operate on the assigned nodes, never on the host element itself; writes into an ACTIVELY ASSIGNED slot container are folded into the record (self-heal), while a fallback-mode slot's content is renderer-owned and out of contract. (7) A FORWARDED slot projects its content everywhere (#1023): a template may forward a slot into a nested component (html`<inner-shell><slot></slot></inner-shell>`), and the outer component's content projects through it on a client-only mount, in the SSR first paint, and across hydration (no flash back to fallback). The renderer stamps each slot with its template owner (carried across SSR as `data-wj-slot-owner`), so a forwarded slot routes to the outer host that rendered it, not the child it nests in. (8) A LAYOUT's named slots stay in sync across soft navigation (#1024): when a layout renders its `${children}` inside a slotted shell and a page emits top-level `slot=`-attributed children, the named-slot slices update on a soft-nav boundary swap just as the default slice does (the swap resyncs every own slot of the enclosing shell from the incoming page).
|
|
120
157
|
|
|
121
158
|
A compound child reads its parent at the first server paint via `closest('ui-tabs')` (only tag-name selectors resolve at SSR, and the compound parent must be light DOM). Genuine live-DOM reads (`querySelector`, `classList`, geometry) still throw at SSR, so keep them in `connectedCallback` / `firstUpdated`.
|
|
122
159
|
|
|
@@ -140,10 +177,81 @@ Three decoupled concerns, do not conflate them.
|
|
|
140
177
|
2. **The client re-fetch default is stale-while-revalidate.** When a prop or dependency change re-runs `async render()`, the previous content stays until the new render resolves. No blank, no flash, no user code.
|
|
141
178
|
3. **`renderFallback()` is the OPTIONAL re-fetch loading UI.** Shown ONLY during a client re-fetch, NEVER on first paint, and it does NOT create a server-streaming boundary.
|
|
142
179
|
|
|
143
|
-
Errors are isolated per component by default (no user code): a thrown `await` renders a component-scoped error state while siblings render, never bubbling to the route `error.ts`. Override `renderError(error)` only to customize it (dev shows the message, prod stays silent).
|
|
180
|
+
Errors are isolated per component by default (no user code): a thrown `await` renders a component-scoped error state while siblings render, never bubbling to the route `error.ts`. Override `renderError(error)` only to customize it (dev shows the message, prod stays silent). The boundary covers the COMMIT as well as the fetch, so a template that throws while being applied (a refused binding, a value whose `toString` throws) reaches `renderError()` too, and `updateComplete` still settles. Those two halves used to disagree: a fetch rejection was contained and a commit throw escaped as an unhandled rejection that also left `updateComplete` pending forever.
|
|
181
|
+
|
|
182
|
+
The boundary also covers `watch(signal)` (its notify microtask) and `until()` (its promise resolution), which commit outside the update cycle. A throw from either used to surface at the window instead of the owning component. It routes to the component whose TEMPLATE holds the binding, which is not always the element the binding sits inside: `html`<child-el>${watch(sig)}</child-el>`` belongs to the parent that wrote it, not to `child-el`. `asyncAppend` / `asyncReplace` is the third such site and is covered the same way: a chunk's own commit throw, and a `watch` / `until` nested inside a chunk, both reach the owning component's `renderError()`. A chunk's own commit throw also STOPS the stream, since the boundary is about to render an error state and appending into a region it may have replaced is not a recovery; a nested directive throws from its own handler outside that loop, so it reaches the boundary but does not stop the stream, the same as a directive nested anywhere else. What stays at `console.error` is the author's own code, the iterable AND any `mapper` passed alongside it, on the standing reasoning that an author's iterable should handle its own errors. That ends the stream too, and always has. With a bare `render()` into a plain container there is no component to receive a commit throw, so it surfaces rather than being swallowed, which is what `watch` and `until` already do.
|
|
183
|
+
|
|
184
|
+
**A commit that throws leaves the directive's own state consistent, so the NEXT valid render is correct.** This matters because the corruption is otherwise silent: the renders that expose it are fully valid and log nothing after the first throw. The hole whose commit threw is marked so the next render re-applies it rather than skipping it as unchanged (its recorded value is never advanced past a throw, and would otherwise match exactly what the recovering render supplies, leaving a child region blank for good). Both list reconcilers additionally repair their own bookkeeping so it describes the DOM again, and the next render is an ordinary reconcile rather than a rebuild of the region, which would discard the node identity the reconcilers exist to preserve. `repeat()` re-unites its key map and repositions every row (the failure was a permanently duplicated row). A plain `.map()` array splices the part of its slot list the failed pass never reached back on, which matters whenever a slot is REPLACED rather than updated in place (its template shape changed, its kind changed between text, template and empty, or the array grew past its old length), since that is the branch that inserts the replacement before removing what it replaced (the failure was a stranded row that outlived even a render of an empty array). `guard()` records its new deps only once the commit succeeds, so a later render with those same deps re-renders the region instead of short-circuiting past a region the throw had blanked; `until()` advances its resolved priority only after the commit succeeds, so a failed high-priority resolution does not refuse the lower-priority one behind it.
|
|
185
|
+
|
|
186
|
+
**Teardown is total as well.** Removing a row is not a commit and has no retry, so a throw while tearing one down cannot be allowed to abandon the rest. Unbinding a `ref` during teardown can never abort the removal of the remaining rows, and `repeat()` drops each leftover key from its map before touching that row, so the map never describes a row that has already been removed (which used to leave the row the app DELETED on screen, reorder the survivors, and let a later render that re-added that key reinsert the disposed instance). To make that hold, a `ref` whose object `value` setter throws is now SWALLOWED on teardown, matching the ref CALLBACK, which was already swallowed everywhere. That is a deliberate divergence from lit, which guards neither and propagates from both. It applies to teardown only: on the COMMIT path a throwing object-ref setter still reaches `renderError()`, because there the boundary can report it and the next render can repair it.
|
|
144
187
|
|
|
145
188
|
Decision rules. Use `async render()` for request-time server data that should be in the first paint (the default). Add `renderFallback()` when a client re-fetch's stale content would mislead. Use `Task` / signals for genuinely client-only data (a click, viewport, live updates). For SLOW data where blocking the first byte hurts, wrap the region in `<webjs-suspense .fallback=${html\`Loading...\`}>` to stream it (the only way to show a first-paint fallback; see `client-router-and-streaming.md`). Do NOT fetch in `connectedCallback` for data knowable server-side, and do NOT prop-drill what a leaf can fetch itself.
|
|
146
189
|
|
|
190
|
+
## Task: client-only async data
|
|
191
|
+
|
|
192
|
+
For async data that is genuinely CLIENT-only (it depends on a click, viewport, or a live source, so `async render()` cannot bake it in at SSR), use the `Task` reactive controller. It shows its pending state at SSR (staying `INITIAL`), then runs in the browser.
|
|
193
|
+
|
|
194
|
+
```ts
|
|
195
|
+
import { Task, TaskStatus } from '@webjsdev/core/task';
|
|
196
|
+
|
|
197
|
+
class SearchResults extends WebComponent({ q: String }) {
|
|
198
|
+
#search = new Task(this, {
|
|
199
|
+
task: async ([q], { signal }) => (await fetch(`/api/s?q=${q}`, { signal })).json(),
|
|
200
|
+
args: () => [this.q], // the args array spreads into the task's first parameter
|
|
201
|
+
});
|
|
202
|
+
render() {
|
|
203
|
+
switch (this.#search.status) {
|
|
204
|
+
case TaskStatus.PENDING: return html`<p>Searching...</p>`;
|
|
205
|
+
case TaskStatus.ERROR: return html`<p>${this.#search.error.message}</p>`;
|
|
206
|
+
case TaskStatus.COMPLETE: return html`<ul>${this.#search.value.map((r) => html`<li>${r.title}</li>`)}</ul>`;
|
|
207
|
+
default: return html`<p>Type to search.</p>`; // INITIAL (also the SSR state)
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
`args()` re-runs the task whenever its return changes; call `this.#task.run()` to trigger it manually. `TaskStatus` is `INITIAL` / `PENDING` / `COMPLETE` / `ERROR`. Prefer `async render()` for server data that should be in the first paint; reach for `Task` only when the data cannot exist until the browser runs.
|
|
214
|
+
|
|
215
|
+
## Context: share state without attribute drilling
|
|
216
|
+
|
|
217
|
+
When a value must reach a deep descendant without threading it through every intermediate component's attributes, use context (from `@webjsdev/core/context`). This is a CLIENT-TIME concern: a provider publishes on connect, so context is empty at SSR. For server-known data, pass it through the page function or a `.prop` instead (see `muscle-memory-gotchas.md`).
|
|
218
|
+
|
|
219
|
+
```ts
|
|
220
|
+
import { createContext, ContextProvider, ContextConsumer } from '@webjsdev/core/context';
|
|
221
|
+
|
|
222
|
+
export const themeContext = createContext<'light' | 'dark'>('theme');
|
|
223
|
+
|
|
224
|
+
class ThemeRoot extends WebComponent({}) {
|
|
225
|
+
#provider = new ContextProvider(this, { context: themeContext, initialValue: 'dark' });
|
|
226
|
+
toggle() { this.#provider.setValue(this.#provider.value === 'dark' ? 'light' : 'dark'); }
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
class ThemedCard extends WebComponent({}) {
|
|
230
|
+
#theme = new ContextConsumer(this, { context: themeContext, subscribe: true }); // re-renders on change
|
|
231
|
+
render() { return html`<div class=${this.#theme.value === 'dark' ? 'bg-black' : 'bg-white'}>...</div>`; }
|
|
232
|
+
}
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
`subscribe: true` re-renders the consumer on every provider change; omit it for a one-shot read. A component can also fire a `ContextRequestEvent` to pull a value imperatively.
|
|
236
|
+
|
|
237
|
+
## Directives (lit-html parity)
|
|
238
|
+
|
|
239
|
+
Import from `@webjsdev/core/directives`. Everything a `class`/`style`/conditional needs is plain JS (`classMap` is `class=${cond ? 'a' : 'b'}`, `when` is a ternary, `map` is `.map`); reach for a directive only for the jobs below.
|
|
240
|
+
|
|
241
|
+
| Directive | Use it for |
|
|
242
|
+
|---|---|
|
|
243
|
+
| `repeat(items, keyFn, tpl)` | A keyed list where items reorder / insert / remove (preserves DOM + state per key). A static list is a plain `.map`. |
|
|
244
|
+
| `watch(signal)` | A fine-grained DOM swap of one signal's value without re-rendering the whole component. |
|
|
245
|
+
| `live(value)` | An `input` / `textarea` `.value` bound to state, so a user edit that equals the last committed value still resets. |
|
|
246
|
+
| `keyed(key, tpl)` | Force a fresh subtree (discard old DOM + state) when `key` changes. |
|
|
247
|
+
| `guard(deps, () => tpl)` | Skip re-rendering an expensive subtree unless `deps` change. |
|
|
248
|
+
| `cache(tpl)` | Keep the DOM of an inactive branch around when toggling between templates. |
|
|
249
|
+
| `until(promise, fallback)` | Render `fallback` until `promise` resolves (prefer `Task` in a component, `Suspense` for a page). |
|
|
250
|
+
| `unsafeHTML(str)` | Render a TRUSTED raw HTML string. NEVER pass user input (XSS). |
|
|
251
|
+
| `ref(cb)` / `createRef()` | Get a handle to the rendered DOM node. |
|
|
252
|
+
| `asyncAppend(iter)` / `asyncReplace(iter)` | Stream from an async iterable, appending each value or replacing with the latest. |
|
|
253
|
+
| `templateContent(el)` | Render the content of a `<template>` element. |
|
|
254
|
+
|
|
147
255
|
## Display-only elision
|
|
148
256
|
|
|
149
257
|
A component that does no client-side work renders the same SSR'd HTML with or without its JS, so WebJs strips its import from the served source (and any vendor reachable only through it). This is automatic and conservative. A component stays elidable while it has NONE of:
|
|
@@ -153,15 +261,16 @@ A component that does no client-side work renders the same SSR'd HTML with or wi
|
|
|
153
261
|
- an overridden lifecycle hook (including `renderFallback` / `renderError`)
|
|
154
262
|
- an imported `signal` / `computed` / `watch` / `Task` / `ref` / streaming directive, or `addController` / `requestUpdate`
|
|
155
263
|
- code that runs at module load (a top-level call, non-data `new`, dynamic `import(...)`, top-level `await`); only declarations and `X.register(...)` are allowed
|
|
156
|
-
- a
|
|
264
|
+
- the dynamic slot READ surface (`slotchange`, `assignedNodes` / `assignedElements` / `assignedSlot`); merely RENDERING a `<slot>` does not ship (the SSR output carries the placed children, so a display-only slotted wrapper is byte-identical without its JS; native-write liveness is consumer-driven and the consumer's tag reference forces the ship)
|
|
265
|
+
- being rendered by a component that itself ships
|
|
157
266
|
|
|
158
267
|
A bare `async render()` (no other signal, light DOM) is elided too: the SSR'd data is the complete first paint. Force shipping with `static interactive = true` when interactivity is invisible to static analysis (a dynamically-built tag string, a `:defined` rule in an external stylesheet). `static shadow = true` always ships (Declarative Shadow DOM re-attaches only during parsing). Turn elision off app-wide with `{ "webjs": { "elide": false } }` or `WEBJS_ELIDE=0`.
|
|
159
268
|
|
|
160
269
|
## Members app code must not shadow
|
|
161
270
|
|
|
162
|
-
A `WebComponent` inherits `HTMLElement` (browser) or an `ElementShim` (SSR) plus the framework reactivity base. A reactive prop or method whose NAME collides either fails to compile (`TS2415` for a type-incompatible property, `TS2416` for a method signature) or silently hijacks the native member at runtime. The fix is always to rename.
|
|
271
|
+
A `WebComponent` inherits `HTMLElement` (browser) or an `ElementShim` (SSR) plus the framework reactivity base. A reactive prop or method whose NAME collides either fails to compile (`TS2415` for a type-incompatible property, `TS2416` for a method signature) or silently hijacks the native member at runtime. The fix is always to rename. The DOM MUTATION methods WebJs instruments for the light-DOM slot API (`append`, `prepend`, `before`, `after`, `replaceWith`, `replaceChildren`, `remove`, `appendChild`, `insertBefore`, `removeChild`, `replaceChild`) are the dangerous case TypeScript does NOT catch (a shorter override is assignable to the native signature), so a handler named `append()` compiles yet silently never runs. `webjs check`'s `no-shadowed-native-member` rule catches exactly these.
|
|
163
272
|
|
|
164
273
|
- HTMLElement / Element: `title`, `id`, `slot`, `role`, `hidden`, `dir`, `lang`, `translate`, `draggable`, `tabIndex`, `className`, `dataset`, `remove`, `closest`, `matches`, `focus`, `blur`, `click`, `append` / `prepend`, `before` / `after`. Rename (`postTitle`, `removeItem`, `handleClick`).
|
|
165
|
-
- WebComponent base: `render`, `update`, `requestUpdate`, `updated` / `firstUpdated`, `willUpdate` / `shouldUpdate`, `connectedCallback`, `renderError` / `renderFallback`, `addController` / `removeController`, `updateComplete
|
|
274
|
+
- WebComponent base: `render`, `update`, `requestUpdate`, `updated` / `firstUpdated`, `willUpdate` / `shouldUpdate`, `connectedCallback`, `renderError` / `renderFallback`, `addController` / `removeController`, `updateComplete` (#1021: there is no WebJs slot API to override; slots are native). Only override one deliberately, with its exact signature; never repurpose the name for app logic.
|
|
166
275
|
|
|
167
276
|
Framework-private fields are underscore-prefixed (`_renderRoot`, `_connected`, `_changedProperties`, `_updatePromise`, `_isUpdating`); never declare a prop or field that matches one. Safe, non-inherited names: `label`, `open`, `count`, `value`, `name`, `items`, `todos`, `active`, `variant`, `size`, `checked`, `selected`, `heading`, `message`, `status`. When in doubt, grep the base surface in `node_modules/@webjsdev/core/src/component.js`.
|
|
@@ -5,13 +5,13 @@
|
|
|
5
5
|
- The `modules/<feature>/` architecture (thin `app/` adapters, `actions/` mutations, `queries/` reads, one function per file)
|
|
6
6
|
- `'use server'` RPC actions, the serializer-safe wire, and how a client import becomes a typed stub
|
|
7
7
|
- Input validation at the boundary via `export const validate`
|
|
8
|
-
- HTTP-verb config exports (`method`, `cache`, `tags`, `invalidates`, `middleware`)
|
|
8
|
+
- HTTP-verb config exports (`method`, `cache`, `tags`, `invalidates`, `middleware`), the middleware `ctx` shape, and `actionSignal()` cancellation
|
|
9
9
|
- The `ActionResult<T>` envelope and its robust failure detection
|
|
10
10
|
- The `route()` REST adapter that exposes an action over HTTP
|
|
11
11
|
- Drizzle rc.3 reads (`db.query.*`) and mutations (`.returning()`)
|
|
12
12
|
- Keeping server-only types off the client (`import type` vs a value import)
|
|
13
13
|
|
|
14
|
-
Read this when a task touches a server mutation, a data read, input validation, a REST endpoint, or the shape a component consumes. Sibling refs: `routing-and-pages.md` (
|
|
14
|
+
Read this when a task touches a server mutation, a data read, input validation, a REST endpoint, or the shape a component consumes. Sibling refs: `routing-and-pages.md` (where a bound form lives on a page, `route.ts` handlers), `auth-and-sessions.md` (protecting an action or endpoint), `optimistic-ui.md` (consuming `ActionResult` on the client), `typescript.md` (erasable syntax, full-stack types).
|
|
15
15
|
|
|
16
16
|
## The Architecture (read this first)
|
|
17
17
|
|
|
@@ -96,28 +96,61 @@ const [updated] = await db.update(posts).set({ title }).where(eq(posts.id, id)).
|
|
|
96
96
|
await db.delete(posts).where(eq(posts.id, id));
|
|
97
97
|
```
|
|
98
98
|
|
|
99
|
-
A `.returning()` row is the table's own columns only, never `with` relations. When the caller wants a joined shape, re-read with `db.query.*` or splice the already-known related value in by hand. Full surface at https://
|
|
99
|
+
A `.returning()` row is the table's own columns only, never `with` relations. When the caller wants a joined shape, re-read with `db.query.*` or splice the already-known related value in by hand. Full surface at https://webjs.dev/docs.
|
|
100
100
|
|
|
101
101
|
## Input validation at the boundary
|
|
102
102
|
|
|
103
|
-
Declare `export const validate` beside the action. It runs SERVER-SIDE before the action body on the RPC
|
|
103
|
+
Declare `export const validate` beside the action. It runs SERVER-SIDE before the action body on every BOUNDARY (the RPC endpoint, a `route()` REST endpoint, and a form submission), receiving the action's FIRST argument. On the form boundary that first argument is the `FormData`, and a `{ success: false, fieldErrors }` return becomes a 422 RE-RENDER of the page with the validator's result on `actionData`, rather than a JSON 422. The framework only CALLS the validator (it ships no validation library) and reads its return: `{ success: true, data? }` runs the action (an optional `data` replaces the input), `{ success: false, fieldErrors }` returns a 422 WITHOUT running the body, and a THROW becomes a sanitized error.
|
|
104
104
|
|
|
105
105
|
```ts
|
|
106
106
|
// modules/posts/actions/create-post.server.ts
|
|
107
107
|
'use server';
|
|
108
|
-
export
|
|
108
|
+
export interface CreatePostInput { title: string; body: string }
|
|
109
|
+
|
|
110
|
+
// `unknown` is CORRECT here and nowhere else in this file: the validator IS
|
|
111
|
+
// the narrowing site for an untrusted wire payload. Never `any`, which would
|
|
112
|
+
// un-type the returned `data` and with it the action's own input.
|
|
113
|
+
export const validate = (input: unknown) => {
|
|
114
|
+
const raw = (input ?? {}) as Record<string, unknown>;
|
|
109
115
|
const fieldErrors: Record<string, string> = {};
|
|
110
|
-
const title = String(
|
|
116
|
+
const title = String(raw.title ?? '').trim();
|
|
111
117
|
if (!title) fieldErrors.title = 'Title is required';
|
|
112
|
-
if (String(
|
|
118
|
+
if (String(raw.body ?? '').length < 10) fieldErrors.body = 'Too short';
|
|
113
119
|
if (Object.keys(fieldErrors).length) return { success: false, fieldErrors };
|
|
114
|
-
|
|
120
|
+
// `satisfies` ties the validator's output to the action's input, so the two
|
|
121
|
+
// cannot drift apart.
|
|
122
|
+
return { success: true, data: { title, body: String(raw.body) } satisfies CreatePostInput };
|
|
115
123
|
};
|
|
116
|
-
export async function createPost(input:
|
|
124
|
+
export async function createPost(input: CreatePostInput) { /* runs only when valid */ }
|
|
117
125
|
```
|
|
118
126
|
|
|
119
127
|
A client call resolves with the failure envelope (it does NOT throw), so the component reads `result.fieldErrors`. A zod adapter wraps `safeParse` so its result becomes the envelope; the framework stays zod-free.
|
|
120
128
|
|
|
129
|
+
## Binding an action to a form
|
|
130
|
+
|
|
131
|
+
A `<form action=${importedAction}>` is the one way a form submits to a server action, and it is the whole wiring:
|
|
132
|
+
|
|
133
|
+
```ts
|
|
134
|
+
import { createPost } from '#modules/posts/actions/create-post.server.ts';
|
|
135
|
+
html`<form action=${createPost}><input name="title"></form>`;
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
The renderer omits the `action` attribute so the form posts to the page's own url, supplies `method="post"` and an enctype, and emits a hidden `__webjs_action` field carrying the action's `<hash>/<fn>` identity, the same identity the RPC endpoint resolves. Nothing about the action's source reaches the browser. With JS off this is an ordinary HTML submission; with JS the client router posts the same body to the same url, so the two paths are identical by construction.
|
|
139
|
+
|
|
140
|
+
**A form-bound action always receives the `FormData`**, which is where it differs from the same function called over RPC (rich arguments) or server-to-server. `validate` is the typing seam: it takes the `FormData` and its transform-return becomes the action's typed input.
|
|
141
|
+
|
|
142
|
+
Everything the action declares applies here too, or an action would be protected over RPC and open over a form:
|
|
143
|
+
|
|
144
|
+
- `validate` runs on the submitted `FormData`.
|
|
145
|
+
- the `middleware` chain runs, with the page's route context (`params`, `searchParams`, `url`) added to `ctx`.
|
|
146
|
+
- `invalidates` is evicted when the action actually RAN (a middleware short-circuit does not evict), and the evicted tags are reported on the response so the browser's tag coordinator bypasses a stale cached GET. One reach limit: `fetch` follows the success `303` transparently, so JS cannot read a redirect's headers; the tags are on the wire and the `422` re-render carries them, and the redirect's own render is server-side and seeds fresh data.
|
|
147
|
+
- `invalidates` and `tags` receive the SAME first argument the action does, so on a form boundary they receive the `FormData`. `invalidates: (input) => ['post:' + input.id]` returns `post:undefined` for a submission and evicts nothing. Either read the field (`(fd) => ['post:' + fd.get('id')]`), declare a `validate` that transforms the `FormData` into the typed input first (the transform result is what the config functions then see), or use an argument-independent tag.
|
|
148
|
+
- `method = 'GET'` cannot be bound to a form: a GET action rides its args in the url and is CSRF-exempt, so it cannot answer a form POST. That is a `405` at runtime and the `form-action-not-a-get-action` error in `webjs check`.
|
|
149
|
+
|
|
150
|
+
The response drives the page: a success is a `303` PRG (to `result.redirect` when it is a same-site local path, else the page's own url), a failure re-renders the SAME page with `status` (default `422`) and the result on `actionData`, a submission carrying no identity is a `405`, and one whose hash no longer resolves is a `422` with a resubmit message (a form held open across a deploy). The submission is Origin-verified like an RPC call, so no token field is needed.
|
|
151
|
+
|
|
152
|
+
A streamed return (#489) is refused from a form-bound action: the RPC stub decodes frames, but a submission is answered with a redirect or a page, and with JS off there is no consumer at all. Stream from a programmatic call instead.
|
|
153
|
+
|
|
121
154
|
## HTTP-verb config exports
|
|
122
155
|
|
|
123
156
|
A `'use server'` action is a POST by default. Reserved sibling exports, read statically (the same way a page reads `export const revalidate`), change its HTTP semantics WITHOUT changing the call site (you still write `await getUser(7)`).
|
|
@@ -139,9 +172,27 @@ export const middleware = [requireAuth]; // async (ctx, next) => resul
|
|
|
139
172
|
export async function updateUser(id: number, patch: Partial<User>) { /* ... */ }
|
|
140
173
|
```
|
|
141
174
|
|
|
142
|
-
- A **GET** rides args in the URL (POST fallback over a 4KB cap), is CSRF-exempt, and carries
|
|
175
|
+
- A **GET** rides args in the URL (POST fallback over a 4KB cap), is CSRF-exempt, and carries a weak `ETag` (304 on `If-None-Match`); with a `cache` export it also carries `Cache-Control` + `X-Webjs-Tags` (without one it is `no-store`, so caching is opted into by `cache`, not by the verb). A **mutation** (POST/PUT/PATCH/DELETE) sends the rich body (DELETE rides the URL), is CSRF-protected, and once it completes without throwing evicts its `invalidates` tags and reports them via `X-Webjs-Invalidate` (a returned `{ success: false }` envelope still evicts, since the action ran). A method mismatch is a `405` + `Allow`.
|
|
176
|
+
- The `cache` object maps onto the `Cache-Control` header. The number shorthand `cache = 60` means `{ maxAge: 60 }`. `maxAge` is the freshness window in seconds (`max-age=<n>`), `swr` is a stale-while-revalidate grace window in seconds (`stale-while-revalidate=<n>`, an expired response is still served instantly while the browser revalidates in the background, usually a bodyless 304 thanks to the ETag), and `public` flips the scope from the default `private`. So `{ maxAge: 60, swr: 300 }` emits `private, max-age=60, stale-while-revalidate=300`.
|
|
143
177
|
- **SAFETY.** `cache` with `public: true` SHARES one response across ALL users, keyed only by URL + args. Use it ONLY for data identical for every visitor (the same rule as a page's `export const revalidate`), never for a session or per-user read.
|
|
144
|
-
- Per-action `middleware` short-circuits by returning an `ActionResult` instead of calling `next()`, and accumulates context the action reads via `actionContext()` from `@webjsdev/server`.
|
|
178
|
+
- Per-action `middleware` short-circuits by returning an `ActionResult` instead of calling `next()`, and accumulates context the action reads via `actionContext()` from `@webjsdev/server`. Each middleware is `async (ctx, next) => result` where `ctx` is `{ request, args, signal, context }`. It writes to the shared bag `ctx.context.<key>` (for example `ctx.context.user = user`), which is exactly what `actionContext().user` reads back in the action. A direct server-to-server call skips the RPC boundary (so its middleware does NOT run), so the action must guard rather than assume a middleware-set value is present.
|
|
179
|
+
|
|
180
|
+
### Cancellation with `actionSignal()`
|
|
181
|
+
|
|
182
|
+
Inside an action, `actionSignal()` from `@webjsdev/server` returns the request's `AbortSignal`. It fires when the client disconnects OR when a newer client render supersedes this one (the RPC stub aborts the previous in-flight fetch). Thread it into the work you start, and re-check it after an await to map an abort to a cancelled envelope:
|
|
183
|
+
|
|
184
|
+
```ts
|
|
185
|
+
'use server';
|
|
186
|
+
import { actionSignal } from '@webjsdev/server';
|
|
187
|
+
export async function search(q: string) {
|
|
188
|
+
const signal = actionSignal();
|
|
189
|
+
const res = await fetch(`https://api/x?q=${q}`, { signal }); // aborts the fetch on disconnect
|
|
190
|
+
if (signal.aborted) return { success: false, error: 'Request cancelled.', status: 499 };
|
|
191
|
+
return { success: true, data: await res.json() };
|
|
192
|
+
}
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
A guard placed BEFORE any await can never fire (nothing has yielded yet). Outside an action the signal never aborts, so a server-to-server call stays safe.
|
|
145
196
|
|
|
146
197
|
## The `ActionResult<T>` envelope
|
|
147
198
|
|
|
@@ -184,4 +235,4 @@ import type { Post } from '#db/schema.server.ts';
|
|
|
184
235
|
import { posts } from '#db/schema.server.ts';
|
|
185
236
|
```
|
|
186
237
|
|
|
187
|
-
Keep the wire shape in a browser-safe `modules/<feature>/types.ts` with NO runtime import from a `.server.ts` file or from `db/`. Define a hand-written DTO, or a type-only derivation (`import type { Post } ...; export type PostFormatted = Omit<Post, 'createdAt'> & { createdAt: string }`). Never `export *` or a value re-export from a `.server.ts` in `types.ts`; that carries the runtime table bindings and breaks any component importing the types. Full reference at https://
|
|
238
|
+
Keep the wire shape in a browser-safe `modules/<feature>/types.ts` with NO runtime import from a `.server.ts` file or from `db/`. Define a hand-written DTO, or a type-only derivation (`import type { Post } ...; export type PostFormatted = Omit<Post, 'createdAt'> & { createdAt: string }`). Never `export *` or a value re-export from a `.server.ts` in `types.ts`; that carries the runtime table bindings and breaks any component importing the types. Full reference at https://webjs.dev/docs.
|