@webjsdev/mcp 0.1.7 → 0.1.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +1 -1
- package/resources/AGENTS.md +83 -72
- package/resources/SKILL.md +226 -0
- package/resources/references/auth-and-sessions.md +220 -0
- package/resources/references/built-ins.md +200 -0
- package/resources/references/client-router-and-streaming.md +204 -0
- package/resources/references/components.md +167 -0
- package/resources/references/data-and-actions.md +187 -0
- package/resources/references/muscle-memory-gotchas.md +170 -0
- package/resources/references/optimistic-ui.md +128 -0
- package/resources/references/routing-and-pages.md +158 -0
- package/resources/references/runtime.md +80 -0
- package/resources/references/service-worker.md +78 -0
- package/resources/references/styling.md +123 -0
- package/resources/references/testing.md +125 -0
- package/resources/references/typescript.md +148 -0
- package/src/mcp-docs.js +27 -14
- package/src/mcp-source.js +1 -1
- package/src/mcp.js +4 -3
- package/resources/agent-docs/advanced.md +0 -1334
- package/resources/agent-docs/built-ins.md +0 -389
- package/resources/agent-docs/components-shadowing.md +0 -87
- package/resources/agent-docs/components.md +0 -734
- package/resources/agent-docs/configuration.md +0 -284
- package/resources/agent-docs/framework-dev.md +0 -73
- package/resources/agent-docs/lit-muscle-memory-gotchas.md +0 -470
- package/resources/agent-docs/metadata.md +0 -334
- package/resources/agent-docs/orm.md +0 -198
- package/resources/agent-docs/recipes.md +0 -664
- package/resources/agent-docs/runtime.md +0 -65
- package/resources/agent-docs/service-worker.md +0 -104
- package/resources/agent-docs/styling.md +0 -279
- package/resources/agent-docs/testing.md +0 -479
- package/resources/agent-docs/types-and-mutations.md +0 -171
- package/resources/agent-docs/typescript.md +0 -402
|
@@ -1,734 +0,0 @@
|
|
|
1
|
-
# WebComponent deep-dive
|
|
2
|
-
|
|
3
|
-
## Property options in full detail
|
|
4
|
-
|
|
5
|
-
| Option | Type | Default | Meaning |
|
|
6
|
-
|---|---|---|---|
|
|
7
|
-
These options are passed to the `prop()` helper inside the `WebComponent({ ... })` factory (e.g. `count: prop(Number, { reflect: true })`); the bare form `count: Number` is shorthand for `prop(Number)`.
|
|
8
|
-
|
|
9
|
-
| Option | Type | Default | Meaning |
|
|
10
|
-
|---|---|---|---|
|
|
11
|
-
| `type` | `Number\|String\|Boolean\|Object\|Array` | `String` | Used by the default attribute converter (the first `prop()` argument) |
|
|
12
|
-
| `reflect` | `boolean` | `false` | Property changes write back to the HTML attribute |
|
|
13
|
-
| `state` | `boolean` | `false` | Internal-only. No attribute, not in `observedAttributes` |
|
|
14
|
-
| `attribute` | `string` | derived from the prop name | The HTML attribute name the property rides |
|
|
15
|
-
| `default` | value or `() => value` | none | Declarative initial value (a function runs per instance for a fresh object / array) |
|
|
16
|
-
| `hasChanged` | `(newVal, oldVal) => boolean` | strict `!==` | Custom change detection |
|
|
17
|
-
| `converter` | `{ fromAttribute?, toAttribute? }` | type-based | Custom attribute ↔ property serialization |
|
|
18
|
-
|
|
19
|
-
Built-in constructors (`String`, `Number`, `Boolean`, `Array`, `Object`) feed
|
|
20
|
-
the default attribute coercion. For anything the default can't parse correctly
|
|
21
|
-
(Date, Map, Set, discriminated unions) supply a custom `converter`.
|
|
22
|
-
|
|
23
|
-
For an array-typed prop, pass `Array`, not `Object` (`items: prop<Tag[]>(Array)`).
|
|
24
|
-
The default converter handles both identically (each JSON-encodes the value), so
|
|
25
|
-
`Object` does not break anything, but `Array` states the prop's shape to the next
|
|
26
|
-
reader. The `array-prop-uses-array-type` `webjs check` rule flags an array-typed
|
|
27
|
-
generic (`T[]`, `readonly T[]`, `Array<T>`, `ReadonlyArray<T>`) declared with the
|
|
28
|
-
`Object` constructor.
|
|
29
|
-
|
|
30
|
-
## Declaring reactive properties: the base-class factory
|
|
31
|
-
|
|
32
|
-
Reactive properties are declared by passing the property shape into the **base-class factory** `WebComponent({ ... })`. The types flow automatically to `this.<prop>`, so there is no `static properties` block and no `declare` line. A direct `static properties` block throws at runtime (caught statically by the `no-static-properties` rule).
|
|
33
|
-
|
|
34
|
-
```ts
|
|
35
|
-
class Counter extends WebComponent({
|
|
36
|
-
count: Number
|
|
37
|
-
}) {
|
|
38
|
-
constructor() {
|
|
39
|
-
super();
|
|
40
|
-
this.count = 0; // fully typed, no declare needed
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
```
|
|
44
|
-
|
|
45
|
-
The bare form takes a type constructor (`count: Number`, `label: String`, `open: Boolean`). The `prop()` helper carries options and narrows the TS type:
|
|
46
|
-
|
|
47
|
-
```ts
|
|
48
|
-
class Dialog extends WebComponent({
|
|
49
|
-
open: prop(Boolean, { reflect: true }), // reflects to the `open` attribute
|
|
50
|
-
showClose: prop(Boolean, { attribute: 'show-close-button' }), // custom attribute name
|
|
51
|
-
variant: prop<'info' | 'danger'>(String, { reflect: true }), // narrowed union type
|
|
52
|
-
student: prop<Student>(Object), // narrowed object type
|
|
53
|
-
internal: prop({ state: true }), // internal state, no attribute, no type
|
|
54
|
-
}) {
|
|
55
|
-
constructor() {
|
|
56
|
-
super();
|
|
57
|
-
this.student = { name: '', email: '' };
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
```
|
|
61
|
-
|
|
62
|
-
Set defaults in the constructor after `super()`. A declarative `default` option also exists (lit-parity, a function default runs per instance for a fresh object / array), but the constructor is the recommended way. **Never** use a class-field declaration OR initializer (e.g., `count = 0`, `student: Student = { ... }`, or `todos!: Todo[]`): even a type-only declaration like `todos!: Todo[]` compiles under modern TS configurations (with `useDefineForClassFields: true`) to define a property on the instance after `super()`, which shadows and clobbers the prototype's reactive accessor, silently breaking reactivity. Only declare properties in the factory, and read/write them directly off `this`. The `reactive-props-no-class-field` rule catches this.
|
|
63
|
-
|
|
64
|
-
```ts
|
|
65
|
-
// set defaults in the constructor after super()
|
|
66
|
-
class Counter extends WebComponent({
|
|
67
|
-
count: prop(Number, { reflect: true }),
|
|
68
|
-
items: prop(Array),
|
|
69
|
-
}) {
|
|
70
|
-
constructor() {
|
|
71
|
-
super();
|
|
72
|
-
this.count = 0;
|
|
73
|
-
this.items = [];
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
```
|
|
77
|
-
|
|
78
|
-
## Lifecycle hooks (lit-aligned)
|
|
79
|
-
|
|
80
|
-
`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).
|
|
81
|
-
|
|
82
|
-
| # | Hook | When |
|
|
83
|
-
|---|---|---|
|
|
84
|
-
| 1 | `shouldUpdate(changedProperties)` | Return `false` to skip the update. Default `true`. |
|
|
85
|
-
| 2 | `willUpdate(changedProperties)` | Pre-render. Property assignments here fold into THIS cycle. |
|
|
86
|
-
| 3 | controllers' `hostUpdate()` | Pre-render controller hook |
|
|
87
|
-
| 4 | `update(changedProperties)` | Default calls `render()` + commits. Override to wrap or short-circuit (rare). |
|
|
88
|
-
| 5 | controllers' `hostUpdated()` | Post-render controller hook |
|
|
89
|
-
| 6 | `firstUpdated(changedProperties)` | Once, on the first render only |
|
|
90
|
-
| 7 | `updated(changedProperties)` | Every render commit. Right place for ad-hoc post-render DOM work. |
|
|
91
|
-
| 8 | `updateComplete` Promise resolves | `await el.updateComplete` to read post-render DOM in tests |
|
|
92
|
-
|
|
93
|
-
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.
|
|
94
|
-
|
|
95
|
-
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.
|
|
96
|
-
|
|
97
|
-
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.
|
|
98
|
-
|
|
99
|
-
See [`/docs/lifecycle`](https://docs.webjs.dev/docs/lifecycle) for per-hook usage examples.
|
|
100
|
-
|
|
101
|
-
## Async render: bare-await data fetch (#469)
|
|
102
|
-
|
|
103
|
-
A component can fetch its own server data into the first paint. `render()` may be `async`, so you write the natural line directly:
|
|
104
|
-
|
|
105
|
-
```ts
|
|
106
|
-
class UserProfile extends WebComponent({ uid: String }) {
|
|
107
|
-
async render() {
|
|
108
|
-
const u = await getUser(this.uid); // a 'use server' action: real fn at SSR, RPC stub on the client
|
|
109
|
-
return html`<h3>${u.name}</h3>`;
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
UserProfile.register('user-profile');
|
|
113
|
-
```
|
|
114
|
-
|
|
115
|
-
Writing `await` makes the function async by the JS rule, and every render path awaits a promise-returning `render()` automatically. There is no flag. Plain sync `render()` stays the zero-cost default.
|
|
116
|
-
|
|
117
|
-
**Three concerns, decoupled. Do not conflate them.**
|
|
118
|
-
|
|
119
|
-
1. **SSR always blocks by default.** The server awaits `async render()`, so the resolved DATA is baked into the first paint. There is no fallback on first paint, ever. JS-off reads the data (a progressive-enhancement UPGRADE over a client-fetched `Task`, which shows nothing without JS).
|
|
120
|
-
2. **The client re-fetch default is stale-while-revalidate.** When a prop / dependency change re-runs `async render()`, the previously rendered content stays until the new render resolves. No blank, no flash, no user code.
|
|
121
|
-
3. **`renderFallback()` is the OPTIONAL re-fetch loading UI.** Define it to OVERRIDE the stale-while-revalidate default with a loading state (skeleton / spinner) shown DURING a client re-fetch. It is shown ONLY on a re-fetch, NEVER on the first paint, and it does NOT create a server-streaming boundary. It is a prop-aware method (not a static field), so it can branch on the component's current state.
|
|
122
|
-
|
|
123
|
-
```ts
|
|
124
|
-
class UserActivity extends WebComponent({ uid: String }) {
|
|
125
|
-
renderFallback() { return html`<div class="skeleton h-24"></div>`; } // shown only while a re-fetch is in flight
|
|
126
|
-
async render() {
|
|
127
|
-
const items = await getActivity(this.uid);
|
|
128
|
-
return html`<ul>${items.map((i) => html`<li>${i.label}</li>`)}</ul>`;
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
```
|
|
132
|
-
|
|
133
|
-
**Errors are isolated per component by default, no user code.** A thrown `await getData()` (or any render throw) renders a component-scoped error state while its siblings render normally; it never bubbles to the route `error.js`. The default surfaces the tag and message in dev and renders a silent empty element in prod (no leak). Override `renderError(error)` only to customize the error UI:
|
|
134
|
-
|
|
135
|
-
```ts
|
|
136
|
-
class Report extends WebComponent {
|
|
137
|
-
async render() { return html`<pre>${await getReport()}</pre>`; }
|
|
138
|
-
renderError(error) { return html`<p class="error">Could not load the report.</p>`; } // optional
|
|
139
|
-
}
|
|
140
|
-
```
|
|
141
|
-
|
|
142
|
-
`Task` and a signal cannot replace this: a `Task` renders its pending state at SSR (it loses the first-paint data), and you cannot wrap a signal around your own `await` inside `render()`. So `async render()` plus `renderFallback()` is the only way to get SSR-first-paint data AND a custom re-fetch loading state.
|
|
143
|
-
|
|
144
|
-
**Decision rules (which tool to reach for):**
|
|
145
|
-
|
|
146
|
-
1. Server data knowable at request time: fetch it IN the component with `async render()`. Co-located, no prop-drilling, data in the first paint. The default, simplest case.
|
|
147
|
-
2. Client re-fetch where stale content would mislead: add `renderFallback()` for a loading state during the re-fetch.
|
|
148
|
-
3. Genuinely CLIENT-ONLY data (depends on a click, viewport, localStorage, or live updates, and does NOT need to be in the first paint): use `Task` / signals plus an RPC action.
|
|
149
|
-
4. Slow server data where blocking the first byte hurts: stream it. Wrap the component in `<webjs-suspense .fallback=${html\`…\`}>` to flush the fallback on the first byte and stream the data in (the only way to show a first-paint fallback; concurrent across boundaries; progressive on soft nav). Do it deliberately for slow regions, not by default.
|
|
150
|
-
|
|
151
|
-
**Anti-patterns (likely footguns):**
|
|
152
|
-
|
|
153
|
-
- Do NOT prop-drill server data through layers when the leaf component can fetch it itself.
|
|
154
|
-
- Do NOT put `await getData()` in a page / layout function if it can live in a component (page / layout fetches run sequentially, a route-level waterfall).
|
|
155
|
-
- Do NOT fetch in `connectedCallback` / `Task` for data that is knowable server-side (that yields a fallback-then-RPC, not first-paint data).
|
|
156
|
-
- Do NOT expect `renderFallback()` to affect the first paint (it is the CLIENT re-fetch loading state).
|
|
157
|
-
- Do NOT add `renderError()` on every component (isolation is automatic).
|
|
158
|
-
|
|
159
|
-
**How it works.** On the server the SSR walker already awaits a promise-returning `render()` and bakes the data in; a throw is caught per component and rendered as the error state. On the client, `update()` detects a thenable from `render()` and routes to a stale-while-revalidate commit: the current DOM stays until the promise resolves, a monotonic render token drops a superseded resolution (an out-of-order fetch never commits stale DOM), and a rejection routes to `renderError()`. `firstUpdated` / `updated` / `updateComplete` fire after the async commit lands. Only signal reads BEFORE the first `await` establish reactive dependencies.
|
|
160
|
-
|
|
161
|
-
**Elision (#474).** A **bare** `async render()` (no other client signal, light DOM) is **elided** like any display-only component: the SSR'd data is the complete first paint, so the framework drops the module and the redundant on-hydration re-fetch (a common content / docs leaf shape). It ships only when it ALSO carries an independent signal (an `@event`, a non-`state` reactive prop, a signal / reactive import, a lifecycle hook including `renderFallback()`, a `<slot>`, cross-module observation, or a transitively-reachable interactive child). Two carve-outs always ship. `static shadow = true` ships because Declarative Shadow DOM attaches only during HTML parsing, so a streamed (`<webjs-suspense>`) or soft-navigated shadow component arrives via a JS DOM insertion and needs its module to re-run `attachShadow`. `static refresh = true` is the explicit opt-in to keep the stale-while-revalidate on-load re-fetch that eliding drops (moot for request-stable data, the default; reach for it only when fresh-on-load matters). The only behaviour eliding removes is that on-load refresh; the first paint is byte-identical, verified by the differential-elision guard.
|
|
162
|
-
|
|
163
|
-
**SSR action seeding (#472).** For a SHIPPING async component (one that hydrates because it carries an interactivity signal), the on-hydration re-fetch is eliminated without eliding the module. Each `'use server'` action result invoked during a (non-streamed) SSR render is serialized into the page as one `<script type="application/json" id="__webjs-seeds">` block, keyed by the action's hash + function name + serialized args. The generated client RPC stub reads that seed on its FIRST call: a hit resolves synchronously (no network, no hydration flicker), a later refetch or arg-change misses (consume-once) and goes to RPC as normal. So `const u = await getUser(this.id)` runs once, server-side, and its result is reused on the client's first render. The capture is a transparent server-side facade over the `'use server'` module (a synchronous load hook wraps each export in a recording `Proxy`), so there is **no source transform and no build step**: the browser source tab and the on-disk files are byte-unchanged, and the RPC stub the browser fetches is the same shape as before (it just consults the seed first). The install mechanism is runtime-neutral (#529): Node uses `module.registerHooks`, Bun uses a `Bun.plugin` `onLoad` (Bun has no `module.registerHooks`), and both emit the identical seed, so a page seeds the same on either runtime. Fail-open by construction: a key miss is always a normal RPC, never wrong data. Default on; opt out with `"webjs": { "seed": false }` or `WEBJS_SEED=0` (then the client re-fetches on hydration, hidden by stale-while-revalidate as before). A soft navigation carries the seed too: the client router ingests the incoming page's payload (`applySwap` -> `scanSeeds`) before its components hydrate, so a navigated async component also skips the refetch. Streamed regions (`<webjs-suspense>`) are NOT seeded (their data resolves after the first flush), so a slow boundary keeps the stale-while-revalidate refetch.
|
|
164
|
-
|
|
165
|
-
**Interactivity during a COLD on-hydration re-fetch (#528).** Seeding matters for more than a wasted round trip. When a SHIPPING async component DOES re-fetch on hydration (seeding off via `WEBJS_SEED=0`, a `static refresh = true` leaf, or a seed miss), its FIRST client commit is deferred until that re-fetch resolves, and that commit is the step that binds the component's event listeners. So for the fetch's duration the SSR'd markup is visible but NOT yet interactive: an `@click` does nothing until the data lands (the window equals the action's latency, not a constant). Seeding erases this by resolving the first render synchronously, so the component hydrates and binds its listeners immediately, which is the default and works on both Node and Bun (#529, seeding is no longer Node-only). The component is fully reactive once that first commit lands: this is purely a cold-start window, NOT a lost signal subscription (a bump after the commit re-renders normally). To keep an async leaf interactive from the first paint regardless, give it a synchronous first render (read request-known data from a prop / the constructor) and move the async work to a signal-driven `Task` or an `@event` handler.
|
|
166
|
-
|
|
167
|
-
### Streaming a slow region with `<webjs-suspense>` (#471)
|
|
168
|
-
|
|
169
|
-
`async render()` BLOCKS the first byte by default (the SSR HTML waits for the data). For a SLOW region where that wait hurts time-to-first-byte, wrap it in `<webjs-suspense>` to stream it: the fallback flushes immediately, the resolved content streams in.
|
|
170
|
-
|
|
171
|
-
```ts
|
|
172
|
-
html`
|
|
173
|
-
<webjs-suspense .fallback=${html`<p>Loading section…</p>`}>
|
|
174
|
-
<user-profile uid="42"></user-profile>
|
|
175
|
-
<user-activity uid="42"></user-activity>
|
|
176
|
-
</webjs-suspense>
|
|
177
|
-
`;
|
|
178
|
-
```
|
|
179
|
-
|
|
180
|
-
- **The fallback is on the first byte, the content streams in.** This is the ONLY way a fallback appears on the first paint. It is a deliberate choice for slow data (fast TTFB, accepting a fallback-then-content swap), exactly like page-level `Suspense`. PE-critical content stays unwrapped (blocking) so it is in the first paint with no JS.
|
|
181
|
-
- **Grouping + override.** One boundary wraps several components under ONE fallback. The boundary `.fallback` wins over a contained component's `renderFallback()`.
|
|
182
|
-
- **Concurrent.** Multiple `<webjs-suspense>` boundaries on a page fetch their data in parallel (each is a non-blocking boundary resolved together), so they stream fast-before-slow with no server waterfall.
|
|
183
|
-
- **Error-isolated.** A throwing component inside a boundary renders its component-scoped error state (via `renderError()` or the default) while its siblings stream normally.
|
|
184
|
-
- **Progressive on soft navigation (#473).** A client-router navigation to a streamed page applies the shell (with fallbacks) immediately, advances the URL, then streams each boundary in, matching the initial-load experience instead of buffering the whole response.
|
|
185
|
-
- **`.fallback` is special-cased at SSR:** the renderer reads it inline as the placeholder, never through the `data-webjs-prop-*` path (a `TemplateResult` is not serializer-safe). It must be an UNQUOTED property hole (`.fallback=${...}`, invariant 4).
|
|
186
|
-
|
|
187
|
-
Decision: a bare `async render()` for request-time data that should be in the first paint (the default); `<webjs-suspense>` ONLY when the data is slow enough that blocking the first byte is the worse tradeoff.
|
|
188
|
-
|
|
189
|
-
## Display-only components are elided from the browser
|
|
190
|
-
|
|
191
|
-
A component that does no client-side work renders the same SSR'd HTML
|
|
192
|
-
whether or not its JavaScript ever reaches the browser. webjs detects
|
|
193
|
-
these statically and strips their import from the served source, so the
|
|
194
|
-
browser never downloads them (and their unique vendor dependencies drop
|
|
195
|
-
from the importmap). This is automatic, with no opt-in keyword and no
|
|
196
|
-
server/client split to reason about. A component stays elidable as long
|
|
197
|
-
as it has none of the following.
|
|
198
|
-
|
|
199
|
-
- An `@event` binding in a template (`@click=${...}`), or a native event-handler property (`.onclick=${...}`).
|
|
200
|
-
- A factory-declared reactive property (`WebComponent({ ... })`) that is not `{ state: true }`. Attribute-driven or `.prop`-driven values are the channel a parent uses to push client updates.
|
|
201
|
-
- An overridden lifecycle hook (anything in the table above), as a method or an arrow class field.
|
|
202
|
-
- 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.
|
|
203
|
-
- An `addController(...)` or `requestUpdate()` call.
|
|
204
|
-
- 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, a non-data `new`, dynamic `import(...)`, or top-level `await` is client work and ships (a top-level `fetch('/track')`, `new WebSocket(...)`, `setTimeout(...)`, `someInit()`). A module-scope pure-DATA constructor is the one exception (`const TAGS = new Set([...])`, a compiled `new RegExp(...)`, a parsed `new URL(...)`, a typed array): it produces inert data with no side effect, so it does not force shipping. 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.
|
|
205
|
-
- 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.
|
|
206
|
-
- Being rendered or imported by a component that itself ships (an interactive parent can re-create the child on the client).
|
|
207
|
-
|
|
208
|
-
The analysis is deliberately conservative: anything it cannot prove
|
|
209
|
-
inert ships normally, so correctness never depends on it. The elidable
|
|
210
|
-
case in practice is a component with no inputs and no behavior: static
|
|
211
|
-
markup, or values seeded in the constructor. Note a slotted wrapper does
|
|
212
|
-
NOT qualify (the `<slot>` itself forces shipping per the list above).
|
|
213
|
-
|
|
214
|
-
**The one boundary the static model cannot see.** Elision proves a
|
|
215
|
-
component's own `render()` is inert; it does NOT prove that no *other*
|
|
216
|
-
client code observes the element's registration. An elided module never
|
|
217
|
-
loads, so its `customElements.define` never runs in the browser and the
|
|
218
|
-
tag stays an un-upgraded `HTMLElement`. That is invisible for a tag that
|
|
219
|
-
exists only as SSR'd markup, but it changes behavior if shipping client
|
|
220
|
-
code depends on the definition:
|
|
221
|
-
|
|
222
|
-
- `customElements.whenDefined('the-tag')` never resolves.
|
|
223
|
-
- reading an upgraded property or method off `document.querySelector('the-tag')` is `undefined` / throws.
|
|
224
|
-
- `el instanceof TheClass` is `false`.
|
|
225
|
-
- a CSS `the-tag:defined { … }` rule never matches.
|
|
226
|
-
|
|
227
|
-
**The three statically visible forms are now detected and force the
|
|
228
|
-
observed component to ship**: a literal `whenDefined('the-tag')`, a CSS
|
|
229
|
-
`the-tag:defined` selector, and `instanceof TheClass` (mapped back to the
|
|
230
|
-
tag via the component's class name) anywhere in a graph-reachable module
|
|
231
|
-
mark `the-tag`'s component as must-ship, so it is never elided. The bias
|
|
232
|
-
stays conservative: detection only ever forces MORE components to ship.
|
|
233
|
-
|
|
234
|
-
What remains an author-facing caveat is the part static analysis cannot
|
|
235
|
-
see: a tag built from a dynamic string (`whenDefined(\`x-\${name}\`)`), or
|
|
236
|
-
a `:defined` rule in an external stylesheet that is not part of the module
|
|
237
|
-
graph. If you observe a component that way, add an interactivity signal
|
|
238
|
-
(an `@event`, a non-`state` reactive property, or a lifecycle hook) so it
|
|
239
|
-
ships. In idiomatic webjs this is rare: a display-only element is
|
|
240
|
-
server-rendered to its final HTML and read as plain markup, and
|
|
241
|
-
`:defined` FOUC-hiding works against progressive enhancement (it would
|
|
242
|
-
hide content that already painted). But if you reach for those dynamic
|
|
243
|
-
patterns, treat the component as interactive.
|
|
244
|
-
|
|
245
|
-
The detection lists live in `packages/server/src/component-elision.js`
|
|
246
|
-
and are the single source of truth. They are kept in lockstep with the
|
|
247
|
-
framework's interactivity surface by two drift guards, which fail the build
|
|
248
|
-
if a new interactivity feature ships without teaching the analyser about it
|
|
249
|
-
(a silent over-elision otherwise). `packages/server/test/elision/lifecycle-coverage.test.js`
|
|
250
|
-
introspects the live `WebComponent` prototype and covers hooks and methods.
|
|
251
|
-
`packages/server/test/elision/sigil-coverage.test.js` covers the two surfaces
|
|
252
|
-
that are not prototype members: template binding sigils and interactivity
|
|
253
|
-
static fields. The renderers' sigil set is single-sourced in core's
|
|
254
|
-
`BINDING_PREFIXES` (`packages/core/src/binding-prefixes.js`); the analyser
|
|
255
|
-
classifies each sigil as a client-behaviour ship signal (`SSR_DROPPED_PREFIXES`,
|
|
256
|
-
`@event`, drops at SSR) or an SSR-safe round-trip (`ROUND_TRIP_PREFIXES`,
|
|
257
|
-
`.prop` / `?bool`, survives into the served HTML). The guard asserts that
|
|
258
|
-
classification partitions `BINDING_PREFIXES` exactly, so a new sigil cannot be
|
|
259
|
-
added without a deliberate ship-or-round-trip decision. Interactivity static
|
|
260
|
-
fields live in the `INTERACTIVITY_STATIC_FIELDS` registry (`shadow`, `refresh`).
|
|
261
|
-
If you add an interactivity feature to the framework, update the matching list
|
|
262
|
-
in `component-elision.js` (and add a new static convention to both the
|
|
263
|
-
`INTERACTIVITY_STATIC_FIELDS` registry and this lifecycle table).
|
|
264
|
-
|
|
265
|
-
## Pages and layouts: keep them carriers, out of the network tab
|
|
266
|
-
|
|
267
|
-
The same elision applies to whole routes, and it is what keeps `page.ts`
|
|
268
|
-
and `layout.ts` out of the browser's network tab. A page/layout NEVER
|
|
269
|
-
hydrates: it loads in the browser only to register the components it
|
|
270
|
-
imports (so their tags upgrade). When that registration is its sole
|
|
271
|
-
browser-relevant job, the framework drops the page/layout module and the
|
|
272
|
-
boot script imports those components directly. It is **import-only** (the
|
|
273
|
-
page/layout is dropped but reaches shipping components, #605) or **inert**
|
|
274
|
-
(it reaches none, so zero application JS, #179).
|
|
275
|
-
|
|
276
|
-
A page/layout starts SHIPPING its own module the moment its closure does
|
|
277
|
-
any OTHER client work. Because that is an elision verdict, not a behaviour
|
|
278
|
-
change, it is invisible in `npm test` and easy to introduce by accident
|
|
279
|
-
(a code sample mistaken for real code, #634; a util that touches a client
|
|
280
|
-
global, #619). To keep a route's modules out of the network tab:
|
|
281
|
-
|
|
282
|
-
- Do not give a page/layout its own module-scope client work: no top-level
|
|
283
|
-
call, no `new SomethingNonData()`, no browser-global access
|
|
284
|
-
(`window` / `document` / `customElements`) at module scope, no bare
|
|
285
|
-
side-effect import of a non-component, and no
|
|
286
|
-
`import '@webjsdev/core/client-router'` (routing is automatic, #620). A
|
|
287
|
-
page-template `@event` or inline `<script>` is fine: it is SSR output,
|
|
288
|
-
never module client work (the analyser scans route-module template
|
|
289
|
-
content as inert, #623 / #634).
|
|
290
|
-
- Do not import a client-effecting NON-component util into a page/layout
|
|
291
|
-
(or into a component chain a page reaches). A helper that touches a
|
|
292
|
-
client global or self-executes drags the whole page in. Put client-only
|
|
293
|
-
behaviour inside a component; put server-only work in `.server.{js,ts}`
|
|
294
|
-
(it never enters the client closure); and if a util MIXES a pure helper
|
|
295
|
-
with client-global code (the `cn.ts` shape, #619), split the client part
|
|
296
|
-
into its own module so the pure helper does not pin every importer.
|
|
297
|
-
|
|
298
|
-
Self-check: `page.ts` / `layout.ts` should NOT appear in the network tab
|
|
299
|
-
or the boot `<script type="module">`. If one does, something in its
|
|
300
|
-
closure does client work and is not a component; that is the thing to move
|
|
301
|
-
into a component or a `.server.{js,ts}` file.
|
|
302
|
-
|
|
303
|
-
`webjs doctor` names this for you (#646). Its "Page/layout elision (carrier
|
|
304
|
-
hygiene)" check runs the same elision verdict and, for every page/layout
|
|
305
|
-
that ships whole, prints the FIRST client-effecting blocker by name, for
|
|
306
|
-
example `app/page.ts ships whole. Its first client-effecting blocker is
|
|
307
|
-
lib/track.ts, which references a browser global at module scope, runs code
|
|
308
|
-
at module scope, or has a bare side-effect import and is not a component`.
|
|
309
|
-
It names the first blocker only; if a module has several, it stays shipped
|
|
310
|
-
until each is moved out. It is ADVISORY (a `warn`, never a hard fail): a page
|
|
311
|
-
legitimately MAY ship, and the analyser is biased toward shipping by design,
|
|
312
|
-
so this is a "you may not have intended this" hint. When the module's own
|
|
313
|
-
code is the cause, it prints `ships whole because it <reason>`.
|
|
314
|
-
|
|
315
|
-
### Elision is what keeps a server import off a display-only page (and `webjs check` guards the seam)
|
|
316
|
-
|
|
317
|
-
Elision is also why a page can call a server-only utility and stay
|
|
318
|
-
browser-safe, and why the same code crashes once the page gains client
|
|
319
|
-
work. A page that does `const s = await auth()` (where `auth` comes from
|
|
320
|
-
a `lib/auth.server.ts` UTILITY, no `'use server'`) is fine while the page
|
|
321
|
-
is display-only: the framework elides the page, strips the server import,
|
|
322
|
-
and the browser never sees it. The moment the page also imports a
|
|
323
|
-
component to register it (`import '../components/workspace.ts'`), enables
|
|
324
|
-
the client router, or uses a reactive primitive, the page stops being
|
|
325
|
-
display-only, must load in the browser to do that work, and drags the
|
|
326
|
-
server import with it. In the browser that import is a throw-at-load
|
|
327
|
-
stub, so the page crashes the instant its module loads. `webjs typecheck`
|
|
328
|
-
and the rest of `webjs check` pass; only the running page fails. This was
|
|
329
|
-
the single biggest source of extra AI iterations when porting a real app.
|
|
330
|
-
|
|
331
|
-
`webjs check`'s `no-server-import-in-browser-module` rule catches it
|
|
332
|
-
statically. It reuses the SAME elision verdict described above (over the
|
|
333
|
-
module graph, scanned components, and route table), so it flags ONLY a
|
|
334
|
-
module that genuinely ships: a display-only page the framework elides is
|
|
335
|
-
never flagged, because its server import really is stripped. The fix it
|
|
336
|
-
suggests is the three legitimate shapes: gate the route in
|
|
337
|
-
`middleware.ts` (server-only, never shipped), call the server through a
|
|
338
|
-
`'use server'` ACTION (its browser stub is a working RPC, so it is
|
|
339
|
-
exempt), or register the component in a `layout.{ts,js}` so the page
|
|
340
|
-
elides again. Server-to-server imports (`.server.ts` importing
|
|
341
|
-
`.server.ts`) and `middleware.ts` / `route.ts` are never flagged. A
|
|
342
|
-
TYPE-ONLY `import type { Row } from './x.server.ts'` is exempt too,
|
|
343
|
-
because the stripper erases it before it reaches the browser, so sharing
|
|
344
|
-
a derived row type from a `.server.ts` is safe (a mixed
|
|
345
|
-
`import { type Row, value }` still ships the runtime `value` binding, so
|
|
346
|
-
it is still flagged).
|
|
347
|
-
|
|
348
|
-
The rule covers every module the build ships, not just pages: a shipping
|
|
349
|
-
component, and `error.{ts,js}` / `loading.{ts,js}` / `not-found.{ts,js}`
|
|
350
|
-
modules, are checked too. Those three boundaries always ship and are
|
|
351
|
-
never elided (only an elidable component import is ever stripped), so a
|
|
352
|
-
personalized 404 that does `await auth()` is the same throw-at-load crash
|
|
353
|
-
and is flagged. Scope note for dynamic imports: a string-literal
|
|
354
|
-
`import('./widget.ts')` IS tracked by the authorization gate (#751), so a
|
|
355
|
-
lazily-imported app module is servable instead of 404ing, but it is kept
|
|
356
|
-
out of elision and the modulepreload set (a dynamic import is lazy by
|
|
357
|
-
author intent, fetched at call time). The `no-server-import-in-browser-module`
|
|
358
|
-
rule still operates on STATIC edges only, so a dynamic
|
|
359
|
-
`import('./x.server.ts')` of a no-`'use server'` utility is not flagged at
|
|
360
|
-
check time, its throw-at-load is deferred to call time; and a computed
|
|
361
|
-
`import(expr)` cannot be resolved statically, so `webjs check` warns on it
|
|
362
|
-
in a shipping module (it would 404 if it targets an app module).
|
|
363
|
-
|
|
364
|
-
### Turning elision off
|
|
365
|
-
|
|
366
|
-
Elision is on by default. To disable it app-wide, set `elide` to `false`
|
|
367
|
-
under the `webjs` key in `package.json`:
|
|
368
|
-
|
|
369
|
-
```jsonc
|
|
370
|
-
{ "webjs": { "elide": false } }
|
|
371
|
-
```
|
|
372
|
-
|
|
373
|
-
With the switch off, every component and route module ships exactly as it
|
|
374
|
-
did before the feature existed (no import stripping, no dropped preloads,
|
|
375
|
-
the importmap keeps every vendor dep). The switch is pure opt-out, so any
|
|
376
|
-
value other than the literal `false`, or an absent key, leaves elision on.
|
|
377
|
-
Reach for it if the conservative analyser ever mis-elides a component, or
|
|
378
|
-
to A/B the wire-byte difference. Because the analyser biases toward
|
|
379
|
-
shipping, needing this should be rare.
|
|
380
|
-
|
|
381
|
-
There is also a `WEBJS_ELIDE` environment override that wins over the
|
|
382
|
-
`package.json` switch: `WEBJS_ELIDE=0` (also `false` / `off` / `no`) forces
|
|
383
|
-
elision off, `WEBJS_ELIDE=1` (`true` / `on` / `yes`) forces it on, and any
|
|
384
|
-
other value (or an unset variable) falls through to the `package.json`
|
|
385
|
-
switch. It is the deploy-time escape hatch (rule elision out while
|
|
386
|
-
debugging a suspected wrong-strip without editing committed code) and the
|
|
387
|
-
seam the differential elision test uses to render the same app on and off
|
|
388
|
-
in one process. Like the `package.json` switch, it is re-read on every
|
|
389
|
-
rebuild.
|
|
390
|
-
|
|
391
|
-
### The differential guard: elision never changes observable output
|
|
392
|
-
|
|
393
|
-
Elision's defining invariant is that removing the elided JS NEVER changes
|
|
394
|
-
what the user sees or can do (the SSR'd HTML is the progressive-enhancement
|
|
395
|
-
baseline; elision only drops JS that would have done nothing). Because the
|
|
396
|
-
analyser is heuristic and its long tail of inputs (comments, dynamic tag
|
|
397
|
-
strings, multi-line templates, vendor side-effects, future interactivity
|
|
398
|
-
surfaces) is open-ended, that invariant is verified DIFFERENTIALLY rather
|
|
399
|
-
than only by example: a test renders a corpus of routes with elision on and
|
|
400
|
-
off and asserts the observable output is identical, both at the SSR layer
|
|
401
|
-
(served HTML, modulo the boot script and modulepreload JS set) and in a
|
|
402
|
-
real browser after hydration (DOM and key interactions). The conservative
|
|
403
|
-
bias means a mistake almost always only over-ships (wastes bytes, ignored
|
|
404
|
-
by the diff); the dangerous direction (a needed module wrongly dropped)
|
|
405
|
-
changes post-hydration behaviour and fails the e2e diff loudly. This is the
|
|
406
|
-
guard that lets per-component elision stay a safe default rather than a
|
|
407
|
-
leap of faith, and it is what would have caught the comment-scanning (#179)
|
|
408
|
-
and cross-module-observation (#169) bug classes instantly. The test lives
|
|
409
|
-
at `packages/server/test/elision/differential-elision.test.js` (SSR layer)
|
|
410
|
-
and the `differential elision` cases in `test/e2e/e2e.test.mjs` (browser
|
|
411
|
-
layer).
|
|
412
|
-
|
|
413
|
-
## ReactiveControllers: composable lifecycle
|
|
414
|
-
|
|
415
|
-
```js
|
|
416
|
-
class FetchController {
|
|
417
|
-
constructor(host, url) {
|
|
418
|
-
this.host = host;
|
|
419
|
-
this.url = url;
|
|
420
|
-
this.data = null;
|
|
421
|
-
host.addController(this); // ← register
|
|
422
|
-
}
|
|
423
|
-
async hostConnected() {
|
|
424
|
-
this.data = await (await fetch(this.url)).json();
|
|
425
|
-
this.host.requestUpdate();
|
|
426
|
-
}
|
|
427
|
-
hostDisconnected() { /* cleanup */ }
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
class MyEl extends WebComponent {
|
|
431
|
-
#users = new FetchController(this, '/api/users');
|
|
432
|
-
render() { return html`${this.#users.data?.length} users`; }
|
|
433
|
-
}
|
|
434
|
-
```
|
|
435
|
-
|
|
436
|
-
Use controllers when the same lifecycle logic (fetch, timer, subscription,
|
|
437
|
-
resize observer) is needed in multiple unrelated components. The built-in
|
|
438
|
-
`Task`, `ContextProvider`, and `ContextConsumer` are all controllers.
|
|
439
|
-
|
|
440
|
-
## Light DOM (default) vs Shadow DOM (opt-in), full detail
|
|
441
|
-
|
|
442
|
-
Light DOM is the default because global CSS and Tailwind utility classes
|
|
443
|
-
apply directly, with no `::part`, no `:host`, no CSS-var plumbing, no
|
|
444
|
-
`adoptedStyleSheets` needed. The browser renders a plain element with
|
|
445
|
-
normal children, and hydration replaces SSR content in place.
|
|
446
|
-
|
|
447
|
-
| Use case | Mode | How |
|
|
448
|
-
|---|---|---|
|
|
449
|
-
| Global / Tailwind CSS, simple composition | **Light DOM** (default) | Just use `class="..."` in your `html\`...\`` template |
|
|
450
|
-
| Scoped styles via `static styles = css\`\`` | Shadow DOM | Set `static shadow = true`. `adoptedStyleSheets` + bare selectors are scoped |
|
|
451
|
-
| `<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). |
|
|
452
|
-
| Third-party embeds needing isolation | Shadow DOM | CSS can't leak in or out |
|
|
453
|
-
|
|
454
|
-
Both modes are fully SSR'd (shadow DOM via Declarative Shadow DOM, light
|
|
455
|
-
DOM as direct HTML with a `<!--webjs-hydrate-->` marker) and hydrate
|
|
456
|
-
without flash on the client.
|
|
457
|
-
|
|
458
|
-
### Class-prefix rule for light-DOM components
|
|
459
|
-
|
|
460
|
-
If a light-DOM component authors its own custom CSS (a `<style>` block
|
|
461
|
-
inside `render()`, or an imported stylesheet), every class selector MUST
|
|
462
|
-
be prefixed with the component's tag name. Pick one of these two
|
|
463
|
-
patterns per component:
|
|
464
|
-
|
|
465
|
-
```ts
|
|
466
|
-
// Pattern A: BEM-ish class names prefixed with tag
|
|
467
|
-
class MyCard extends WebComponent {
|
|
468
|
-
render() {
|
|
469
|
-
return html`
|
|
470
|
-
<style>
|
|
471
|
-
.my-card__body { padding: 16px; }
|
|
472
|
-
.my-card__title { font-weight: 600; }
|
|
473
|
-
</style>
|
|
474
|
-
<div class="my-card__body">
|
|
475
|
-
<h3 class="my-card__title"><slot name="title"></slot></h3>
|
|
476
|
-
</div>
|
|
477
|
-
`;
|
|
478
|
-
}
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
// Pattern B: descendant selector rooted at the tag
|
|
482
|
-
class MyCard extends WebComponent {
|
|
483
|
-
render() {
|
|
484
|
-
return html`
|
|
485
|
-
<style>
|
|
486
|
-
my-card .body { padding: 16px; }
|
|
487
|
-
my-card .title { font-weight: 600; }
|
|
488
|
-
</style>
|
|
489
|
-
<div class="body">
|
|
490
|
-
<h3 class="title"><slot name="title"></slot></h3>
|
|
491
|
-
</div>
|
|
492
|
-
`;
|
|
493
|
-
}
|
|
494
|
-
}
|
|
495
|
-
```
|
|
496
|
-
|
|
497
|
-
Prefer Tailwind utility classes first. They're unique by construction.
|
|
498
|
-
Drop down to custom CSS only when Tailwind can't express it.
|
|
499
|
-
|
|
500
|
-
### When to opt in to shadow DOM
|
|
501
|
-
|
|
502
|
-
Set `static shadow = true` when:
|
|
503
|
-
- You author styles via `static styles = css\`...\`` and want them
|
|
504
|
-
`adoptedStyleSheets`-scoped without a prefix discipline.
|
|
505
|
-
- You're publishing a component for third parties who won't have your
|
|
506
|
-
Tailwind build, and you need the embed to look right in any host.
|
|
507
|
-
- You want the browser's built-in `::slotted()` CSS selector for
|
|
508
|
-
styling projected children from inside the shadow tree.
|
|
509
|
-
|
|
510
|
-
Slots themselves are no longer a reason to opt into shadow DOM. The
|
|
511
|
-
same `<slot>` / `<slot name="x">` syntax works in light DOM with full
|
|
512
|
-
shadow-DOM spec parity (`assignedNodes`, `assignedElements`,
|
|
513
|
-
`assignedSlot`, `slotchange`, named slots, fallback content, first-wins
|
|
514
|
-
resolution). See the "Slots" section below.
|
|
515
|
-
|
|
516
|
-
`static styles` on a light-DOM component is silently ignored.
|
|
517
|
-
|
|
518
|
-
## Slots: full shadow-DOM parity in both DOM modes
|
|
519
|
-
|
|
520
|
-
webjs supports the entire shadow-DOM `<slot>` surface in light DOM. The
|
|
521
|
-
same `render()` template projects children identically whether your
|
|
522
|
-
component declares `static shadow = true` or leaves it at the default
|
|
523
|
-
`false`. Migrating between modes never requires a template rewrite.
|
|
524
|
-
|
|
525
|
-
### Syntax
|
|
526
|
-
|
|
527
|
-
```ts
|
|
528
|
-
class MyCard extends WebComponent {
|
|
529
|
-
// static shadow defaults to false. Either value works for everything
|
|
530
|
-
// below.
|
|
531
|
-
render() {
|
|
532
|
-
return html`
|
|
533
|
-
<header><slot name="header"></slot></header>
|
|
534
|
-
<main><slot></slot></main>
|
|
535
|
-
<footer><slot name="footer">no actions</slot></footer>
|
|
536
|
-
`;
|
|
537
|
-
}
|
|
538
|
-
}
|
|
539
|
-
MyCard.register('my-card');
|
|
540
|
-
```
|
|
541
|
-
|
|
542
|
-
Author markup:
|
|
543
|
-
|
|
544
|
-
```html
|
|
545
|
-
<my-card>
|
|
546
|
-
<h2 slot="header">Title</h2>
|
|
547
|
-
<p>Body content</p>
|
|
548
|
-
<p>More body content</p>
|
|
549
|
-
<button slot="footer">Save</button>
|
|
550
|
-
</my-card>
|
|
551
|
-
```
|
|
552
|
-
|
|
553
|
-
The `<h2>` projects into the `header` slot, both `<p>` elements into the
|
|
554
|
-
default slot in source order, and the `<button>` into the `footer` slot.
|
|
555
|
-
|
|
556
|
-
### Default slot
|
|
557
|
-
|
|
558
|
-
A `<slot>` without a `name` attribute receives all authored children
|
|
559
|
-
without a `slot=""` attribute. Text nodes, comments, and whitespace also
|
|
560
|
-
route to the default slot.
|
|
561
|
-
|
|
562
|
-
```ts
|
|
563
|
-
class Wrapper extends WebComponent {
|
|
564
|
-
render() { return html`<div><slot></slot></div>`; }
|
|
565
|
-
}
|
|
566
|
-
```
|
|
567
|
-
|
|
568
|
-
```html
|
|
569
|
-
<wrapper>
|
|
570
|
-
Plain text
|
|
571
|
-
<p>An element</p>
|
|
572
|
-
<!-- a comment -->
|
|
573
|
-
</wrapper>
|
|
574
|
-
```
|
|
575
|
-
|
|
576
|
-
### Named slot
|
|
577
|
-
|
|
578
|
-
`<slot name="x">` receives authored children with `slot="x"`. A child
|
|
579
|
-
with `slot=""` (empty string) routes to the default slot, matching the
|
|
580
|
-
shadow-DOM spec.
|
|
581
|
-
|
|
582
|
-
### Fallback content
|
|
583
|
-
|
|
584
|
-
A slot's authored inner content is its fallback. If no children match
|
|
585
|
-
the slot, the fallback renders.
|
|
586
|
-
|
|
587
|
-
```ts
|
|
588
|
-
render() { return html`<slot name="actions">no actions</slot>`; }
|
|
589
|
-
```
|
|
590
|
-
|
|
591
|
-
When no `slot="actions"` child is provided, the slot shows "no actions".
|
|
592
|
-
When projection happens, the fallback is replaced by the projected
|
|
593
|
-
content.
|
|
594
|
-
|
|
595
|
-
### First-wins resolution
|
|
596
|
-
|
|
597
|
-
Multiple slots with the same `name` (or multiple default slots) are
|
|
598
|
-
permitted. Per shadow-DOM spec, the first one in document order receives
|
|
599
|
-
the assignment; subsequent same-named slots show their fallback content.
|
|
600
|
-
|
|
601
|
-
```ts
|
|
602
|
-
render() {
|
|
603
|
-
return html`
|
|
604
|
-
<slot name="title">Untitled</slot>
|
|
605
|
-
<slot name="title">never shown</slot>
|
|
606
|
-
`;
|
|
607
|
-
}
|
|
608
|
-
```
|
|
609
|
-
|
|
610
|
-
### Dynamic slot name and child slot attribute
|
|
611
|
-
|
|
612
|
-
A slot's `name` attribute can be a template hole. Re-projection happens
|
|
613
|
-
automatically when the value changes. Likewise, a child's `slot=""`
|
|
614
|
-
attribute can change at runtime; the child re-routes to the new slot.
|
|
615
|
-
|
|
616
|
-
```ts
|
|
617
|
-
render() {
|
|
618
|
-
return html`<slot name=${this.section}></slot>`;
|
|
619
|
-
}
|
|
620
|
-
```
|
|
621
|
-
|
|
622
|
-
### DOM API
|
|
623
|
-
|
|
624
|
-
Every shadow-DOM slot API is mirrored on light-DOM slots:
|
|
625
|
-
|
|
626
|
-
| API | Returns |
|
|
627
|
-
|---|---|
|
|
628
|
-
| `slot.assignedNodes(options?)` | Projected nodes in source order; empty array when slot shows fallback |
|
|
629
|
-
| `slot.assignedNodes({ flatten: true })` | Recursively unwraps nested forwarding slots to the leaf nodes |
|
|
630
|
-
| `slot.assignedElements(options?)` | Element-only filter of `assignedNodes` |
|
|
631
|
-
| `element.assignedSlot` | Returns the slot a child is projected into, or `null` |
|
|
632
|
-
| `slotchange` event | Fires on a slot when its assigned-node set actually changes (with equality detection to avoid no-op fires) |
|
|
633
|
-
|
|
634
|
-
The polyfills are gated on a `data-webjs-light` attribute that the
|
|
635
|
-
framework places on its slots, so the polyfill never interferes with
|
|
636
|
-
real shadow-DOM slots elsewhere on the page.
|
|
637
|
-
|
|
638
|
-
### SSR + hydration
|
|
639
|
-
|
|
640
|
-
Both modes are SSR'd:
|
|
641
|
-
|
|
642
|
-
- **Light DOM.** The server emits projected children directly inside
|
|
643
|
-
`<slot data-webjs-light data-projection="actual">` elements. Without
|
|
644
|
-
JavaScript, the page renders correctly because the projection is
|
|
645
|
-
baked into the HTML. On hydration the framework adopts the SSR-placed
|
|
646
|
-
Node references; DOM identity (event listeners, focus, scroll, input
|
|
647
|
-
values) survives the round-trip.
|
|
648
|
-
- **Shadow DOM.** The server emits Declarative Shadow DOM
|
|
649
|
-
(`<template shadowrootmode="open">…<slot>…</slot>…</template>`). The
|
|
650
|
-
browser opens the shadow root on parse and projects natively, again
|
|
651
|
-
without JavaScript.
|
|
652
|
-
|
|
653
|
-
### Compound components read their parent via `closest()` at SSR
|
|
654
|
-
|
|
655
|
-
A compound component (a tabs trigger, a toggle-group item) typically
|
|
656
|
-
derives its active/pressed state by walking to the parent and reading
|
|
657
|
-
its value:
|
|
658
|
-
|
|
659
|
-
```ts
|
|
660
|
-
get _tabs() { return this.closest('ui-tabs'); }
|
|
661
|
-
render() {
|
|
662
|
-
const active = this._tabs?.value === this.value;
|
|
663
|
-
this.dataset.state = active ? 'active' : 'inactive';
|
|
664
|
-
return html`<button data-state=${active ? 'active' : 'inactive'}><slot></slot></button>`;
|
|
665
|
-
}
|
|
666
|
-
```
|
|
667
|
-
|
|
668
|
-
This works in the **first server paint**, not only after hydration. The
|
|
669
|
-
SSR walker threads the chain of enclosing custom-element instances into
|
|
670
|
-
each instance, and the server element shim's `closest()` resolves a
|
|
671
|
-
parent over that chain (so `this.closest('ui-tabs').value` reads the
|
|
672
|
-
live parent property the walker already applied). Host IDL properties a
|
|
673
|
-
`render()` mutates on `this` (`this.dataset.*`, `this.className`,
|
|
674
|
-
`this.hidden`, `this.ariaPressed`, the rest of the `aria*` mixin)
|
|
675
|
-
reflect to the matching attribute on the SSR'd host tag, so the active
|
|
676
|
-
tab is marked before any JavaScript runs. The first client render
|
|
677
|
-
produces the identical state (the browser's real `closest()` against the
|
|
678
|
-
real DOM), so there is no hydration flash.
|
|
679
|
-
|
|
680
|
-
Limits:
|
|
681
|
-
|
|
682
|
-
- Only **tag-name selectors** resolve at SSR (`closest('ui-tabs')`). A
|
|
683
|
-
class, attribute, or descendant selector returns null server-side and
|
|
684
|
-
resolves on the client. That covers the compound-component pattern;
|
|
685
|
-
anything finer is client-only.
|
|
686
|
-
- The compound **parent** must be light DOM (the default, and what every
|
|
687
|
-
kit Tier-2 component uses). A shadow-DOM parent projects its children
|
|
688
|
-
through a native `<slot>`, and those slotted children are not threaded
|
|
689
|
-
the SSR ancestor chain, so their `closest(parent)` resolves to null in
|
|
690
|
-
the first server paint (it still resolves on the client after
|
|
691
|
-
hydration). Keep compound parents light DOM for a correct first paint.
|
|
692
|
-
- Genuine layout / live-DOM reads (`querySelector`, `classList`,
|
|
693
|
-
`attachShadow`, geometry) still throw at SSR, so keep them in
|
|
694
|
-
`connectedCallback` / `firstUpdated`.
|
|
695
|
-
|
|
696
|
-
### Slot inside conditionals and lists
|
|
697
|
-
|
|
698
|
-
A slot can live inside any `html\`\`` template fragment: conditional
|
|
699
|
-
ternaries, `${repeat()}` iterations, async `Task` results. When a slot
|
|
700
|
-
disappears (e.g., its containing template collapses), the projected
|
|
701
|
-
children move to a per-host pending map and re-attach with DOM identity
|
|
702
|
-
preserved when the slot reappears.
|
|
703
|
-
|
|
704
|
-
```ts
|
|
705
|
-
render() {
|
|
706
|
-
return html`
|
|
707
|
-
<div>
|
|
708
|
-
${this.expanded
|
|
709
|
-
? html`<section><slot></slot></section>`
|
|
710
|
-
: html`<i>collapsed</i>`}
|
|
711
|
-
</div>
|
|
712
|
-
`;
|
|
713
|
-
}
|
|
714
|
-
```
|
|
715
|
-
|
|
716
|
-
Toggling `this.expanded` between true and false preserves the projected
|
|
717
|
-
child Node references.
|
|
718
|
-
|
|
719
|
-
### Composition with Suspense
|
|
720
|
-
|
|
721
|
-
A slot composes naturally with `Suspense`. Authored children that
|
|
722
|
-
include `${Suspense({ fallback, children })}` project the fallback HTML
|
|
723
|
-
into the slot at SSR time; when the children promise resolves and
|
|
724
|
-
streams in, the `data-webjs-resolve` swap targets the
|
|
725
|
-
`<webjs-boundary>` element which lives inside the slot, updating the
|
|
726
|
-
slot's content in place.
|
|
727
|
-
|
|
728
|
-
## Helper methods
|
|
729
|
-
|
|
730
|
-
| Method | Purpose |
|
|
731
|
-
|---|---|
|
|
732
|
-
| `signal.set(v)` (instance signal) | Component-local reactive state; auto-tracked by SignalWatcher |
|
|
733
|
-
| `this.requestUpdate()` | Manually schedule a re-render (controllers) |
|
|
734
|
-
| `this.shadowRoot.querySelector(sel)` | Query shadow DOM (native API) |
|