@webjsdev/mcp 0.1.17 → 0.1.18

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.
@@ -15,7 +15,7 @@ Read this when wiring caching or rate limiting, storing uploads, hardening heade
15
15
 
16
16
  ## Environment variables
17
17
 
18
- `process.env.X` reads are **server-only**. `NODE_ENV` is defined both sides. A variable named with the `WEBJS_PUBLIC_` prefix is exposed to the browser via an inline `<script>` (no build step), so read it client-side as `process.env.WEBJS_PUBLIC_ANALYTICS_ID`.
18
+ `process.env.X` reads are **server-only**. `NODE_ENV` is defined both sides. A variable named with the `WEBJS_PUBLIC_` prefix is exposed to the browser via an inline `<script>` (no build step), so read it client-side as `process.env.WEBJS_PUBLIC_ANALYTICS_ID`. The public set is read **once, at the first render of a build**: set it before boot (`.env`, the deploy environment, an `env.js` default), because a `process.env` mutation made at runtime is not reflected in the browser until the next restart or dev rebuild.
19
19
 
20
20
  | Variable | Effect |
21
21
  |---|---|
@@ -230,13 +230,23 @@ An over-limit body responds `413` without buffering the whole payload.
230
230
 
231
231
  ```jsonc
232
232
  { "webjs": {
233
- "dev": { "before": ["webjs db migrate"], "parallel": ["tailwindcss ... --watch"], "watch": ["../blog"] },
234
- "start": { "before": ["webjs db migrate"] }
233
+ "dev": {
234
+ "before": ["webjs db migrate", "webjs regenerate"],
235
+ "regenerate": [{ "output": "public/tailwind.css",
236
+ "command": "tailwindcss -i ./public/input.css -o ./public/tailwind.css --minify",
237
+ "inputs": ["app", "components", "modules", "lib", "public/input.css"] }],
238
+ "watch": ["../blog"]
239
+ },
240
+ "start": { "before": ["webjs db migrate", "webjs regenerate"] }
235
241
  } }
236
242
  ```
237
243
 
238
244
  `before` runs to completion first (a non-zero exit aborts the boot). `parallel` (dev only) runs long-lived watchers alongside the server and tears them down on exit. `watch` (dev only) adds extra live-reload directories outside the app tree.
239
245
 
