@webjsdev/mcp 0.1.10 → 0.1.12

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.
@@ -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.**
@@ -43,6 +56,16 @@ revalidate(); // clear the entire snapshot cache
43
56
 
44
57
  The router keeps a URL-keyed snapshot cache (LRU, cap 16) so Back/Forward restores instantly, then refetches in the background. Call `revalidate(path)` after a server action mutates data a cached page depends on. Wire bytes are minimized by an `X-Webjs-Have` header, so the server returns only the divergent layout fragment. Concurrent navigations abort the prior in-flight fetch, and scroll is restored on Back/Forward.
45
58
 
59
+ **Back/Forward scroll restore vs late layout growth.** The router SUPPRESSES the browser's scroll anchoring (`overflow-anchor`) for the duration of a Back/Forward restore, then puts it back. The saved offset was recorded against the page at its SETTLED height, while the DOM the restore swaps in is still shorter until its components upgrade and render. Without the suppression the browser treats that late growth as content appearing above a reader and adds it to the offset the router just replayed, so the reader lands BELOW where they left (the reported case was 763px, exactly the height a page gained after its swap). What follows for an app:
60
+
61
+ - **Do not write your own scroll restore.** A `popstate` listener that calls `scrollTo`, a saved offset in `sessionStorage`, a `scrollIntoView` on a remembered element: all of them fight the router, which already set `history.scrollRestoration = 'manual'` and is the sole authority on scroll during a navigation. If Back lands in the wrong place, that is a framework bug to report, not something to patch in app code.
62
+ - **An app that sets `overflow-anchor` on `<html>` itself sees it overridden during a restore and restored afterwards**, including a value set inline by your own script. Setting it in a stylesheet is unaffected between restores. Nothing else on the page is touched, and the router never sets `overflow-anchor` anywhere but the root element.
63
+ - **A new PAGE navigation ends an open window.** The window outlives its own restore on purpose (a floor, then a ceiling), so a page navigation or a page-level form submission starting inside that span closes it first, and reopens only if it earns one. Otherwise a second Back, or a click, would inherit suppressed anchoring on a page it was never meant for. A FRAME-TARGETED navigation or submission is the exception, on exactly the rule that decides frame targeting everywhere else (the enclosing frame, an explicit `data-webjs-frame="<id>"` from anywhere, or the frame's own `src`; `_top` and an unresolvable id are page navigations and do close the window). It swaps one region and leaves the page, and so the restored offset, intact, so it leaves the restore running. Closing there would hand anchoring back mid-restore and bring the double count straight back, and it needs no user input to happen, since a component upgrading in the just-restored page can drive a frame on its own.
64
+ - **Suppression is conditional on the offset being reachable, and follows the chase onto it.** A page that has not grown yet can be too short to scroll that far, so the browser clamps to its current maximum. There the shortfall IS the growth still to come, and anchoring adding it is what carries the reader back down, so the router leaves anchoring alone. Suppressing in that case would freeze the clamp and strand the reader a full page-growth above where they left, which is this same defect pointing the other way. That case is not left to anchoring alone, though, because anchoring adds the FULL growth however far short the clamp fell, so by itself it only lands a reader who left at the very bottom. The router also CHASES the recorded offset there, re-asserting it the moment the page is tall enough to hold it, and then stopping. That is the one place the router writes scroll after the initial restore, it is scoped to the clamped path, and it stops on the same inputs that close a suppression window. It is also time-boxed, and more tightly than the window a landed restore gets: a few hundred milliseconds from the RESTORE, not the 2s ceiling, and the suppression it installs on landing shares that same deadline rather than starting a fresh one. That bound is what keeps it from moving a reader who has landed and started reading, since such a reader generates no input to cancel it and the chase cannot tell the restore settling apart from any other growth. Anchoring is left on only WHILE the offset is out of reach, which is the part that heals the clamp. The moment the chase lands on the offset it suppresses anchoring too, because the growth that made the offset reachable is rarely all of it and every later stage would otherwise be added on top of what was just written. Both halves end together on the bound. After it, the router writes no more scroll and anchoring is back on, so a component that reaches its final height later than the bound (a chart, an embed measured from its content) has its growth added and the reader drifts BELOW the offset, the same way they would without this fix at all, rather than sitting at the clamp.
65
+ - **The window closes on the first real input** (`wheel`, `touchmove`, `keydown`, `pointerdown`), so a reader who starts scrolling mid-restore immediately gets normal browser anchoring back. Absent that it closes once the restore is over, which is the LATER of the restore's own background revalidation settling and a short floor, and at the latest on a 2s ceiling. The floor is load-bearing: waiting on the revalidation alone ties the window's length to network latency rather than to the growth it guards, so a server answering faster than the page renders would close it early and the reader would land low again. Suppression only ever WITHHOLDS a browser correction, it never moves the viewport, so it cannot yank someone who has taken over.
66
+
67
+ Components that reach their final size only after they render (a chart, a media embed with no intrinsic dimensions, anything sized from measured content) are exactly the shape that triggers this, and they need no special handling: give them a placeholder height where you can, and let the router own the restore.
68
+
46
69
  **Error recovery.** A 2xx/3xx swap applies in place, and an HTML error body of any status (a 422 re-rendered form, a 5xx error page) is ALSO applied in place with no reload. For a non-HTML error or a transport failure the router dispatches a cancelable `webjs:navigation-error` on `document` (detail `{ url, status, error }`). Call `preventDefault()` to own recovery, otherwise the router renders a minimal in-place alert into the layout slot.
47
70
 
48
71
  ```ts
@@ -52,11 +75,24 @@ document.addEventListener('webjs:navigation-error', (e) => {
52
75
  });
53
76
  ```
54
77
 
78
+ **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.
79
+
80
+ ```ts
81
+ document.addEventListener('webjs:navigation-fallback', (e) => {
82
+ // A full document load on a click is a UX regression worth knowing about in prod.
83
+ if (e.detail.willReload) analytics.track('router_full_load', e.detail);
84
+ });
85
+ ```
86
+
87
+ **`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.
88
+
55
89
  **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
90
 
91
+ **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.
92
+
57
93
  ## Link Prefetch
58
94
 
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.
95
+ 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
96
 
61
97
  Override per link with the `data-prefetch` attribute.
62
98
 
@@ -72,6 +108,8 @@ Next-style aliases work (`true` = `render`, `auto` = `viewport`, `false` = `none
72
108
 
73
109
  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
110
 
111
+ **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.
112
+
75
113
  ## `<webjs-frame>` Partial-Swap Regions
76
114
 
77
115
  `<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 +141,16 @@ The router can wrap a navigation's DOM mutation in the native View Transitions A
103
141
  <meta name="view-transition" content="same-origin">
104
142
  ```
105
143
 
106
- The accepted value is `same-origin`. When enabled it wraps all three swap paths (the layout-marker swap, the `<webjs-frame>` swap, and the full-body fallback). 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`.
144
+ 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:
145
+
146
+ ```ts
147
+ // app/gallery/page.ts
148
+ export const metadata = { other: { 'view-transition': 'same-origin' } };
149
+ ```
150
+
151
+ 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.
152
+
153
+ 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
154
 
108
155
  ## `<webjs-stream>` Surgical Updates
109
156
 
@@ -120,17 +167,18 @@ Actions: `append` / `prepend` (child of the target id), `before` / `after` (sibl
120
167
  ```ts
121
168
  // app/post/[id]/route.ts
122
169
  import { stream, streamResponse, acceptsStream, broadcast } from '@webjsdev/server';
170
+ import { escapeText } from '@webjsdev/core';
123
171
 
124
172
  export async function POST(req: Request, { params }) {
125
173
  const comment = await addComment(params.id, await req.formData());
126
- const parts = stream.append('comments', `<li>${escapeHtml(comment.text)}</li>`);
174
+ const parts = stream.append('comments', `<li>${escapeText(comment.text)}</li>`);
127
175
  broadcast(`post:${params.id}`, parts); // fan out to every viewer
128
176
  if (acceptsStream(req)) return streamResponse(parts);
129
- return Response.redirect(`/post/${params.id}`, 303); // no-JS fallback
177
+ return Response.redirect(new URL(`/post/${params.id}`, req.url), 303); // no-JS fallback
130
178
  }
131
179
  ```
132
180
 
133
- `stream.*` escapes the target id but NOT the content, so escape any user substring yourself, exactly like an `html` hole.
181
+ `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
182
 
135
183
  ## Streaming (Suspense and RPC)
136
184
 
@@ -176,13 +224,25 @@ export function WS(ws, req, { params }) {
176
224
  }
177
225
  ```
178
226
 
179
- **Client.** `connectWS(url, handlers)` from `@webjsdev/core` auto-reconnects with exponential backoff, handles JSON parse/stringify, and queues sends while disconnected.
227
+ **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
228
 
181
229
  ```js
182
230
  import { connectWS, renderStream } from '@webjsdev/core';
183
- connectWS('/feed', { onMessage: (m) => renderStream(m) });
231
+
232
+ connectedCallback() {
233
+ super.connectedCallback();
234
+ this.conn = connectWS('/feed', {
235
+ onOpen: () => (this.online = true),
236
+ onClose: () => (this.online = false),
237
+ onMessage: (m) => renderStream(m), // apply a server-pushed <webjs-stream> payload
238
+ });
239
+ }
240
+ disconnectedCallback() { super.disconnectedCallback(); this.conn?.close(); }
241
+ send(text) { this.conn.send(text); }
184
242
  ```
185
243
 
244
+ **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).
245
+
186
246
  **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
247
 
188
248
  ## 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, and dynamic `name=${...}` 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 projects into `<slot data-webjs-light data-projection="actual">`, shadow DOM via Declarative Shadow DOM), so slotted content renders with no JS.
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. Total also means a removal takes the row's own boundary markers with it, so a list that grows and shrinks all day is net zero on the nodes the renderer added, rather than accruing one invisible comment per removed row for the life of the region.
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,65 @@ 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 rendered `<slot>`, or being rendered by a component that itself ships
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
266
+
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. `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`.
268
+
269
+ ### What `static interactive = true` does and does not rescue
270
+
271
+ The analyser reads source lexically, so a few real shapes escape it. The override covers them:
272
+
273
+ - **An OBSERVER that computes the tag it waits for.** `customElements.whenDefined(TAG)` where `TAG` is a variable does not name a tag the analyser can resolve, so the observed component is elided, its `register` never runs, and the `await` never settles. Put `static interactive = true` on the OBSERVED component.
274
+ - **A `:defined` rule in an external stylesheet.** `public/app.css` is not in the module graph, so a `my-badge:defined { … }` rule is invisible. Same fix, on the component the rule names.
275
+ - **A consumer that reaches the element through a string selector.** The analyser matches `whenDefined` / `:defined` / `instanceof`, so a `document.querySelector('my-wrapper')` consumer escapes all three. Same fix, on the component being reached.
276
+
277
+ **It does NOT rescue a component whose OWN registration tag is computed.** `Badge.register(TAG)` is not a registration the scanner recognises (invariant 3 requires a literal tag), so that component is never in the component set at all: it gets no verdict, nothing consults the analyser for it, and the override has nothing to attach to. The registration still runs if the module reaches the browser, so what you ALWAYS lose is the verdict, the tag-to-module registry entry, and the preload hint. Whether the element upgrades depends on one thing: the importing module has to ship WHOLE. An inert, import-only, or elided importer is dropped from the boot and takes the import with it, and then the element never registers at all. A page rendering a real component alongside the orphan is import-only unless it ALSO does its own client work, so shipping whole is the narrower case: assume the element does not upgrade. Always pass a literal: `Badge.register('my-badge')`.
278
+
279
+ `webjs dev` warns, and `webjs elision` / `webjs doctor` report it, as an **orphan**. That name covers TWO shapes and they fail differently, so read the warning carefully: a computed tag is the case above, while a class with NO registration call anywhere in the app is the plainer one (someone forgot to register it), and that element never upgrades. The check is app-wide, so registering the class from a sibling module is fine and is not reported. Both lose the verdict, the registry entry, and the preload hint.
280
+
281
+ ### Inspecting and proving the verdict
282
+
283
+ Elision is the one thing WebJs decides about your code that you did not write down, so it is inspectable rather than something to reason about from the rules above.
284
+
285
+ ```sh
286
+ webjs elision # per-module verdict, and the evidence behind every ship
287
+ webjs elision --json # the same object, for a tool or an agent
288
+ webjs elision --verify # prove elision changed nothing your app serves
289
+ webjs elision --verify --routes /,/blog/hello # add paths (the only way to cover a dynamic route)
290
+ ```
291
+
292
+ **Reading the report.** Every component is `elided` or `shipped`. A shipped one carries the `evidence` that forced it, first match wins:
293
+
294
+ | `evidence` | Means | `by` |
295
+ |---|---|---|
296
+ | `own` | its own source carries a signal; `reason` is the exact one | null |
297
+ | `observed` | another module observes its registration (`whenDefined` / `:defined` / `instanceof`) | the observer |
298
+ | `closure` | something it imports does client work | the import |
299
+ | `render` | a shipping component can render its tag | that component |
300
+ | `import` | a shipping component imports it | that component |
301
+ | `unreadable` | its source could not be read, so it ships conservatively | null |
302
+
303
+ An elided row carries no reason on purpose: elision is the ABSENCE of every signal, so there is no positive fact to report.
304
+
305
+ **What to do with each verdict.** `elided` on a component you believe is interactive is the one result worth acting on: find the signal it is missing (the list above), and if the interactivity is genuinely invisible to static analysis, add `static interactive = true`. `shipped` with an `evidence` you did not expect is usually a `closure` row, and the fix is to move the client-effecting import out of that component's path. An `orphans` row is always a bug, and the fix depends on which shape it is: give the class a literal registration tag if its tag is computed, or add the missing `Class.register('my-tag')` call if there is none at all (delete the class instead if nothing uses it).
306
+
307
+ **What `--verify` proves.** It renders every static page route with elision on and off and diffs the bytes with the JS-loaded set masked out, which is the framework's own guard pointed at your app. So it proves elision did not change what your app SERVES. It does not prove post-hydration behaviour, because a wrongly dropped module shows up as a dead click, not as different bytes. Cover that half by running your own browser or e2e suite twice:
308
+
309
+ ```sh
310
+ WEBJS_ELIDE=1 npm run test:e2e
311
+ WEBJS_ELIDE=0 npm run test:e2e
312
+ ```
313
+
314
+ It exits non-zero on a divergence AND on a corpus where nothing could be compared, so it is safe to put in CI. The ON side is forced on rather than read from your config, so the comparison is a real one even in an app that has elision switched off, and the run reports how many modules elision actually dropped so a trivially-true pass is visible. Dynamic routes are skipped by name (rendering one would mean inventing param values); pass real ones with `--routes`. A route whose two same-side renders already differ is reported as nondeterministic and excluded, since a differential over live data proves nothing.
157
315
 
158
- 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`.
316
+ `webjs doctor` carries the same verdict as a one-line inventory, and warns only on an orphan.
159
317
 
160
318
  ## Members app code must not shadow
161
319
 
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.
320
+ 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
321
 
164
322
  - 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`. Only override one deliberately, with its exact signature; never repurpose the name for app logic.
323
+ - 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
324
 
167
325
  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`.