@webjsdev/mcp 0.1.18 → 0.1.20

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.
@@ -1,505 +0,0 @@
1
- # Components
2
-
3
- ## What This Covers
4
-
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
- - Declaring reactive properties through the `WebComponent({ ... })` factory and `prop()`, with options (`reflect`, `state`, `attribute`, `default`, `converter`, `hasChanged`)
7
- - Signals for component-local and shared state, plus `effect` / `batch`, alongside lit's `state: true` / `attribute: false` properties
8
- - The Lit-aligned lifecycle and exactly which hooks SSR runs versus skips
9
- - Light DOM (default) versus shadow DOM, and the light-host `display: block` rule
10
- - Slots with full shadow-DOM parity in both DOM modes
11
- - `async render()`: SSR-blocking first paint, client stale-while-revalidate, `renderFallback()` / `renderError()`
12
- - `Task` for client-only async data, and context (`createContext` / `ContextProvider` / `ContextConsumer`) to avoid attribute drilling
13
- - The lit-html directive set (`repeat`, `watch`, `live`, `keyed`, `guard`, `cache`, `until`, `unsafeHTML`, `ref`, `asyncAppend` / `asyncReplace`, `templateContent`)
14
- - Display-only elision (when a component is stripped from the browser)
15
- - Inherited members app code must NOT shadow (`title`, `remove`, `render`, ...)
16
-
17
- Read this when you are authoring or reviewing a `WebComponent`. For styling a component (Tailwind, the tag-prefix rule, host sizing) see `styling.md`. For streaming a slow region or programmatic navigation see `client-router-and-streaming.md`. For Lit habits that break WebJs see `muscle-memory-gotchas.md`.
18
-
19
- ## Ownership: what a component owns
20
-
21
- A component owns four things together: its markup, its state, its listeners, and its styling. The moment one of them lives in a different file from the rest, the feature can no longer be read, tested, or deleted as a unit, and the parts drift apart. These are CONVENTIONS, judged by a reader. `webjs check` has no rule for any of them, and adding one would be wrong, because a sensible app can legitimately want a delegated listener to pass.
22
-
23
- Most of what follows restates widely held component-model advice, ported. Lit's base class exists to hold reactive state, scoped styles, and a declarative template TOGETHER (the `lit` package README), and Lit documents a ref's value as `undefined` once the node "is no longer rendered", which is precisely the signal a selector lookup cannot give you. React frames the same ideas as lifting state to the closest common owner (react.dev, "Sharing State Between Components") and treating a ref as an escape hatch rather than the normal way to reach a node (react.dev, "Escape Hatches"). Where WebJs moves the boundary, the rule that needs it says so inline and names the mechanism.
24
-
25
- **1. Markup and the code that drives it live in the same component.** A class selector is not an interface. `document.querySelector('.nav-toggle')` keeps compiling, keeps type-checking, and keeps passing `webjs check` after someone renames the class in the other file. It just starts returning `null` at runtime. If you are writing a selector to find markup that another file rendered, write the component that renders it instead. Where a value genuinely has to exist in two places (a layout's pre-paint inline script cannot import), the second place READS the first declaration rather than restating it.
26
-
27
- **2. Reach your own rendered node with a ref, never with a selector.** `render()` already owns the node, so let the handle flow out of the template with `ref()` / `createRef()` from `@webjsdev/core/directives` (the directives table below carries the one-line summary). A ref is scoped to the component, so it cannot match a node some other component rendered, and it goes `undefined` when the node stops being rendered, which makes a stale handle visible instead of silent. Two reads a ref cannot express stay vanilla: `this.closest('parent-tag')` for compound-component ancestor lookup, and `assignedNodes()` for slotted content.
28
-
29
- **3. State lives on the component, never on `<body>` or `<html>`.** The client router swaps a range INSIDE the document, so the document shell sits outside every swap. An open flag parked on `<body>` therefore survives a navigation that removed the markup it described, and it re-opens a panel over the next page or leaves scrolling locked on a page with nothing open. State held in a reactive property or an INSTANCE signal dies with the element, which is the behaviour you wanted in the first place. A module-scope signal deliberately outlives it, which is what rule 7 reaches for, so it is the right home for state genuinely shared between components and the wrong one for one element's own open flag. The carve-out is a document-level EFFECT rather than one component's state, and it comes in two shapes. A TRANSIENT effect, a scroll lock being the usual case, belongs to the element that opened it and must be released in `disconnectedCallback`. A PERSISTENT one is a document-wide SETTING, the theme being the case the framework itself ships: the scaffold's theme toggle writes `data-theme` on `<html>` and persists it, deliberately without releasing it on disconnect, because it describes the document rather than the element (`styling.md` carries that pattern). What the rule forbids is neither of those. It is one component's own open / selected / active flag parked on the shell because that was the convenient place to reach it from.
30
-
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
-
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.
34
-
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.
36
-
37
- **6. Listening on `document` is legitimate. Querying `document` usually is not.** An outside-click dismissal or an Escape handler has no choice, because the event happens outside the element, so the listener has to be global. What decides whether that is ownership or a reach across the app is what the handler then READS. `this.contains(e.target)` is a decision about the component's own subtree. `document.querySelector('.other-thing')` is a decision about someone else's markup. Add the listener in `connectedCallback`, remove it in `disconnectedCallback`, and store the handler in a field so `removeEventListener` gets the same reference back (a function created inline at add time can never be removed).
38
-
39
- **7. Talk to an ancestor with an event, and to a stranger with a module-scope signal.** A child telling its own ancestor something dispatches a `CustomEvent` with `bubbles: true`, and the ancestor binds `@my-event=${...}` in the template that rendered it. Add `composed: true` as well when the component sets `static shadow = true`, or the event stops at the shadow boundary. Two components with NO ancestor relationship share a module-scope `signal` that both import, which is typed, greppable, and owned by a module. What neither case is: a made-up event name on `document` used as a global bus, which is a global variable with extra steps. Framework events such as `webjs:navigate` ride `document` because the router has no element to dispatch from, and that is not a licence to add your own.
40
-
41
- The shape to fix, all four pieces in different places:
42
-
43
- ```js
44
- // In a layout's inline script, driving markup that another file rendered.
45
- document.addEventListener('click', (e) => {
46
- if (e.target.closest('.nav-toggle')) document.body.toggleAttribute('data-nav-open');
47
- });
48
- function syncNav() {
49
- const btn = document.querySelector('.nav-toggle'); // another file's markup
50
- const open = document.body.hasAttribute('data-nav-open'); // outlives the markup
51
- if (btn) btn.setAttribute('aria-expanded', String(open)); // a second home for the state
52
- }
53
- ```
54
-
55
- The shape to write, one component owning all four:
56
-
57
- ```ts
58
- import { WebComponent, prop, html } from '@webjsdev/core';
59
- import { createRef, ref } from '@webjsdev/core/directives';
60
-
61
- class NavDrawer extends WebComponent({ open: prop(Boolean, { reflect: true }) }) {
62
- private toggleRef = createRef<HTMLButtonElement>();
63
- // Stored in a field, so removeEventListener gets the same reference back.
64
- private onDocClick = (e: MouseEvent) => {
65
- if (!this.contains(e.target as Node)) this.open = false; // reads its OWN subtree
66
- };
67
- private onDocKeydown = (e: KeyboardEvent) => {
68
- if (e.key !== 'Escape' || !this.open) return;
69
- this.open = false;
70
- // The ref lands after the FIRST client commit, and `ref()` is a no-op at
71
- // SSR, so read `.value` from a handler or `firstUpdated`, never from the
72
- // constructor. This is the reach a selector would otherwise have done.
73
- this.toggleRef.value?.focus();
74
- };
75
-
76
- constructor() { super(); this.open = false; } // SSR runs the constructor
77
-
78
- connectedCallback() {
79
- super.connectedCallback();
80
- document.addEventListener('click', this.onDocClick); // listening globally is fine
81
- document.addEventListener('keydown', this.onDocKeydown);
82
- }
83
- disconnectedCallback() {
84
- super.disconnectedCallback();
85
- document.removeEventListener('click', this.onDocClick); // the state dies with the element
86
- document.removeEventListener('keydown', this.onDocKeydown);
87
- }
88
-
89
- render() {
90
- return html`
91
- <button ${ref(this.toggleRef)}
92
- aria-expanded=${this.open ? 'true' : 'false'}
93
- @click=${() => { this.open = !this.open; }}>Menu</button>
94
- <nav ?hidden=${!this.open}><slot></slot></nav>
95
- `;
96
- }
97
- }
98
- NavDrawer.register('nav-drawer');
99
- ```
100
-
101
- This repo's own website is the worked example. Before commit `b80de906` the docs drawer and the header menu were exactly the first shape, and every accessibility bug their tests now pin came out of the split. `website/components/docs-drawer.ts` and `website/components/site-nav-menu.ts` are the second shape, and `website/AGENTS.md` records the app-level version of these rules under "What stays inline script in the root layout".
102
-
103
- ## Sizing an island: own the behaviour, not the section
104
-
105
- The ownership rules above answer "what must not be split apart". They do NOT answer "how much to pull in", and read alone they push in one direction only: rule 1 says that if you are reaching for a selector, write the component that renders that markup instead. Applied without a stopping rule, that argument never terminates, because there is always more surrounding markup a component could render.
106
-
107
- Here is the stopping rule. **A component owns the markup its own behaviour reads or writes.** Static markup that no handler touches, no state change re-renders, and no template hole depends on belongs to the page, not to the island.
108
-
109
- This is a byte-cost rule, not a taste one. A page never hydrates, so markup it renders is free in the browser. Markup an island renders is not: the island's module is fetched, `@webjsdev/core` comes with it, and on upgrade the component re-renders, replacing the server's DOM for that subtree. Moving static markup across that boundary converts free HTML into shipped JavaScript that reproduces markup the server already sent.
110
-
111
- ```ts
112
- // TOO BIG. One button's worth of behaviour, a whole page's worth of markup.
113
- class ProductPage extends WebComponent({ product: prop<Product>(Object) }) {
114
- render() {
115
- return html`
116
- <h1>${this.product.name}</h1>
117
- <p>${this.product.description}</p>
118
- <spec-table .rows=${this.product.specs}></spec-table>
119
- <button @click=${() => addToCart(this.product.id)}>Add to cart</button>
120
- <review-list .reviews=${this.product.reviews}></review-list>
121
- `;
122
- }
123
- }
124
- ```
125
-
126
- ```ts
127
- // RIGHT SIZE. The page renders the static markup. The island is the button.
128
- class AddToCart extends WebComponent({ productId: String }) {
129
- render() {
130
- return html`<button @click=${() => addToCart(this.productId)}>Add to cart</button>`;
131
- }
132
- }
133
- ```
134
-
135
- The first version ships three modules instead of one: itself, plus `<spec-table>` and `<review-list>`, which were display-only and elidable until a shipping component rendered them (see "Display-only elision" below). The second ships one small module, and the heading, description, spec table, and reviews stay HTML the browser never pays for.
136
-
137
- **How to tell, on a component you are about to write.** Walk its template and ask of each element: does a handler in this class touch it, does a state or property change alter it, or does it sit in a template hole? If the answer is no for all three, that element is a passenger. A template that is mostly passengers is an island that wants splitting, and the split is usually "hoist the static markup back to the page and keep the interactive fragment".
138
-
139
- **The exception, and it is a real one.** Markup that is static *today* but is the thing a near-term behaviour will read is fine to keep, because the alternative is a component that reaches outward for it later, which is what rule 1 forbids. Judge the behaviour you are building, not one you are speculating about. When those genuinely collide, ownership wins over bytes: a coherent component that ships a little extra markup beats a split feature that a selector holds together.
140
-
141
- ## Reactive properties: the base-class factory
142
-
143
- Reactive properties are declared by passing their shape into `WebComponent({ ... })`. The types flow automatically to `this.<prop>`, so there is NO `static properties` block and NO `declare` line (a `static properties` block throws at runtime, caught by `no-static-properties`).
144
-
145
- ```ts
146
- import { WebComponent, prop, html } from '@webjsdev/core';
147
-
148
- class Dialog extends WebComponent({
149
- open: prop(Boolean, { reflect: true }), // reflects to the `open` attribute
150
- showClose: prop(Boolean, { attribute: 'show-close-button' }), // custom attribute name
151
- variant: prop<'info' | 'danger'>(String, { reflect: true }), // narrowed union type
152
- student: prop<Student>(Object), // narrowed object type
153
- items: prop<Tag[]>(Array), // array-typed prop uses Array, not Object
154
- internal: prop<boolean>({ state: true }), // internal state, no attribute (untyped is unknown)
155
- }) {
156
- constructor() {
157
- super();
158
- this.open = false; // set defaults in the constructor, after super()
159
- this.student = { name: '', email: '' };
160
- this.items = [];
161
- }
162
- render() {
163
- return html`<button ?disabled=${!this.open}>${this.variant}</button>`;
164
- }
165
- }
166
- Dialog.register('ui-dialog');
167
- ```
168
-
169
- The bare form is shorthand: `count: Number` means `prop(Number)`. Use `prop()` to pass options or narrow the TS type.
170
-
171
- | Option | Default | Meaning |
172
- |---|---|---|
173
- | `type` | `String` | Constructor feeding the default attribute converter |
174
- | `reflect` | `false` | Property changes write back to the HTML attribute (a value with no attribute representation removes it instead, see below) |
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`) |
177
- | `default` | none | Declarative initial value (a function runs per instance for a fresh object / array) |
178
- | `hasChanged` | strict `!==` | Custom change detection |
179
- | `converter` | type-based | Custom attribute-to-property serialization. `fromAttribute` runs on BOTH readers (the client upgrade and SSR), ahead of type coercion |
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
-
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`.
188
-
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;`.
190
-
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`.
192
-
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.
194
-
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.
196
-
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.
198
-
199
- ## Signals
200
-
201
- A signal is one of the state options listed above, alongside `state: true` and `attribute: false` properties.
202
-
203
- ```ts
204
- import { signal, computed } from '@webjsdev/core';
205
-
206
- const cart = signal<Item[]>([]); // module-scope: shared across components, survives navigations
207
- const count = computed(() => cart.get().length); // derived
208
- ```
209
-
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`.
211
-
212
- Two more signal primitives from `@webjsdev/core` cover client-side reactions and batched writes:
213
-
214
- - `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.
215
- - `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.
216
-
217
- ```ts
218
- import { signal, effect, batch } from '@webjsdev/core';
219
- const open = signal(false), count = signal(0);
220
- connectedCallback() { super.connectedCallback(); this.dispose = effect(() => { document.title = `(${count.get()})`; }); }
221
- disconnectedCallback() { super.disconnectedCallback(); this.dispose?.(); }
222
- reset() { batch(() => { open.set(false); count.set(0); }); } // one re-render, not two
223
- ```
224
-
225
- ## Lifecycle (Lit-aligned) and what SSR runs
226
-
227
- Each update cycle runs these in order; each receives a `changedProperties` Map.
228
-
229
- | # | Hook | When |
230
- |---|---|---|
231
- | 1 | `shouldUpdate(changed)` | Return `false` to skip. Default `true`. |
232
- | 2 | `willUpdate(changed)` | Pre-render. Assignments here fold into THIS cycle. |
233
- | 3 | `update(changed)` | Default calls `render()` + commits. Override rarely. |
234
- | 4 | `firstUpdated(changed)` | Once, on the first render only. |
235
- | 5 | `updated(changed)` | Every commit. Ad-hoc post-render DOM work. |
236
- | 6 | `updateComplete` resolves | `await el.updateComplete` to read post-render DOM in tests. |
237
-
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).
239
-
240
- ## Light DOM (default) vs shadow DOM
241
-
242
- Light DOM is the default: global CSS and Tailwind utilities apply directly, no `:host` or CSS-var plumbing. Set `static shadow = true` only for `static styles = css\`...\`` scoped styles, third-party embed isolation, or the native `::slotted()` selector.
243
-
244
- ```ts
245
- class Panel extends WebComponent({ label: String }) {
246
- static shadow = true;
247
- static styles = css`:host { display: block } .body { padding: 16px }`;
248
- render() { return html`<div class="body">${this.label}</div>`; }
249
- }
250
- ```
251
-
252
- - A light-DOM component authoring custom CSS MUST prefix every class selector with its tag name (`.my-card__body` or `my-card .body`). Prefer Tailwind, unique by construction. `static styles` on a light-DOM component is silently ignored.
253
- - **Never interpolate into a component's `<style>` or `<script>` body** (`html\`<style>${x}</style>\``). The server emits it but the client drops the raw-text hole, so it paints then wipes to empty on hydrate (flagged by `no-interpolation-in-raw-text-element`). Use `static styles` or Tailwind.
254
- - Light-DOM hosts are marked `display: block` via one low-priority `@layer webjs-host` rule (overridable by any Tailwind utility). Shadow hosts are NOT marked; set `:host { display: block }` in `static styles`. Size the HOST (put `w-full max-w-[...]` on the render root), not only an inner wrapper. See `styling.md`.
255
-
256
- ## Slots
257
-
258
- 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).
259
-
260
- **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.)
261
-
262
- 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.
263
-
264
- 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.
265
-
266
- ```ts
267
- class MyCard extends WebComponent {
268
- render() {
269
- return html`
270
- <header><slot name="header"></slot></header>
271
- <main><slot></slot></main>
272
- <footer><slot name="footer">no actions</slot></footer>`;
273
- }
274
- }
275
- ```
276
-
277
- 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.
278
-
279
- **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:
280
-
281
- ```ts
282
- const card = document.querySelector('my-card');
283
- card.appendChild(node); // live, projected
284
- card.querySelector('[slot=old]').slot = 'new'; // flip re-projects
285
- card.innerHTML = '<p>replaced</p>'; // replaces slotted content
286
- card.querySelector('slot').assignedNodes(); // read, mirrors shadow
287
- node.assignedSlot;
288
- card.querySelector('slot').addEventListener('slotchange', ...); // async + coalesced
289
- ```
290
-
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).
292
-
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`.
294
-
295
- ## Async render: first-paint server data
296
-
297
- `render()` may be `async`, so a leaf component fetches its own server data into the first paint with no prop-drilling.
298
-
299
- ```ts
300
- class UserActivity extends WebComponent({ uid: String }) {
301
- renderFallback() { return html`<div class="skeleton h-24"></div>`; } // optional, re-fetch only
302
- async render() {
303
- const items = await getActivity(this.uid); // 'use server' action: real fn at SSR, RPC stub on client
304
- return html`<ul>${items.map((i) => html`<li>${i.label}</li>`)}</ul>`;
305
- }
306
- }
307
- ```
308
-
309
- Three decoupled concerns, do not conflate them.
310
-
311
- 1. **SSR always blocks by default.** The server awaits `async render()`, so the resolved data is baked into the first paint. There is no first-paint fallback, ever (a progressive-enhancement upgrade over a client-fetched `Task`).
312
- 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.
313
- 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.
314
-
315
- 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.
316
-
317
- 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.
318
-
319
- **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.
320
-
321
- **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.
322
-
323
- 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.
324
-
325
- ## Task: client-only async data
326
-
327
- 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.
328
-
329
- ```ts
330
- import { Task, TaskStatus } from '@webjsdev/core/task';
331
-
332
- class SearchResults extends WebComponent({ q: String }) {
333
- #search = new Task(this, {
334
- task: async ([q], { signal }) => (await fetch(`/api/s?q=${q}`, { signal })).json(),
335
- args: () => [this.q], // the args array spreads into the task's first parameter
336
- });
337
- render() {
338
- switch (this.#search.status) {
339
- case TaskStatus.PENDING: return html`<p>Searching...</p>`;
340
- case TaskStatus.ERROR: return html`<p>${this.#search.error.message}</p>`;
341
- case TaskStatus.COMPLETE: return html`<ul>${this.#search.value.map((r) => html`<li>${r.title}</li>`)}</ul>`;
342
- default: return html`<p>Type to search.</p>`; // INITIAL (also the SSR state)
343
- }
344
- }
345
- }
346
- ```
347
-
348
- `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.
349
-
350
- ## Context: share state without attribute drilling
351
-
352
- 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`).
353
-
354
- ```ts
355
- import { createContext, ContextProvider, ContextConsumer } from '@webjsdev/core/context';
356
-
357
- export const themeContext = createContext<'light' | 'dark'>('theme');
358
-
359
- class ThemeRoot extends WebComponent({}) {
360
- #provider = new ContextProvider(this, { context: themeContext, initialValue: 'dark' });
361
- toggle() { this.#provider.setValue(this.#provider.value === 'dark' ? 'light' : 'dark'); }
362
- }
363
-
364
- class ThemedCard extends WebComponent({}) {
365
- #theme = new ContextConsumer(this, { context: themeContext, subscribe: true }); // re-renders on change
366
- render() { return html`<div class=${this.#theme.value === 'dark' ? 'bg-black' : 'bg-white'}>...</div>`; }
367
- }
368
- ```
369
-
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.
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
-
392
- ## Directives (lit-html parity)
393
-
394
-
395
- | Directive | Use it for |
396
- |---|---|
397
- | `repeat(items, keyFn, tpl)` | A keyed list where items reorder / insert / remove (preserves DOM + state per key). A static list is a plain `.map`. |
398
- | `watch(signal)` | A fine-grained DOM swap of one signal's value without re-rendering the whole component. |
399
- | `live(value)` | An `input` / `textarea` `.value` bound to state, so a user edit that equals the last committed value still resets. |
400
- | `keyed(key, tpl)` | Force a fresh subtree (discard old DOM + state) when `key` changes. |
401
- | `guard(deps, () => tpl)` | Skip re-rendering an expensive subtree unless `deps` change. |
402
- | `cache(tpl)` | Keep the DOM of an inactive branch around when toggling between templates. |
403
- | `until(promise, fallback)` | Render `fallback` until `promise` resolves (prefer `Task` in a component, `Suspense` for a page). |
404
- | `unsafeHTML(str)` | Render a TRUSTED raw HTML string. NEVER pass user input (XSS). |
405
- | `ref(cb)` / `createRef()` | Get a handle to the rendered DOM node. |
406
- | `asyncAppend(iter)` / `asyncReplace(iter)` | Stream from an async iterable, appending each value or replacing with the latest. |
407
- | `templateContent(el)` | Render the content of a `<template>` element. |
408
-
409
- Every directive here is CLIENT behaviour. At SSR the server renders one shot, so
410
- `guard` always invokes its function, `watch` reads its signal once and inlines
411
- the value, and `live` is fully transparent, resolving to the value it wraps in
412
- every hole position (a child, a plain attribute, a `?bool`, a `.prop`). So
413
- `?open=${live(false)}` omits its attribute exactly as `?open=${false}` does. It
414
- was previously resolved only in a child hole, which served `open=""` and let
415
- hydration close the element a moment later (#1443).
416
-
417
- ## Lazy components: load on first sight
418
-
419
- `static lazy = true` defers a component's module until an element with its tag is first visible: scrolled within 200px of the viewport, or shown when a `hidden` tab panel or a closed `<dialog>` around it opens. Reach for it for heavy panes and dialogs a first screen does not show (an editor behind a Code tab, a data browser, a settings dialog). It stays lazy when the component that renders it imports it (`import './code-pane.ts'`): the server keeps that import for SSR, and the browser copy of the importer gets an `observeLazy` registration in its place, with no preload for the lazy subtree (#1524). Only a SIDE-EFFECT import defers; a binding import (`import { CodePane } from ...`) stays eager, and one anywhere in the app makes that component eager everywhere. Until the module arrives the element is not upgraded, so a parent calling into it uses optional calls (`this.#pane.value?.save?.()`). The loader scans light DOM only, so do not render a lazy tag inside a shadow root. Browser tests (`webjs test --browser`) get every import as written, so a test that imports a lazy component has it defined at once. A component opened by a window event rather than by becoming visible (a panel that renders nothing until an `open` event) does not fit `static lazy`: dynamically `import()` its module in the code that dispatches the event, then dispatch.
420
-
421
- ## Display-only elision
422
-
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:
424
-
425
- - an `@event` binding or native handler property (`.onclick`)
426
- - a factory-declared reactive property, including `state: true` and `attribute: false`
427
- - an overridden lifecycle hook (including `renderFallback` / `renderError`)
428
- - an imported `signal` / `computed` / `watch` / `Task` / `ref` / streaming directive, or `addController` / `requestUpdate`
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)
430
- - 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)
431
- - being rendered by a component that itself ships
432
-
433
- That last blocker is the one to DESIGN around rather than merely inspect, because it is the only one that is not about the component you are looking at. Elision propagates downward from whatever ships, so the size of your islands decides how much of the tree stays elidable. Ten display-only components rendered by a page are ten modules the browser never fetches. The same ten rendered by one oversized interactive wrapper all ship, and nothing about any of them changed. This is why "Sizing an island" above is a byte-cost rule and not a tidiness preference.
434
-
435
- 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`.
436
-
437
- ### What `static interactive = true` does and does not rescue
438
-
439
- The analyser reads source lexically, so a few real shapes escape it. The override covers them:
440
-
441
- - **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.
442
- - **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.
443
- - **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.
444
-
445
- **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')`.
446
-
447
- `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.
448
-
449
- ### Inspecting and proving the verdict
450
-
451
- 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.
452
-
453
- ```sh
454
- webjs elision # per-module verdict, and the evidence behind every ship
455
- webjs elision --json # the same object, for a tool or an agent
456
- webjs elision --verify # prove elision changed nothing your app serves
457
- webjs elision --verify --routes /,/blog/hello # add paths (the only way to cover a dynamic route)
458
- ```
459
-
460
- **Reading the report.** Every component is `elided` or `shipped`. A shipped one carries the `evidence` that forced it, first match wins:
461
-
462
- | `evidence` | Means | `by` |
463
- |---|---|---|
464
- | `own` | its own source carries a signal; `reason` is the exact one | null |
465
- | `observed` | another module observes its registration (`whenDefined` / `:defined` / `instanceof`) | the observer |
466
- | `closure` | something it imports does client work | the import |
467
- | `render` | a shipping component can render its tag | that component |
468
- | `import` | a shipping component imports it | that component |
469
- | `unreadable` | its source could not be read, so it ships conservatively | null |
470
-
471
- An elided row carries no reason on purpose: elision is the ABSENCE of every signal, so there is no positive fact to report.
472
-
473
- **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).
474
-
475
- **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:
476
-
477
- ```sh
478
- WEBJS_ELIDE=1 npm run test:e2e
479
- WEBJS_ELIDE=0 npm run test:e2e
480
- ```
481
-
482
- 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.
483
-
484
- `webjs doctor` carries the same verdict as a one-line inventory, and warns only on an orphan.
485
-
486
- ## Members app code must not shadow
487
-
488
- 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.
489
-
490
- - 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`).
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.
492
-
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.