246
+ `regenerate` (#967) keeps a served build output fresh without a watcher: when its `output` is requested in dev and is not fresh, the server runs `command` to completion before serving it. Prefer it over a `tailwindcss --watch` in `parallel`, which can die mid-session and serve stale CSS with no error. Fresh is decided by a RECORD (#1672): every build writes, in `.webjs/regenerate.json`, the command, a fingerprint of the files under `inputs` taken before it ran, and the output it produced, and an output is fresh while all three still match. So a rebuild whose bytes did not change (the Tailwind CLI skips the write) is fresh, a file saved during the compile is not, and an input dated in the future hides nothing. The command must WRITE `output`: the output is moved aside first, so a rule whose `output` is not the path its command writes is reported on every request instead of silently serving stale CSS. The output never counts as its own input, but a prepare step inside the command that writes into an input directory (a copy of shared sources, say) must write a file only when its content changed: a file rewritten with the same bytes changes the input fingerprint on every build, so the output would never read as fresh.
247
+
248
+ Build the outputs with `webjs regenerate` in `dev.before` and `start.before` (and in a CI step before `webjs doctor`), never with the raw compile command: it runs every rule that is not fresh and records each build, so a boot compiles only what changed and the first request after it compiles nothing. `webjs regenerate --force` rebuilds every rule. A compile run by hand (`npm run css:build`) writes no record, so that output is rebuilt once on its next dev request and `webjs doctor` reports it unrecorded until then.
249
+
240
250
  ### Local CI (`webjs.ci`)
241
251
 
242
252
  `webjs ci` runs the step list the block declares, the Rails 8.1 `bin/ci` posture: your machine is the first CI runner, and a cloud pipeline runs the SAME list by calling `npm run ci`, so the two cannot drift. Each step prints a heading, then `✅ <title> passed in 2.11s` or `❌ <title> failed in 0.01s`; the run ends with every failure listed and one total line, and exits 1 on any failure.
@@ -17,6 +17,10 @@ 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
+ **A page's stylesheets load before it is swapped in (#1666).** A `<link rel="stylesheet">` the incoming page carries, in its head or anywhere in its body, and that the live document does not already link (compared by resolved URL), is loaded through a temporary copy in `<head>` (marked `data-webjs-preload-style`, carrying the live page's CSP nonce) and awaited BEFORE the swap, so the new content's first paint is styled. The copy loads held under `media="not all"` and is released inside the swap commit (#1669), so the outgoing page, and a view transition's capture of it, never paint with the incoming page's CSS. This is what lets a nested layout or a page bring its own stylesheet on a soft navigation: emit the `<link>` as the first thing in that layout's or page's markup. Once the swap commits, each copy is removed as soon as the page's own link to the same file has loaded. A copy with no twin in the swapped-in page (a superseded or discarded navigation) is removed at once.
21
+
22
+ **A layout's stylesheet leaves with the layout (#1669).** The server moves a `<link rel="stylesheet">` at the start of a nested layout's or page's output into `<head>`, tagged `data-wj-style-owner` with the boundary it was rendered in, so it blocks first paint on a full load (a body link does not, #406). When a soft navigation swaps that boundary's range (replaces it, or morphs it in place on a searchParams change) and the incoming page does not link the same file, the router switches the sheet off (`sheet.disabled`, marked `data-wj-style-off`) inside the swap commit, and switches it back on, synchronously, when a later page or a back / forward restore links it again. So a section's sheet costs nothing outside the section, and Back never paints unstyled waiting for it. Nothing is switched off on a frame swap or the full-body fallback, and a sheet with no owner (the root layout's, or one in a head an app writes itself) is never touched, since a reduced `X-Webjs-Have` head omits what the client already has and that absence must never read as "no longer needed" (#936). No head merge appends a second link to a file the head already links. A sheet a layout and a page under it both link stays the layout's while that layout is on screen, so leaving the page does not switch it off. So a link into a section with its own stylesheet needs no `data-no-router`: the crossing, and Back across it, apply exactly the sheets a full load of each page would. On a frame navigation only the frame's subtree is read. A sheet that errors or takes longer than 2 seconds never holds the navigation, and a page that brings no new stylesheet swaps in the same task as before. An alternate stylesheet, a `disabled` one, and one whose `media` does not match are not waited for. A stylesheet a FULL page load put in `<head>` still stays across soft navigations (#936), as in Next.js and TanStack. Turbo Drive, Remix and the Next.js app router all load new CSS before they commit a navigation.
23
+
20
24
  **A mutating form submission re-renders the layouts (#1557).** A link click keeps the layout chrome by construction, but a form action can change what a layout renders: a sign-in sets the cookie the header reads, a sign-out clears it. So a POST / PUT / PATCH / DELETE form the router submits sends NO `X-Webjs-Have` (the redirect `fetch` follows keeps request headers, so a have-header would make the server short-circuit the very layouts that need to re-run), and the swap applies the boundary plan as usual and then MORPHS the chrome outside the plan's range, level by level from `<body>` down. The morph is keyed + positional and state-preserving, so a hydrated layout component survives unless its own markup changed, and form controls keep what the reader typed (a 422 re-render included). When the chain from `<body>` to the range cannot be paired safely (a different depth or tag, or a hydrated component wrapping `${children}`), it takes the full-body tier instead, which is correct and only loses component state. Nodes appended to `<body>` at runtime (a dev overlay, a dialog portal) trail the server-rendered content and are left alone. A frame-targeted submission keeps its frame-only swap, and an RPC mutation followed by `navigate()` is a GET navigation, so call `refreshPage('shell')` after it when the layout depends on what changed.
21
25
 
22
26
  **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.
@@ -4,7 +4,7 @@
4
4
 
5
5
  - What a component owns (markup, state, listeners, styling), and the rules that follow from it: refs over selectors, no state on `<body>`, ARIA derived in `render()`
6
6
  - Declaring reactive properties through the `WebComponent({ ... })` factory and `prop()`, with options (`reflect`, `state`, `attribute`, `default`, `converter`, `hasChanged`)
7
- - Signals as the default state primitive for component-local and shared state, plus `effect` / `batch`
7
+ - Signals for component-local and shared state, plus `effect` / `batch`, alongside lit's `state: true` / `attribute: false` properties
8
8
  - The Lit-aligned lifecycle and exactly which hooks SSR runs versus skips
9
9
  - Light DOM (default) versus shadow DOM, and the light-host `display: block` rule
10
10
  - Slots with full shadow-DOM parity in both DOM modes
@@ -30,7 +30,6 @@ Most of what follows restates widely held component-model advice, ported. Lit's
30
30
 
31
31
  **4. ARIA state is a hole in `render()`, derived from the same state that drives behaviour.** `aria-expanded=${this.open ? 'true' : 'false'}` cannot disagree with `this.open`. A second function that re-finds the button and calls `setAttribute` can, and does, the first time someone adds a close path that forgets to call it. The same holds for `class`, `?disabled`, and any `.prop`. Two caveats ride this rule:
32
32
 
33
- - A plain-attribute hole follows one rule on the server and the client (#1573): `null` / `undefined` omit the attribute, `false` omits it except on an `aria-*` name, where it is written as `"false"`, and `true` on an HTML boolean attribute (`checked`, `selected`, `disabled`, ...) writes the empty value like `?attr` (#1579). So `checked=${isDefault}` and `selected=${i === 0}` behave exactly like `?checked` / `?selected`, though `?attr` stays the clearer spelling for a boolean attribute. So `aria-expanded=${this.open}` serves `"true"` / `"false"` and hydrates unchanged, and `aria-current=${active ? 'page' : null}` omits the attribute on an inactive link. `?attr=${bool}` is not a substitute for a tri-state ARIA attribute, since a boolean binding omits the attribute when false.
34
33
  - A hole commits on the next render, one microtask later. The one place a direct write is still correct is a synchronous snapshot read such as `webjs:before-cache`, where the router reads `outerHTML` in the same task. That is a documented exception, not the normal path.
35
34
 
36
35
  **5. Behaviour needs an importable surface, or its test is a copy of it.** An inline `<script>` in a layout has no module identity, so a browser test cannot import it. It can only transcribe the listener into the test file and assert against the transcription, which then needs a SECOND test to grep the original for drift. Two tests, neither running shipping code. A component is importable, so its browser test mounts the real element and drives real events. A page or layout may still carry an inline `<script>`, but only for pre-paint boot work no module can do: reading a stored theme before first paint so the wrong palette never flashes, or measuring the header height into a CSS custom property. It must not be interactivity, and WHERE it sits decides how often it runs. The ROOT layout's markup sits OUTSIDE every swap range, so a soft navigation does not re-run its script, which is what makes it the right home for boot work and the wrong home for anything that has to respond to a later navigation. A page or a NESTED layout sits inside the swap range instead, so its script re-executes on every navigation that swaps that range (#1102), which means it has to be idempotent or guard on a flag it sets the first time. Neither shape gives you a listener that simply works, which is what a custom element is for. Under an opt-in CSP the script also needs the nonce from `cspNonce()`. `client-router-and-streaming.md` carries the full re-execution rule.
@@ -152,7 +151,7 @@ class Dialog extends WebComponent({
152
151
  variant: prop<'info' | 'danger'>(String, { reflect: true }), // narrowed union type
153
152
  student: prop<Student>(Object), // narrowed object type
154
153
  items: prop<Tag[]>(Array), // array-typed prop uses Array, not Object
155
- internal: prop({ state: true }), // internal state, no attribute, no type
154
+ internal: prop<boolean>({ state: true }), // internal state, no attribute (untyped is unknown)
156
155
  }) {
157
156
  constructor() {
158
157
  super();
@@ -173,27 +172,33 @@ The bare form is shorthand: `count: Number` means `prop(Number)`. Use `prop()` t
173
172
  |---|---|---|
174
173
  | `type` | `String` | Constructor feeding the default attribute converter |
175
174
  | `reflect` | `false` | Property changes write back to the HTML attribute (a value with no attribute representation removes it instead, see below) |
176
- | `state` | `false` | Internal-only. No attribute, not observed, and never read from one at SSR either |
177
- | `attribute` | derived from name | The HTML attribute name the property rides |
175
+ | `state` | `false` | Internal-only. No attribute, not observed, and never read from one at SSR either (lit's `@state()`) |
176
+ | `attribute` | derived from name | The HTML attribute name the property rides, or `false` for none: a public property-only input a parent sets with `.prop=${…}` (lit's `attribute: false`) |
178
177
  | `default` | none | Declarative initial value (a function runs per instance for a fresh object / array) |
179
178
  | `hasChanged` | strict `!==` | Custom change detection |
180
179
  | `converter` | type-based | Custom attribute-to-property serialization. `fromAttribute` runs on BOTH readers (the client upgrade and SSR), ahead of type coercion |
181
180
 
181
+ **State options.** Everything lit offers works, and each is a normal choice:
182
+
183
+ - `state: true`: internal state with no attribute (`open: prop(Boolean, { state: true })`, then `this.open = true`), reported in `changedProperties`.
184
+ - `attribute: false`: a public property with no attribute, set with `.prop=${…}` (`items: prop<Item[]>(Array, { attribute: false })`).
185
+ - Signals: `signal()` / `computed()`, read with `.get()` and written with `.set()`. Assigning to a signal field (`this.count = 5`) replaces the signal, so nothing re-renders and the next `.get()` throws; `no-signal-field-assignment` in `webjs check` flags it.
186
+
182
187
  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`.
183
188
 
184
189
  **A `converter.fromAttribute` runs SERVER-SIDE too.** Both attribute readers go through one shared implementation, so the converter fires during SSR as well as on the client upgrade, and it wins over the declared `type` on both. So keep it free of browser globals (`document`, `window`, `navigator`), since SSR has no DOM and touching one throws where the same code worked in the browser. A converter that THROWS is not caught by either reader, because an author who writes one owns the conversion: at SSR the throw lands in per-component error isolation (an error box in dev, an empty element at a 200 in production, with the cause in the server log) while sibling components still render, and on the client it escapes `attributeChangedCallback` during upgrade. Both readers hand the converter DECODED attribute text, so a converter that parses its input (`JSON.parse` for a Map or a Set, `new Date(...)`) sees the same string on both sides even when the attribute carries `&quot;` or `&amp;`.
185
190
 
186
191
  **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`.
187
192
 
188
- **An `Object` or `Array` typed reflected property whose value `JSON.stringify` cannot serialize AT ALL drops its attribute the same way, and warns.** Three shapes do this: a cycle (an object or array that reaches itself, which arrives from a parent/child graph, a linked node, a memo table, or anything a library hands back with a back-reference), a `BigInt` anywhere inside the value, and an author `toJSON()` that throws. The line to keep straight is that a value which serializes WITH A GAP in it keeps its data (the carried-function case above), while one that does not serialize at all has no string to put in the attribute and so has no attribute representation, exactly like a function. The property itself is untouched and still holds the value; only the attribute goes. Before this guard the throw escaped reflection entirely, which meant a client upgrade threw before the component's first render, and an SSR render was swallowed by per-component error isolation, which shows an error box in dev and renders the component EMPTY on a page that still returned 200 in production. To reflect something about a graph-shaped value, reflect a derived scalar (an id, a count) and keep the graph on a non-reflected property. On the read side an attribute that is PRESENT but not parseable JSON reads back as `null` rather than as the raw string, on both the SSR and the client reader. An ABSENT attribute is a different case: neither reader sees it, so the property keeps its constructor value. The two readers also see the same attribute SET, not merely the same fallback: a `state: true` prop and an attribute matching no declared property are ignored by both, and a camelCase attribute name reaches its prop on both through the lowercased alias, and both are handed a value whose HTML character references are already decoded.
193
+ **An `Object` or `Array` typed reflected property whose value `JSON.stringify` cannot serialize AT ALL drops its attribute the same way, and warns.** Three shapes do this: a cycle (an object or array that reaches itself, which arrives from a parent/child graph, a linked node, a memo table, or anything a library hands back with a back-reference), a `BigInt` anywhere inside the value, and an author `toJSON()` that throws. The line to keep straight is that a value which serializes WITH A GAP in it keeps its data (the carried-function case above), while one that does not serialize at all has no string to put in the attribute and so has no attribute representation, exactly like a function. The property itself is untouched and still holds the value; only the attribute goes. Before this guard the throw escaped reflection entirely, which meant a client upgrade threw before the component's first render, and an SSR render was swallowed by per-component error isolation, which shows an error box in dev and renders the component EMPTY on a page that still returned 200 in production. To reflect something about a graph-shaped value, reflect a derived scalar (an id, a count) and keep the graph on a non-reflected property. On the read side an attribute that is PRESENT but not parseable JSON reads back as `null` rather than as the raw string, on both the SSR and the client reader. An ABSENT attribute is a different case: neither reader sees it, so the property keeps its constructor value. The two readers also see the same attribute SET, not merely the same fallback: a `state: true` or `attribute: false` prop and an attribute matching no declared property are ignored by both, and a camelCase attribute name reaches its prop on both through the lowercased alias, and both are handed a value whose HTML character references are already decoded.
189
194
 
190
- **Writing attributes in markup.** Names are case-insensitive and the browser lowercases them while parsing, so a camelCase prop answers to BOTH its kebab-case name (`user-name`, the one reflection writes) and its lowercased name (`username`, lit's default), which is what a camelCase attribute in markup (`userName="…"`) arrives as; all three spellings reach `userName` on both sides. Prefer kebab-case. A prop that renames its attribute answers to the new name ONLY, so `open: prop(Boolean, { attribute: 'is-open' })` is written `<my-el is-open>` and `<my-el open>` reaches nothing. Character references are decoded before the value is coerced, so `cfg="&#123;&quot;a&quot;:1&#125;"` parses as the object it spells and `label="Tom &amp; Jerry"` is `Tom & Jerry`; the legacy semicolon-less forms decode exactly where a browser decodes them (`&nbsp` at the end of a value is a non-breaking space, `&nbspx` and `&nbsp=x` stay literal), and writing the semicolon avoids the question. A `state: true` prop takes an SSR value only through a `.prop=${value}` binding from the parent template, never from an attribute.
195
+ **Writing attributes in markup.** Names are case-insensitive and the browser lowercases them while parsing, so a camelCase prop answers to BOTH its kebab-case name (`user-name`, the one reflection writes) and its lowercased name (`username`, lit's default), which is what a camelCase attribute in markup (`userName="…"`) arrives as; all three spellings reach `userName` on both sides. Prefer kebab-case. A prop that renames its attribute answers to the new name ONLY, so `open: prop(Boolean, { attribute: 'is-open' })` is written `<my-el is-open>` and `<my-el open>` reaches nothing. A `state: true` or `attribute: false` prop takes an SSR value only through a `.prop=${value}` binding from the parent template, never from an attribute. Character references are decoded before the value is coerced, so `cfg="&#123;&quot;a&quot;:1&#125;"` parses as the object it spells and `label="Tom &amp; Jerry"` is `Tom & Jerry`; the legacy semicolon-less forms decode exactly where a browser decodes them (`&nbsp` at the end of a value is a non-breaking space, `&nbspx` and `&nbsp=x` stay literal), and writing the semicolon avoids the question.
191
196
 
192
197
  **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.
193
198
 
194
- ## Signals are the default state primitive
199
+ ## Signals
195
200
 
196
- Reserve the factory for values that ride an HTML attribute, reflect to one, or arrive via `.prop=${value}` SSR hydration. For everything else use signals.
201
+ A signal is one of the state options listed above, alongside `state: true` and `attribute: false` properties.
197
202
 
198
203
  ```ts
199
204
  import { signal, computed } from '@webjsdev/core';
@@ -202,7 +207,7 @@ const cart = signal<Item[]>([]); // module-scope: shared across
202
207
  const count = computed(() => cart.get().length); // derived
203
208
  ```
204
209
 
205
- 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`.
210
+ Read with `signal.get()` inside `render()`; the built-in `SignalWatcher` tracks the read and re-renders on change. It tracks every read the synchronous update cycle makes, not only `render()`'s (the span lit-labs/signals tracks), so a signal read only in `willUpdate()` or `updated()` (a host attribute derived from internal state) re-renders too. The DOM commits are untracked, which is why a `watch()` part swaps without re-rendering its host. A write a phase makes to a signal that same phase read never re-triggers it (so a listener doing a read-modify-write on a store from an event `updated()` dispatches cannot loop), while a write in `updated()` to a signal `render()` read does schedule the next update. A cycle `shouldUpdate()` skips keeps its subscriptions. An instance signal field (`readonly #items = signal([])`) is component-local. For a fine-grained DOM swap use `${watch(signal)}` from `@webjsdev/core/directives`.
206
211
 
207
212
  Two more signal primitives from `@webjsdev/core` cover client-side reactions and batched writes:
208
213
 
@@ -230,7 +235,7 @@ Each update cycle runs these in order; each receives a `changedProperties` Map.
230
235
  | 5 | `updated(changed)` | Every commit. Ad-hoc post-render DOM work. |
231
236
  | 6 | `updateComplete` resolves | `await el.updateComplete` to read post-render DOM in tests. |
232
237
 
233
- **SSR runs only the value-deriving path**: the constructor, attribute application, `willUpdate` (and controllers' `hostUpdate`), `reflect: true` reflection, then `render()`. It does NOT invoke `connectedCallback`, `disconnectedCallback`, `firstUpdated`, `updated`, `update`'s DOM commit, `hostUpdated`, or `shouldUpdate`. So set first-paint defaults in the constructor, derive SSR-visible state in `willUpdate`, and keep browser-only work (DOM queries, layout, `localStorage`, viewport) in `connectedCallback` / `firstUpdated`. A browser global in the constructor or `render()` throws at SSR (flagged by `no-browser-globals-in-render`; attribute methods and `closest()` are shimmed).
238
+ **SSR runs only the value-deriving path**: the constructor, attribute application, `willUpdate` (and controllers' `hostUpdate`), `reflect: true` reflection, then `render()`. The host is then served with its attributes as the element left them, the way lit-ssr emits them: an attribute the element set during that pass is added, one it changed carries the new value, one it removed is dropped, and one it left alone keeps its source text byte for byte (the `data-webjs-prop-*` carrier of a `.prop` binding is always kept). A component that hands its host's class to the element it renders, the shadcn `className` shape, therefore paints one box on the first paint, not two. Read the author's value BEFORE you change it on the host, and record it somewhere the hydrating instance can read, because the browser upgrades the served markup with a fresh instance that sees the attribute as served. It does NOT invoke `connectedCallback`, `disconnectedCallback`, `firstUpdated`, `updated`, `update`'s DOM commit, `hostUpdated`, or `shouldUpdate`. So set first-paint defaults in the constructor, derive SSR-visible state in `willUpdate`, and keep browser-only work (DOM queries, layout, `localStorage`, viewport) in `connectedCallback` / `firstUpdated`. A browser global in the constructor or `render()` throws at SSR (flagged by `no-browser-globals-in-render`; attribute methods and `closest()` are shimmed).
234
239
 
235
240
  ## Light DOM (default) vs shadow DOM
236
241
 
@@ -283,7 +288,7 @@ node.assignedSlot;
283
288
  card.querySelector('slot').addEventListener('slotchange', ...); // async + coalesced
284
289
  ```
285
290
 
286
- 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).
291
+ 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; this is a client-only first mount, since a host hydrated from SSR keeps its projected children in the document, so they are assigned at `firstUpdated` and a projected iframe never navigates twice, #1666). (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).
287
292
 
288
293
  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`.
289
294
 
@@ -364,9 +369,28 @@ class ThemedCard extends WebComponent({}) {
364
369
 
365
370
  `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.
366
371
 
372
+ ## SVG fragments: the `svg` tag
373
+
374
+ A template is parsed as HTML on the client, so markup that belongs inside an
375
+ `<svg>` but is built in its own template comes out as HTML elements and never
376
+ paints. That is any fragment whose root is an SVG child: a `<rect>` per data
377
+ point, a helper that returns a `<path>`, a `<g>` of labels. Write those with
378
+ `svg` (lit's tag, from `@webjsdev/core`) and keep the outer `<svg>` in `html`:
379
+
380
+ ```ts
381
+ import { html, svg } from '@webjsdev/core';
382
+
383
+ const bar = (v: number, i: number) => svg`<rect x=${i * 12} width="10" height=${v}></rect>`;
384
+ html`<svg viewBox="0 0 120 100">${values.map(bar)}</svg>`;
385
+ ```
386
+
387
+ Every hole kind works in an `svg` fragment (`@event`, `.prop`, `?bool`,
388
+ attributes, children). The server renders `svg` and `html` identically, so a
389
+ first paint looks right either way; the `html` mistake shows only after the
390
+ component re-renders in the browser.
391
+
367
392
  ## Directives (lit-html parity)
368
393
 
369
- 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.
370
394
 
371
395
  | Directive | Use it for |
372
396
  |---|---|
@@ -399,7 +423,7 @@ hydration close the element a moment later (#1443).
399
423
  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:
400
424
 
401
425
  - an `@event` binding or native handler property (`.onclick`)
402
- - a factory-declared reactive property that is not `{ state: true }`
426
+ - a factory-declared reactive property, including `state: true` and `attribute: false`
403
427
  - an overridden lifecycle hook (including `renderFallback` / `renderError`)
404
428
  - an imported `signal` / `computed` / `watch` / `Task` / `ref` / streaming directive, or `addController` / `requestUpdate`
405
429
  - 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. A call inside a declaration does not run at load, so it is not a blocker: an arrow's expression body (`export const usd = (m) => Math.round(m) / MICROS`) and a function parameter default (`function relativeTime(when, now = Date.now())`) are both fine. TypeScript types are erased before the analyser reads a module, so an annotation can never be a blocker however call-shaped it looks (`readonly (readonly [number, number, number])[]` is fine)
@@ -467,3 +491,15 @@ A `WebComponent` inherits `HTMLElement` (browser) or an `ElementShim` (SSR) plus
467
491
  - 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.
468
492
 
469
493
  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` and its sibling `component/` directory, where the class body actually lives.
494
+
495
+ ## Lit JavaScript API compatibility
496
+
497
+ WebJs keeps `WebComponent({ ... })` for inferred reactive properties, including `state: true` and `attribute: false`. App TypeScript must erase to whitespace; decorators and compiler transforms are unnecessary.
498
+
499
+ - Function event listeners receive the rendering component as `this`. Arrow and bound functions preserve their JavaScript receiver. Listener objects use `handleEvent()` and support `capture`, `once`, and `passive`.
500
+ - Quoted and unquoted single-expression `@event`, `.property`, and `?boolean` bindings behave identically. Surrounding text or multiple expressions are invalid. Form-action bindings retain their separate contract.
501
+ - Ordinary attribute values stringify booleans; nullish values become empty strings. Use `?disabled=${disabled}` for boolean attributes and `title=${value ?? nothing}` for omission, including inside mixed attributes.
502
+ - Boolean child values render text. Use `${condition ? html`<p>Visible</p>` : nothing}` for conditional content.
503
+ - `nothing` clears children, removes attributes, and assigns `undefined` to properties. `noChange` preserves the previously committed value.
504
+ - `classMap`, `styleMap`, and `ifDefined` are available from `@webjsdev/core/directives`. Map helpers require their matching class/style attribute with one expression; static text around it is supported. They preserve static tokens while removing obsolete dynamic entries. `classMap` separates adjacent static class names even when its map is empty, matching Lit.
505
+ - Normal lifecycle timing follows Lit: skipped updates consume their change map; `hasChanged` gates updates without discarding assignments; post-update writes schedule a new cycle; `updateComplete` reports whether another cycle is pending. WebJs retains its component error boundaries and recovery.
@@ -100,7 +100,7 @@ A `.returning()` row is the table's own columns only, never `with` relations. Wh
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 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.
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. That argument reads the same way on every boundary: on the form boundary it is the submitted fields as a plain object (`raw.title`) that also answers the `FormData` readers (`raw.get('title')`), so one validator serves RPC, REST and a form. On the form boundary 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
@@ -137,16 +137,22 @@ html`<form action=${createPost}><input name="title"></form>`;
137
137
 
138
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, encoded per the declared `enctype` (#1307: multipart stays `FormData`, urlencoded, which is the HTML default, is sent as `URLSearchParams`), so the two paths are identical by construction.
139
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.
140
+ **A form-bound action receives a `FormData` whose fields are also properties** (#1664), so it reads like the object the same function gets over RPC or through `route()`: every field is a property holding its last value (`input.title`), and destructuring, spreading and a schema library's `parse(input)` all work. It is still a real `FormData`, so `input.get('title')`, `instanceof FormData`, the writers (which keep the properties in step) and `fetch(url, { body: input })` all behave as usual. A `FormData` passed as the first argument of an RPC call gets the same treatment. Three rules to know:
141
+
142
+ - A field submitted more than once keeps only its LAST value on the property. Name a checkbox group `tags[]` to receive `input.tags` as an array, or read `input.getAll('tags')`; `get`, `getAll` and `has` accept the bracket-less name, so both spellings agree. A name submitted both plain and with `[]` is one array, and dev warns, since that is an authoring mistake. In dev the server warns once when a plain name arrives more than once. Iteration (`entries`, `keys`, `Object.fromEntries(input)`) reports names as submitted, brackets included, the way a `FormData` does.
143
+ - A file field is a real `File` (`input.avatar.name`, `await input.avatar.text()`).
144
+ - Iteration (`entries`, `keys`, `Object.fromEntries(input)`) reports names as submitted, brackets included.
145
+
146
+ Values are still the strings and files the browser submitted, so `validate` remains the typing seam: its transform-return (`'7'` to `7`, a trimmed title) becomes the action's typed input.
141
147
 
142
148
  A failing form action does not need to echo the submission back. On the 422 re-render `actionData.values` already carries every submitted text field (any `values` the action returns are layered on top), and with JavaScript on the client router restores what was typed into every control the re-render did not explicitly set (#1581). Return `values` only to normalize or blank a field; see `routing-and-pages.md` for the page side.
143
149
 
144
150
  Everything the action declares applies here too, or an action would be protected over RPC and open over a form:
145
151
 
146
- - `validate` runs on the submitted `FormData`.
152
+ - `validate` runs on the submitted fields, in the object shape above.
147
153
  - the `middleware` chain runs, with the page's route context (`params`, `searchParams`, `url`) added to `ctx`.
148
154
  - `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.
149
- - `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.
155
+ - `invalidates` and `tags` receive the SAME first argument the action does, so `invalidates: (input) => ['post:' + input.id]` works on a form submission exactly as it does over RPC. When a `validate` is declared, its transform result is what the config functions see.
150
156
  - `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`.
151
157
  - A form whose buttons run DIFFERENT actions binds each on its submitter, `<button formaction=${publishDraft}>`. **The submitter is self-sufficient** (#1307): the renderer gives it `formmethod="post"` and `formenctype` ON THE BUTTON, alongside the identity riding the button's own `name`/`value` pair, and a submitter's `formmethod` overrides the form's `method` per HTML. So a per-button action works inside a bound form, an unbound form, a `method="get"` form, or a form with no method at all, and the enclosing form does not need binding for the button's sake. Bind the form when the FORM itself should run an action on a plain submit. In dev the client logs one `console.error` at submit time for a submission holding an identity it cannot deliver, and in production both server-visible fingerprints reach `onError` with a code (`WEBJS_FORM_SUBMITTED_AS_GET` for an identity in the query string, `WEBJS_FORM_ACTION_MISSING` for a body carrying no identity). See `muscle-memory-gotchas.md` for the shape.
152
158
 
@@ -131,9 +131,9 @@ The bound, refused, and allowed shapes in full. Every "no" row is a binding that
131
131
  | `.action=` on a custom element | **no** | an author-defined property, not a reflected IDL attribute, so a function is a legitimate value. One declared `reflect: true` reflects on a path outside these commit sites, which used to write `String(value)` and emit the source. It now removes the attribute and warns instead, for a bare function and for an array carrying one, unless the prop supplies its own `converter.toAttribute`, which runs first and stays the author's call |
132
132
  | `?action=` | yes | a function never leaked through a boolean hole, but the binding is meaningless, so it is refused rather than emitting the bare `action=""` that ANY truthy value produces there. Two separate facts worth carrying: `action=""` is a conformance error (the spec wants a valid non-empty URL whenever the attribute is present), and deleting the attribute is still not the WebJs fix, since a page has no `action` export and an unbound `method="post"` form is a 405 (a bare GET form just re-renders). Bind it: `<form action=${fn}>` |
133
133
  | `@action=` unquoted | **no** | an event listener, and a function is exactly what one takes |
134
- | `@action="${fn}"` quoted | yes | quoting makes it an ordinary attribute again, so it leaks |
134
+ | `@action="${fn}"` quoted | no | an event listener, equivalent to the unquoted spelling |
135
135
 
136
- That last row is the one to remember: quoting a binding hole turns it back into a plain attribute, which is why invariant 4 requires `@`, `.` and `?` holes to be unquoted.
136
+ Quoted single-expression event, property, and boolean bindings follow Lit. Ordinary quoted action bindings retain the source-leak protections above.
137
137
 
138
138
  `.action=${fn}` on a native form is refused during SSR too, even though the property is dropped there and nothing could leak, so a page cannot render clean on the server and then throw on hydration.
139
139
 
@@ -180,6 +180,26 @@ A boolean in a plain hole on an HTML boolean attribute renders like `?attr` (cor
180
180
 
181
181
  A string stays a string: `action="/search"` and `action=${'/search'}` are unchanged, which is what a search form (`<form method="get" action="/search">`) and a `route.ts` endpoint both want. Other attributes keep their existing stringify behaviour; only a FUNCTION under `action` / `formaction` is claimed.
182
182
 
183
+ ### Read a form action's fields as properties, not only through `get()`
184
+
185
+ Next hands a form-bound action a raw `FormData`, so the reflex is `formData.get('title')`. In WebJs that still works (the input is a real `FormData`), but each submitted field is also a property (#1664), the same shape the action receives over RPC, so the idiomatic read is `input.title`, and one `validate` serves every boundary:
186
+
187
+ ```ts
188
+ 'use server';
189
+ export const validate = (raw: unknown) => {
190
+ const r = (raw ?? {}) as Record<string, unknown>;
191
+ const title = String(r.title ?? '').trim(); // works for a form post AND an RPC call
192
+ if (!title) return { success: false, fieldErrors: { title: 'Required' } };
193
+ return { success: true, data: { title } };
194
+ };
195
+ ```
196
+
197
+ Three edges where the Next reflex goes wrong:
198
+
199
+ - **A checkbox group.** A name submitted more than once keeps only its LAST value on the property, so `input.tags` is one string. Name the controls `tags[]` to get `input.tags` as an array, or read `input.getAll('tags')`. Dev warns once when a plain name repeats.
200
+ - **Iteration reports names as submitted.** `Object.fromEntries(input)` and `entries()` show `tags[]`, brackets included, like any `FormData`; read the property or `getAll('tags')` for the group.
201
+ - **Values are still strings and `File`s.** `input.count` is `'7'`, not `7`: coerce in `validate`.
202
+
183
203
  ### `params` and `searchParams` are awaitable AND synchronously readable
184
204
 
185
205
  Next 15/16 made `params` / `searchParams` Promises. WebJs supports BOTH, so either muscle memory is correct.
@@ -292,25 +312,26 @@ class StudentCard extends WebComponent({ student: prop<Student>(Object) }) {
292
312
 
293
313
  The `@property()` decorator is banned by the erasable-TS invariant (decorators are non-erasable, they would force a build step). A `static properties = { ... }` block THROWS at runtime (`no-static-properties`). The single replacement for both is the declare-free base-class factory `WebComponent({ ... })`, with the `prop()` helper carrying options.
294
314
 
295
- ### Passing a method straight to an `@event` binding
296
-
297
- Lit invokes an `@event` listener with `this` set to the host element, so `@click=${this.handleClick}` is correct there. WebJs stores the handler verbatim and dispatches it through an internal part object (`part.handler?.(ev)` in `core/src/render-client/parts.js`), so `this` is THAT object, not your component. It does not fail loudly. A read such as `this.todos` returns `undefined`, and a write such as `this.count = 1` silently lands on the framework's internal object. The `TypeError` arrives later, when you dereference the `undefined` you read back, so the stack points away from the real cause. Nothing catches it statically either: `webjs check` and `tsc` both pass and the component SSRs correctly.
315
+ ### The `@state()` decorator and `state: true`
298
316
 
299
- Wrap the call at the binding site, which is the conventional spelling, or declare the handler as an arrow class field, which carries its own `this`.
317
+ lit's `@state()` decorator is non-erasable, so WebJs spells the same thing as a factory option. It behaves as in lit: no attribute, reassign it to re-render, and it shows up in `changedProperties`.
300
318
 
301
319
  ```ts
302
- // BROKEN: `this` is undefined when the event fires.
303
- html`<form @submit=${this.handleSubmit}>`
304
-
305
- // Wrap it at the binding site:
306
- html`<form @submit=${(e: SubmitEvent) => this.handleSubmit(e)}>`
320
+ // lit
321
+ @state() private open = false;
307
322
 
308
- // Or pre-bind by declaring the handler as an arrow field:
309
- _onSubmit = (e: SubmitEvent) => { /* ... */ };
310
- html`<form @submit=${this._onSubmit}>`
323
+ // WebJs
324
+ class Menu extends WebComponent({ open: prop(Boolean, { state: true }) }) {
325
+ constructor() { super(); this.open = false; }
326
+ toggle() { this.open = !this.open; }
327
+ }
311
328
  ```
312
329
 
313
- An arrow class field is a class-field initializer, but it is NOT a reactive property, so the reactive-property class-field ban does not apply to it and `reactive-props-no-class-field` does not flag it.
330
+ Signals work too, as lit supports them through `@lit-labs/signals`. Write a signal with `.set()`, never by assignment: `this.count = 5` on a signal field replaces the signal and the next `.get()` throws (`no-signal-field-assignment` flags it). In an `async render()`, read signals before the first `await`, since reads after it are not tracked.
331
+
332
+ ### Event handlers follow Lit's JavaScript API
333
+
334
+ `@click=${this.handleClick}` invokes a function with the rendering component as `this`. Arrow functions and explicitly bound functions retain their JavaScript receiver. Objects with `handleEvent()` receive their own object as `this`; `capture`, `once`, and `passive` are supported on listeners. Standalone `render(value, container, { host })` sets an explicit function receiver; without a host it is the bound element.
314
335
 
315
336
  ### Expecting shadow DOM and reaching for scoped CSS
316
337
 
@@ -338,4 +359,4 @@ Context providers publish on connect via `hostConnected`, which does not run at
338
359
 
339
360
  ### Vanilla DOM instead of Lit idioms
340
361
 
341
- WebJs components are Lit-shaped on purpose: the value is the declarative DX. Prefer a factory-declared reactive prop over `this.getAttribute`, a `signal` over a `state: true` prop for internal state, a `class=${...}` binding over `this.classList`, a `@click=${...}` binding over `this.addEventListener`, and `C.register('x')` over `customElements.define`. Vanilla DOM stays right only where the platform offers nothing declarative: `this.closest('ui-tabs')` for compound-component ancestor lookup (resolves at SSR too), slotted-content queries, global `document` / `window` listeners, and imperative `el.focus()`. This is a convention, not a lint rule. A global `document` / `window` LISTENER is one of those legitimate cases, because the event happens outside the element. A document QUERY is not: reaching for markup that another component rendered is the jQuery habit to drop, and for your OWN rendered node a `ref` replaces the selector entirely. The ownership rules at the top of `components.md` state the full test.
362
+ WebJs components are Lit-shaped on purpose: the value is the declarative DX. Prefer a factory-declared reactive prop over `this.getAttribute`, a `state: true` prop or a `signal` for internal state, a `class=${...}` binding over `this.classList`, a `@click=${...}` binding over `this.addEventListener`, and `C.register('x')` over `customElements.define`. Vanilla DOM stays right only where the platform offers nothing declarative: `this.closest('ui-tabs')` for compound-component ancestor lookup (resolves at SSR too), slotted-content queries, global `document` / `window` listeners, and imperative `el.focus()`. This is a convention, not a lint rule. A global `document` / `window` LISTENER is one of those legitimate cases, because the event happens outside the element. A document QUERY is not: reaching for markup that another component rendered is the jQuery habit to drop, and for your OWN rendered node a `ref` replaces the selector entirely. The ownership rules at the top of `components.md` state the full test.
@@ -137,23 +137,23 @@ class LikeButton extends WebComponent({ postId: prop(String) }) {
137
137
  // INSTANCE scope: one signal per element. A module-scope `signal()` is SHARED
138
138
  // across every instance (invariant 5), so a feed of these would all flip
139
139
  // together on one click. Scope per-item state to the instance.
140
- private liked = signal(false);
140
+ readonly #liked = signal(false);
141
141
 
142
142
  private async toggle() {
143
- const next = !this.liked.get();
143
+ const next = !this.#liked.get();
144
144
  // Returns the action's ActionResult, so a { success: false } is readable
145
145
  // by the caller. The rollback has already happened by then.
146
- return optimistic(this.liked, next, () => likePost(this.postId));
146
+ return optimistic(this.#liked, next, () => likePost(this.postId));
147
147
  }
148
148
 
149
149
  render() {
150
- return html`<button @click=${() => this.toggle()}>${this.liked.get() ? 'Liked' : 'Like'}</button>`;
150
+ return html`<button @click=${() => this.toggle()}>${this.#liked.get() ? 'Liked' : 'Like'}</button>`;
151
151
  }
152
152
  }
153
153
  LikeButton.register('like-button');
154
154
  ```
155
155
 
156
- Reserve MODULE scope for state that genuinely is app-wide (a theme, a cart count, a sidebar-open flag), which is the case invariant 5's "module-scope signals share state across components" exists to serve. Per-item state goes on the instance, as above. `liked` is a plain instance field, not a reactive property declared in the factory, so the class-field ban does not reach it and `reactive-props-no-class-field` does not flag it. `gallery/modules/optimistic-ui/components/like-button.ts` is the same shape in shipped code.
156
+ Reserve MODULE scope for state that genuinely is app-wide (a theme, a cart count, a sidebar-open flag), which is the case invariant 5's "module-scope signals share state across components" exists to serve. Per-item state goes on the instance, as above. `#liked` is a private field holding a signal, not a reactive property declared in the factory, so the class-field ban does not reach it and `reactive-props-no-class-field` does not flag it. It is `readonly`: a signal field is written through `.set()`, never assigned. `gallery/modules/optimistic-ui/components/like-button.ts` is the same shape in shipped code.
157
157
 
158
158
  It rolls back on a thrown error OR an `ActionResult` `{ success: false }` envelope, and never on success. It is client-only (it mutates a signal), so a component importing it is never elided as a display-only component.
159
159
 
@@ -178,14 +178,14 @@ A page imports a `'use server'` action and binds it into the form: `<form action
178
178
 
179
179
  **Do not treat the page's segment middleware as the action's authorization gate.** An identity names the action, not the page that rendered it, so the same action is reachable by submitting to any page path (and, being a `'use server'` export, at its RPC endpoint too, where no segment middleware runs at all). That is the same reachability every server action has always had, and the removed page `action` export was the one shape where the URL really did scope the handler. Authorize the action itself: an `export const middleware = [requireAdmin]` on the action, or a check in its body, both of which run on either transport.
180
180
 
181
- A form-bound action always receives the `FormData` as its first argument, which is where it differs from the same action called over RPC. `validate` is the typing seam: it receives that `FormData`, and its transform-return becomes the action's typed input.
181
+ A form-bound action receives the submitted fields as ONE object, the same shape the action gets over RPC: each field is a property (`input.email`), and the `FormData` readers work on it too (`input.get('email')`, `input.getAll('tags')`). A name ending in `[]` arrives as an array, a file field is a real `File`, and a plain name submitted twice keeps its last value on the property. `validate` is the typing seam: its transform-return becomes the action's typed input. See `data-and-actions.md` for the full shape.
182
182
 
183
183
  ```ts
184
184
  // modules/contact/actions/send-message.server.ts
185
185
  'use server';
186
- export async function sendMessage(formData: FormData) {
187
- const email = String(formData.get('email') || '').trim();
188
- const body = String(formData.get('body') || '').trim();
186
+ export async function sendMessage(input: { email?: string; body?: string }) {
187
+ const email = String(input.email || '').trim();
188
+ const body = String(input.body || '').trim();
189
189
  const values = { email, body };
190
190
  const fieldErrors: Record<string, string> = {};
191
191
  if (!email.includes('@')) fieldErrors.email = 'Enter a valid email';
@@ -35,6 +35,7 @@ Three seams pick a runtime-specific implementation, all inside the framework, no
35
35
  | Hot reload | restart on change (the `webjs dev` supervisor, #1521) | in place in one long-lived process (`bun --hot` resets the module registry, #1575); only an `instrumentation.*` / `env.*` edit restarts it |
36
36
  | WebSocket | the `ws` library | native `Bun.serve` + a bridge adapter |
37
37
  | 103 Early Hints | yes | no (`Bun.serve` has no informational-response API) |
38
+ | Response compression (prod) | brotli / gzip / deflate over `node:zlib`: a buffered body (an SSR page, JSON, a module) is compressed synchronously and sent with its `Content-Length`, a streamed body through a streaming compressor, a buffered body under 1 KB is sent as is, and a buffered body under 32 KB is gzip-encoded even when brotli is accepted (gzip 6 is 2 to 3x cheaper than brotli 4 at that size for a few percent more bytes, #1646) | same (both shells share the seam, the 1 KB floor and the 32 KB brotli floor) |
38
39
  | Dev edit to a page / layout | full reload (the dev restart replaces the process) | refreshes IN PLACE, no reload (#1398) |
39
40
  | Reverse-proxy headers | `X-Forwarded-Proto` / `X-Forwarded-Host` honored | same |
40
41
 
@@ -42,7 +43,7 @@ Three seams pick a runtime-specific implementation, all inside the framework, no
42
43
 
43
44
  **The in-place dev refresh (#1398) needs the server process to SURVIVE the edit,** which is the whole of the Node-versus-Bun difference in that row. A page or layout never hydrates, so a freshly rendered page is the complete truth for it and the client router can swap it in without a reload, keeping scroll and (for a page edit) the hydrated state of components outside the changed region. The server classifies the changed file and puts the verdict on the live-reload event, so this needs a process that is still alive to do the classifying.
44
45
 
45
- Bun keeps ONE dev server process for the whole session (#1575), so it gets the refresh. `bun --hot` re-runs the CLI on a change; the first run's server owns the process (its listener, live-reload stream, watchers and analysis caches) and a re-run only tells it the module registry was reset, so nothing is started twice and memory stays flat over hundreds of edits (it used to grow about 25 MB an edit until `bun --hot` stopped reloading). The app's modules load through a `Bun.plugin` that reads them fresh, and its `#` imports resolve through the app's own `imports` map, because Bun keeps the old source of a file that was replaced (an atomic save) and a stale directory listing for a new file next to a `*.server.*` module. So `bun --hot` no longer sees app edits itself, and the dev server asks it for a registry reset after an edit to a module some other module imports, a new module, or a `*.server.*` module, all without a process restart. A page, layout or route handler nothing imports needs no reset: the dev re-import is keyed by the file's content, so an unchanged file reuses its loaded module (no new module instance per request) and an edited one is a new import. `instrumentation.*` and `env.*` run once per process, so an edit to one restarts the server on both runtimes. On Node the `webjs dev` supervisor (it replaced `node --watch` in #1521) RESTARTS the server process on a change under `app`, `components`, `modules`, `lib`, or `actions`, or to a root `middleware.{ts,js,mts,mjs}`, and a fresh process holds no record of what changed, so those edits are always a full reload. Two Node cases still refresh in place: an edit OUTSIDE that watched set (`db/schema.server.ts`, a `webjs.dev.watch` content dir), and running `npm run dev -- --no-hot`, which keeps the server in one process on either runtime. An in-place refresh loads the rebuilt stylesheets (a `webjs.dev.regenerate` compile runs on that request) BEFORE it swaps the new markup in, and drops the old sheets only after, so an element that gained a utility class never paints without its rule (#1535; a full reload never had the gap, since a head stylesheet is render-blocking). A component edit is a full reload everywhere by design, because `customElements.define` is once-per-tag and swapping fresh markup onto the old class would be worse than the reload.
46
+ Bun keeps ONE dev server process for the whole session (#1575), so it gets the refresh. `bun --hot` re-runs the CLI on a change; the first run's server owns the process (its listener, live-reload stream, watchers and analysis caches) and a re-run only tells it the module registry was reset, so nothing is started twice and memory stays flat over hundreds of edits (it used to grow about 25 MB an edit until `bun --hot` stopped reloading). The app's modules load through a `Bun.plugin` that reads them fresh, and its `#` imports resolve through the app's own `imports` map, because Bun keeps the old source of a file that was replaced (an atomic save) and a stale directory listing for a new file next to a `*.server.*` module. So `bun --hot` no longer sees app edits itself, and the dev server asks it for a registry reset after an edit to a module some other module imports, a new module, or a `*.server.*` module, all without a process restart. A page, layout or route handler nothing imports needs no reset: the dev re-import is keyed by the file's content, so an unchanged file reuses its loaded module (no new module instance per request) and an edited one is a new import. `instrumentation.*` and `env.*` run once per process, so an edit to one restarts the server on both runtimes. On Node the `webjs dev` supervisor (it replaced `node --watch` in #1521) RESTARTS the server process on a change under `app`, `components`, `modules`, `lib`, `actions`, or `db` (a schema edit has to reach a fresh process, #1664; only JS and TS source under `db/` counts, so a database file and its sidecars never restart it), or to a root `middleware.{ts,js,mts,mjs}`, and a fresh process holds no record of what changed, so those edits are always a full reload. Two Node cases still refresh in place: an edit OUTSIDE that watched set (a `webjs.dev.watch` content dir), and running `npm run dev -- --no-hot`, which keeps the server in one process on either runtime. An in-place refresh loads the rebuilt stylesheets (a `webjs.dev.regenerate` compile runs on that request) BEFORE it swaps the new markup in, and drops the old sheets only after, so an element that gained a utility class never paints without its rule (#1535; a full reload never had the gap, since a head stylesheet is render-blocking). A component edit is a full reload everywhere by design, because `customElements.define` is once-per-tag and swapping fresh markup onto the old class would be worse than the reload.
46
47
 
47
48
  After a reset, app code that imports `@webjsdev/server` gets a fresh copy of the package while the first run's server keeps handling requests. Everything the two copies must agree on (the request `auth()`, `cookies()` and `headers()` read, the action signal, the seed collector and action identity, the default cache store, sessions, broadcast clients) lives in process-wide state keyed by `Symbol.for`, so a signed-in page and the `'use server'` queries it calls still see the request after any number of edits (#1590). Before that fix, `auth()` without an explicit request returned `null` in app code after the first edit.
48
49
 
@@ -97,6 +98,10 @@ Both `node:sqlite` and `bun:sqlite` default `busy_timeout` to 0, so a contended
97
98
 
98
99
  A `--db postgres` app opens one `pg` Pool in `db/connection.server.ts`. It holds no session state between queries (no `SET`, `LISTEN`, or advisory lock kept across them), so the same code works behind a transaction pooler. Idle connections close after 10 seconds, a connection attempt gives up after 5 seconds (a database that is down fails the request instead of hanging it), `DATABASE_POOL_MAX` caps the pool (default 10), and an idle connection the server drops (a restart or failover) is logged rather than crashing the process. Keep it that way: do not add `SET` statements or session-scoped features to app code.
99
100
 
101
+ ## Knowing when an edit has settled
102
+
103
+ After an edit, the dev server rebuilds and re-analyses the app, and on Node it may restart first. The reload frame a tab receives goes out BEFORE anything is re-imported, so a broken module only shows on the next request. Do not `sleep` and grep a page for "Server error". Run `npx webjs status [path...]` (#1664): it waits until the change has been rebuilt and the analysis has run again (retrying through a Node restart), requests each path (the app root when none is given) plus every page with a recorded render error, then prints every error, render, TypeScript strip, rebuild, route handler, server action, middleware, with its file, line and code frame. A render error stays listed across an unrelated rebuild until that url answers below 500 again (fixed, or deleted and now a 404), and the other kinds clear on each rebuild. A page re-requested only because of an old error fails the run on a server error, not on a 404. It exits 0 when settled and clean, 1 on any problem, 2 when no dev server answered; `--json` gives one document an agent can branch on. The same data is the dev-only `GET /__webjs/dev/status?wait=<seconds>` (`{ status, settled, ready, boot, seq, rebuilds, errors }`), which answers 404 under `npm run start`.
104
+
100
105
  ## Verifying a runtime-sensitive change
101
106
 
102
107
  Most app code needs no runtime-specific testing, because it does not touch a runtime seam. If you DO change something runtime-sensitive (the serializer, a stream, `node:crypto`, low-level request handling, anything that behaves differently under `Bun.serve` versus `node:http`), prove it on both runtimes. The Node suite is the source of truth, and an additive Bun matrix re-runs the runtime-sensitive tests under Bun. See `testing.md` for the cross-runtime matrix and the `test/bun/**` assertions.