@webjsdev/cli 0.10.12 → 0.10.14

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,486 +0,0 @@
1
- # WebComponent deep-dive
2
-
3
- ## Property options in full detail
4
-
5
- | Option | Type | Default | Meaning |
6
- |---|---|---|---|
7
- | `type` | `Number\|String\|Boolean\|Object\|Array` | `String` | Used by the default attribute converter |
8
- | `reflect` | `boolean` | `false` | Property changes write back to the HTML attribute |
9
- | `state` | `boolean` | `false` | Internal-only. No attribute, not in `observedAttributes` |
10
- | `hasChanged` | `(newVal, oldVal) => boolean` | strict `!==` | Custom change detection |
11
- | `converter` | `{ fromAttribute?, toAttribute? }` | type-based | Custom attribute ↔ property serialization |
12
-
13
- Built-in constructors (`String`, `Number`, `Boolean`, `Array`, `Object`) feed
14
- the default attribute coercion. For anything the default can't parse correctly
15
- (Date, Map, Set, discriminated unions) supply a custom `converter`.
16
-
17
- ## Why `declare` is required in TypeScript
18
-
19
- The framework installs reactive getter/setter on `this` inside the
20
- constructor via `Object.defineProperty`. Without `declare`, TypeScript
21
- emits `student = undefined` after `super()`, which under modern class-
22
- field semantics uses `[[Define]]` to overwrite the accessor. Result:
23
- `this.student = …` no longer goes through the setter, no `requestUpdate`,
24
- no `hasChanged`, no reflect, and reactivity silently breaks.
25
-
26
- The `.d.ts` overlay shipped with the framework makes every other class
27
- member fully typed, so only the reactive properties need the `declare`
28
- line, and only in TypeScript files.
29
-
30
- ## Lifecycle hooks (lit-aligned)
31
-
32
- `WebComponent` ships lit's full reactive lifecycle. Every update cycle runs these hooks in order; each receives a `changedProperties` Map (`Map<string, oldValue>`, where keys are reactive-property names).
33
-
34
- | # | Hook | When |
35
- |---|---|---|
36
- | 1 | `shouldUpdate(changedProperties)` | Return `false` to skip the update. Default `true`. |
37
- | 2 | `willUpdate(changedProperties)` | Pre-render. Property assignments here fold into THIS cycle. |
38
- | 3 | controllers' `hostUpdate()` | Pre-render controller hook |
39
- | 4 | `update(changedProperties)` | Default calls `render()` + commits. Override to wrap or short-circuit (rare). |
40
- | 5 | controllers' `hostUpdated()` | Post-render controller hook |
41
- | 6 | `firstUpdated(changedProperties)` | Once, on the first render only |
42
- | 7 | `updated(changedProperties)` | Every render commit. Right place for ad-hoc post-render DOM work. |
43
- | 8 | `updateComplete` Promise resolves | `await el.updateComplete` to read post-render DOM in tests |
44
-
45
- Assignments during `willUpdate` fold into the current cycle (no new render scheduled); assignments during `updated` or `firstUpdated` queue a fresh cycle. The framework gates this via an internal flag, so authors don't manage it.
46
-
47
- The SSR pipeline runs the **pre-render value-deriving hooks** before `render()`: `willUpdate` (so derived state is in the first paint) and controllers' `hostUpdate`, then it reflects `reflect: true` properties to attributes. The rest stay **client-only** and SSR does not invoke them: `shouldUpdate`, the `update` DOM commit, `hostUpdated`, `updated`, `firstUpdated`, `connectedCallback`, `disconnectedCallback`. Set SSR-meaningful defaults in the constructor, derive SSR-visible state in `willUpdate`, and keep browser-only work (DOM queries, layout, localStorage, viewport) in `connectedCallback` / `firstUpdated`. A `Task` is the one controller whose `hostUpdate` does not act at SSR: it ships the `INITIAL` state and runs only on hydration, so no request fires server-side.
48
-
49
- For component-local state, create an instance signal in the constructor and call `signal.set(...)` to mutate. The built-in `SignalWatcher` re-runs `render()` on the next microtask; the same lifecycle hooks fire as for reactive-property changes.
50
-
51
- See [`/docs/lifecycle`](https://docs.webjs.com/docs/lifecycle) for per-hook usage examples.
52
-
53
- ## Display-only components are elided from the browser
54
-
55
- A component that does no client-side work renders the same SSR'd HTML
56
- whether or not its JavaScript ever reaches the browser. webjs detects
57
- these statically and strips their import from the served source, so the
58
- browser never downloads them (and their unique vendor dependencies drop
59
- from the importmap). This is automatic, with no opt-in keyword and no
60
- server/client split to reason about. A component stays elidable as long
61
- as it has none of the following.
62
-
63
- - An `@event` binding in a template (`@click=${...}`), or a native event-handler property (`.onclick=${...}`).
64
- - A reactive property in `static properties` that is not `{ state: true }`. Attribute-driven or `.prop`-driven values are the channel a parent uses to push client updates.
65
- - An overridden lifecycle hook (anything in the table above), as a method or an arrow class field.
66
- - A `signal` / `computed` / `watch` / `Task` / `ref` / `live` / streaming directive imported from `@webjsdev/core`, OR a transitive import of a module that reads shared module-scope signal state.
67
- - An `addController(...)` or `requestUpdate()` call.
68
- - Any code that runs at module load. A display-only module's top level may only *declare* things (imports, the `WebComponent` class, `const` / `let` / `var`, pure initializers like `css\`...\``) and *register* the component (`X.register(...)` / `customElements.define(...)`). Any other top-level call, `new`, dynamic `import(...)`, or top-level `await` is client work and ships (a top-level `fetch('/track')`, `new WebSocket(...)`, `setTimeout(...)`, `someInit()`). This is checked structurally as an allowlist of safe top-level forms, not a denylist of global names, so a brand-new browser API is caught automatically with no code change. Code inside a method, `render()`, or an uninvoked function does not count (it does not run at load), nor do these words in rendered template text or a `.fetch` / `.location` member access.
69
- - A rendered `<slot>`. Light-DOM slots rely on the client projection runtime, and proving a slot is purely native (shadow DOM) is beyond static analysis, so any `<slot>` ships.
70
- - Being rendered or imported by a component that itself ships (an interactive parent can re-create the child on the client).
71
-
72
- The analysis is deliberately conservative: anything it cannot prove
73
- inert ships normally, so correctness never depends on it. The elidable
74
- case in practice is a component with no inputs and no behavior: static
75
- markup, or values seeded in the constructor. Note a slotted wrapper does
76
- NOT qualify (the `<slot>` itself forces shipping per the list above).
77
-
78
- **The one boundary the static model cannot see.** Elision proves a
79
- component's own `render()` is inert; it does NOT prove that no *other*
80
- client code observes the element's registration. An elided module never
81
- loads, so its `customElements.define` never runs in the browser and the
82
- tag stays an un-upgraded `HTMLElement`. That is invisible for a tag that
83
- exists only as SSR'd markup, but it changes behavior if shipping client
84
- code depends on the definition:
85
-
86
- - `customElements.whenDefined('the-tag')` never resolves.
87
- - reading an upgraded property or method off `document.querySelector('the-tag')` is `undefined` / throws.
88
- - `el instanceof TheClass` is `false`.
89
- - a CSS `the-tag:defined { … }` rule never matches.
90
-
91
- **The three statically visible forms are now detected and force the
92
- observed component to ship**: a literal `whenDefined('the-tag')`, a CSS
93
- `the-tag:defined` selector, and `instanceof TheClass` (mapped back to the
94
- tag via the component's class name) anywhere in a graph-reachable module
95
- mark `the-tag`'s component as must-ship, so it is never elided. The bias
96
- stays conservative: detection only ever forces MORE components to ship.
97
-
98
- What remains an author-facing caveat is the part static analysis cannot
99
- see: a tag built from a dynamic string (`whenDefined(\`x-\${name}\`)`), or
100
- a `:defined` rule in an external stylesheet that is not part of the module
101
- graph. If you observe a component that way, add an interactivity signal
102
- (an `@event`, a non-`state` reactive property, or a lifecycle hook) so it
103
- ships. In idiomatic webjs this is rare: a display-only element is
104
- server-rendered to its final HTML and read as plain markup, and
105
- `:defined` FOUC-hiding works against progressive enhancement (it would
106
- hide content that already painted). But if you reach for those dynamic
107
- patterns, treat the component as interactive.
108
-
109
- The detection lists live in `packages/server/src/component-elision.js`
110
- and are the single source of truth. They are kept in lockstep with the
111
- lifecycle table above by `packages/server/test/elision/lifecycle-coverage.test.js`,
112
- which fails if a new `WebComponent` hook is added without teaching the
113
- analyser about it. If you add an interactivity feature to the framework,
114
- update that file.
115
-
116
- ### Turning elision off
117
-
118
- Elision is on by default. To disable it app-wide, set `elide` to `false`
119
- under the `webjs` key in `package.json`:
120
-
121
- ```jsonc
122
- { "webjs": { "elide": false } }
123
- ```
124
-
125
- With the switch off, every component and route module ships exactly as it
126
- did before the feature existed (no import stripping, no dropped preloads,
127
- the importmap keeps every vendor dep). The switch is pure opt-out, so any
128
- value other than the literal `false`, or an absent key, leaves elision on.
129
- Reach for it if the conservative analyser ever mis-elides a component, or
130
- to A/B the wire-byte difference. Because the analyser biases toward
131
- shipping, needing this should be rare.
132
-
133
- There is also a `WEBJS_ELIDE` environment override that wins over the
134
- `package.json` switch: `WEBJS_ELIDE=0` (also `false` / `off` / `no`) forces
135
- elision off, `WEBJS_ELIDE=1` (`true` / `on` / `yes`) forces it on, and any
136
- other value (or an unset variable) falls through to the `package.json`
137
- switch. It is the deploy-time escape hatch (rule elision out while
138
- debugging a suspected wrong-strip without editing committed code) and the
139
- seam the differential elision test uses to render the same app on and off
140
- in one process. Like the `package.json` switch, it is re-read on every
141
- rebuild.
142
-
143
- ### The differential guard: elision never changes observable output
144
-
145
- Elision's defining invariant is that removing the elided JS NEVER changes
146
- what the user sees or can do (the SSR'd HTML is the progressive-enhancement
147
- baseline; elision only drops JS that would have done nothing). Because the
148
- analyser is heuristic and its long tail of inputs (comments, dynamic tag
149
- strings, multi-line templates, vendor side-effects, future interactivity
150
- surfaces) is open-ended, that invariant is verified DIFFERENTIALLY rather
151
- than only by example: a test renders a corpus of routes with elision on and
152
- off and asserts the observable output is identical, both at the SSR layer
153
- (served HTML, modulo the boot script and modulepreload JS set) and in a
154
- real browser after hydration (DOM and key interactions). The conservative
155
- bias means a mistake almost always only over-ships (wastes bytes, ignored
156
- by the diff); the dangerous direction (a needed module wrongly dropped)
157
- changes post-hydration behaviour and fails the e2e diff loudly. This is the
158
- guard that lets per-component elision stay a safe default rather than a
159
- leap of faith, and it is what would have caught the comment-scanning (#179)
160
- and cross-module-observation (#169) bug classes instantly. The test lives
161
- at `packages/server/test/elision/differential-elision.test.js` (SSR layer)
162
- and the `differential elision` cases in `test/e2e/e2e.test.mjs` (browser
163
- layer).
164
-
165
- ## ReactiveControllers: composable lifecycle
166
-
167
- ```js
168
- class FetchController {
169
- constructor(host, url) {
170
- this.host = host;
171
- this.url = url;
172
- this.data = null;
173
- host.addController(this); // ← register
174
- }
175
- async hostConnected() {
176
- this.data = await (await fetch(this.url)).json();
177
- this.host.requestUpdate();
178
- }
179
- hostDisconnected() { /* cleanup */ }
180
- }
181
-
182
- class MyEl extends WebComponent {
183
- #users = new FetchController(this, '/api/users');
184
- render() { return html`${this.#users.data?.length} users`; }
185
- }
186
- ```
187
-
188
- Use controllers when the same lifecycle logic (fetch, timer, subscription,
189
- resize observer) is needed in multiple unrelated components. The built-in
190
- `Task`, `ContextProvider`, and `ContextConsumer` are all controllers.
191
-
192
- ## Light DOM (default) vs Shadow DOM (opt-in), full detail
193
-
194
- Light DOM is the default because global CSS and Tailwind utility classes
195
- apply directly, with no `::part`, no `:host`, no CSS-var plumbing, no
196
- `adoptedStyleSheets` needed. The browser renders a plain element with
197
- normal children, and hydration replaces SSR content in place.
198
-
199
- | Use case | Mode | How |
200
- |---|---|---|
201
- | Global / Tailwind CSS, simple composition | **Light DOM** (default) | Just use `class="..."` in your `html\`...\`` template |
202
- | Scoped styles via `static styles = css\`\`` | Shadow DOM | Set `static shadow = true`. `adoptedStyleSheets` + bare selectors are scoped |
203
- | `<slot>` content projection | **Both** | Same `<slot>` / `<slot name="x">` syntax. Light DOM uses framework projection; shadow DOM uses native browser projection. Full spec parity in both modes (see "Slots" section below). |
204
- | Third-party embeds needing isolation | Shadow DOM | CSS can't leak in or out |
205
-
206
- Both modes are fully SSR'd (shadow DOM via Declarative Shadow DOM, light
207
- DOM as direct HTML with a `<!--webjs-hydrate-->` marker) and hydrate
208
- without flash on the client.
209
-
210
- ### Class-prefix rule for light-DOM components
211
-
212
- If a light-DOM component authors its own custom CSS (a `<style>` block
213
- inside `render()`, or an imported stylesheet), every class selector MUST
214
- be prefixed with the component's tag name. Pick one of these two
215
- patterns per component:
216
-
217
- ```ts
218
- // Pattern A: BEM-ish class names prefixed with tag
219
- class MyCard extends WebComponent {
220
- render() {
221
- return html`
222
- <style>
223
- .my-card__body { padding: 16px; }
224
- .my-card__title { font-weight: 600; }
225
- </style>
226
- <div class="my-card__body">
227
- <h3 class="my-card__title"><slot name="title"></slot></h3>
228
- </div>
229
- `;
230
- }
231
- }
232
-
233
- // Pattern B: descendant selector rooted at the tag
234
- class MyCard extends WebComponent {
235
- render() {
236
- return html`
237
- <style>
238
- my-card .body { padding: 16px; }
239
- my-card .title { font-weight: 600; }
240
- </style>
241
- <div class="body">
242
- <h3 class="title"><slot name="title"></slot></h3>
243
- </div>
244
- `;
245
- }
246
- }
247
- ```
248
-
249
- Prefer Tailwind utility classes first. They're unique by construction.
250
- Drop down to custom CSS only when Tailwind can't express it.
251
-
252
- ### When to opt in to shadow DOM
253
-
254
- Set `static shadow = true` when:
255
- - You author styles via `static styles = css\`...\`` and want them
256
- `adoptedStyleSheets`-scoped without a prefix discipline.
257
- - You're publishing a component for third parties who won't have your
258
- Tailwind build, and you need the embed to look right in any host.
259
- - You want the browser's built-in `::slotted()` CSS selector for
260
- styling projected children from inside the shadow tree.
261
-
262
- Slots themselves are no longer a reason to opt into shadow DOM. The
263
- same `<slot>` / `<slot name="x">` syntax works in light DOM with full
264
- shadow-DOM spec parity (`assignedNodes`, `assignedElements`,
265
- `assignedSlot`, `slotchange`, named slots, fallback content, first-wins
266
- resolution). See the "Slots" section below.
267
-
268
- `static styles` on a light-DOM component is silently ignored.
269
-
270
- ## Slots: full shadow-DOM parity in both DOM modes
271
-
272
- webjs supports the entire shadow-DOM `<slot>` surface in light DOM. The
273
- same `render()` template projects children identically whether your
274
- component declares `static shadow = true` or leaves it at the default
275
- `false`. Migrating between modes never requires a template rewrite.
276
-
277
- ### Syntax
278
-
279
- ```ts
280
- class MyCard extends WebComponent {
281
- // static shadow defaults to false. Either value works for everything
282
- // below.
283
- render() {
284
- return html`
285
- <header><slot name="header"></slot></header>
286
- <main><slot></slot></main>
287
- <footer><slot name="footer">no actions</slot></footer>
288
- `;
289
- }
290
- }
291
- MyCard.register('my-card');
292
- ```
293
-
294
- Author markup:
295
-
296
- ```html
297
- <my-card>
298
- <h2 slot="header">Title</h2>
299
- <p>Body content</p>
300
- <p>More body content</p>
301
- <button slot="footer">Save</button>
302
- </my-card>
303
- ```
304
-
305
- The `<h2>` projects into the `header` slot, both `<p>` elements into the
306
- default slot in source order, and the `<button>` into the `footer` slot.
307
-
308
- ### Default slot
309
-
310
- A `<slot>` without a `name` attribute receives all authored children
311
- without a `slot=""` attribute. Text nodes, comments, and whitespace also
312
- route to the default slot.
313
-
314
- ```ts
315
- class Wrapper extends WebComponent {
316
- render() { return html`<div><slot></slot></div>`; }
317
- }
318
- ```
319
-
320
- ```html
321
- <wrapper>
322
- Plain text
323
- <p>An element</p>
324
- <!-- a comment -->
325
- </wrapper>
326
- ```
327
-
328
- ### Named slot
329
-
330
- `<slot name="x">` receives authored children with `slot="x"`. A child
331
- with `slot=""` (empty string) routes to the default slot, matching the
332
- shadow-DOM spec.
333
-
334
- ### Fallback content
335
-
336
- A slot's authored inner content is its fallback. If no children match
337
- the slot, the fallback renders.
338
-
339
- ```ts
340
- render() { return html`<slot name="actions">no actions</slot>`; }
341
- ```
342
-
343
- When no `slot="actions"` child is provided, the slot shows "no actions".
344
- When projection happens, the fallback is replaced by the projected
345
- content.
346
-
347
- ### First-wins resolution
348
-
349
- Multiple slots with the same `name` (or multiple default slots) are
350
- permitted. Per shadow-DOM spec, the first one in document order receives
351
- the assignment; subsequent same-named slots show their fallback content.
352
-
353
- ```ts
354
- render() {
355
- return html`
356
- <slot name="title">Untitled</slot>
357
- <slot name="title">never shown</slot>
358
- `;
359
- }
360
- ```
361
-
362
- ### Dynamic slot name and child slot attribute
363
-
364
- A slot's `name` attribute can be a template hole. Re-projection happens
365
- automatically when the value changes. Likewise, a child's `slot=""`
366
- attribute can change at runtime; the child re-routes to the new slot.
367
-
368
- ```ts
369
- render() {
370
- return html`<slot name=${this.section}></slot>`;
371
- }
372
- ```
373
-
374
- ### DOM API
375
-
376
- Every shadow-DOM slot API is mirrored on light-DOM slots:
377
-
378
- | API | Returns |
379
- |---|---|
380
- | `slot.assignedNodes(options?)` | Projected nodes in source order; empty array when slot shows fallback |
381
- | `slot.assignedNodes({ flatten: true })` | Recursively unwraps nested forwarding slots to the leaf nodes |
382
- | `slot.assignedElements(options?)` | Element-only filter of `assignedNodes` |
383
- | `element.assignedSlot` | Returns the slot a child is projected into, or `null` |
384
- | `slotchange` event | Fires on a slot when its assigned-node set actually changes (with equality detection to avoid no-op fires) |
385
-
386
- The polyfills are gated on a `data-webjs-light` attribute that the
387
- framework places on its slots, so the polyfill never interferes with
388
- real shadow-DOM slots elsewhere on the page.
389
-
390
- ### SSR + hydration
391
-
392
- Both modes are SSR'd:
393
-
394
- - **Light DOM.** The server emits projected children directly inside
395
- `<slot data-webjs-light data-projection="actual">` elements. Without
396
- JavaScript, the page renders correctly because the projection is
397
- baked into the HTML. On hydration the framework adopts the SSR-placed
398
- Node references; DOM identity (event listeners, focus, scroll, input
399
- values) survives the round-trip.
400
- - **Shadow DOM.** The server emits Declarative Shadow DOM
401
- (`<template shadowrootmode="open">…<slot>…</slot>…</template>`). The
402
- browser opens the shadow root on parse and projects natively, again
403
- without JavaScript.
404
-
405
- ### Compound components read their parent via `closest()` at SSR
406
-
407
- A compound component (a tabs trigger, a toggle-group item) typically
408
- derives its active/pressed state by walking to the parent and reading
409
- its value:
410
-
411
- ```ts
412
- get _tabs() { return this.closest('ui-tabs'); }
413
- render() {
414
- const active = this._tabs?.value === this.value;
415
- this.dataset.state = active ? 'active' : 'inactive';
416
- return html`<button data-state=${active ? 'active' : 'inactive'}><slot></slot></button>`;
417
- }
418
- ```
419
-
420
- This works in the **first server paint**, not only after hydration. The
421
- SSR walker threads the chain of enclosing custom-element instances into
422
- each instance, and the server element shim's `closest()` resolves a
423
- parent over that chain (so `this.closest('ui-tabs').value` reads the
424
- live parent property the walker already applied). Host IDL properties a
425
- `render()` mutates on `this` (`this.dataset.*`, `this.className`,
426
- `this.hidden`, `this.ariaPressed`, the rest of the `aria*` mixin)
427
- reflect to the matching attribute on the SSR'd host tag, so the active
428
- tab is marked before any JavaScript runs. The first client render
429
- produces the identical state (the browser's real `closest()` against the
430
- real DOM), so there is no hydration flash.
431
-
432
- Limits:
433
-
434
- - Only **tag-name selectors** resolve at SSR (`closest('ui-tabs')`). A
435
- class, attribute, or descendant selector returns null server-side and
436
- resolves on the client. That covers the compound-component pattern;
437
- anything finer is client-only.
438
- - The compound **parent** must be light DOM (the default, and what every
439
- kit Tier-2 component uses). A shadow-DOM parent projects its children
440
- through a native `<slot>`, and those slotted children are not threaded
441
- the SSR ancestor chain, so their `closest(parent)` resolves to null in
442
- the first server paint (it still resolves on the client after
443
- hydration). Keep compound parents light DOM for a correct first paint.
444
- - Genuine layout / live-DOM reads (`querySelector`, `classList`,
445
- `attachShadow`, geometry) still throw at SSR, so keep them in
446
- `connectedCallback` / `firstUpdated`.
447
-
448
- ### Slot inside conditionals and lists
449
-
450
- A slot can live inside any `html\`\`` template fragment: conditional
451
- ternaries, `${repeat()}` iterations, async `Task` results. When a slot
452
- disappears (e.g., its containing template collapses), the projected
453
- children move to a per-host pending map and re-attach with DOM identity
454
- preserved when the slot reappears.
455
-
456
- ```ts
457
- render() {
458
- return html`
459
- <div>
460
- ${this.expanded
461
- ? html`<section><slot></slot></section>`
462
- : html`<i>collapsed</i>`}
463
- </div>
464
- `;
465
- }
466
- ```
467
-
468
- Toggling `this.expanded` between true and false preserves the projected
469
- child Node references.
470
-
471
- ### Composition with Suspense
472
-
473
- A slot composes naturally with `Suspense`. Authored children that
474
- include `${Suspense({ fallback, children })}` project the fallback HTML
475
- into the slot at SSR time; when the children promise resolves and
476
- streams in, the `data-webjs-resolve` swap targets the
477
- `<webjs-boundary>` element which lives inside the slot, updating the
478
- slot's content in place.
479
-
480
- ## Helper methods
481
-
482
- | Method | Purpose |
483
- |---|---|
484
- | `signal.set(v)` (instance signal) | Component-local reactive state; auto-tracked by SignalWatcher |
485
- | `this.requestUpdate()` | Manually schedule a re-render (controllers) |
486
- | `this.shadowRoot.querySelector(sel)` | Query shadow DOM (native API) |