@webjsdev/mcp 0.1.6 → 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.
Files changed (33) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/resources/AGENTS.md +169 -77
  4. package/resources/SKILL.md +226 -0
  5. package/resources/references/auth-and-sessions.md +220 -0
  6. package/resources/references/built-ins.md +200 -0
  7. package/resources/references/client-router-and-streaming.md +204 -0
  8. package/resources/references/components.md +167 -0
  9. package/resources/references/data-and-actions.md +187 -0
  10. package/resources/references/muscle-memory-gotchas.md +170 -0
  11. package/resources/references/optimistic-ui.md +128 -0
  12. package/resources/references/routing-and-pages.md +158 -0
  13. package/resources/references/runtime.md +80 -0
  14. package/resources/references/service-worker.md +78 -0
  15. package/resources/references/styling.md +123 -0
  16. package/resources/references/testing.md +125 -0
  17. package/resources/references/typescript.md +148 -0
  18. package/src/mcp-docs.js +27 -14
  19. package/src/mcp-source.js +12 -2
  20. package/src/mcp.js +4 -3
  21. package/resources/agent-docs/advanced.md +0 -1251
  22. package/resources/agent-docs/built-ins.md +0 -379
  23. package/resources/agent-docs/components.md +0 -712
  24. package/resources/agent-docs/configuration.md +0 -284
  25. package/resources/agent-docs/framework-dev.md +0 -65
  26. package/resources/agent-docs/lit-muscle-memory-gotchas.md +0 -470
  27. package/resources/agent-docs/metadata.md +0 -334
  28. package/resources/agent-docs/recipes.md +0 -528
  29. package/resources/agent-docs/runtime.md +0 -65
  30. package/resources/agent-docs/service-worker.md +0 -100
  31. package/resources/agent-docs/styling.md +0 -279
  32. package/resources/agent-docs/testing.md +0 -438
  33. package/resources/agent-docs/typescript.md +0 -402
@@ -1,712 +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 initializer (e.g., `count = 0` or `student: Student = { ... }`): the framework installs reactive getter/setter on `this` inside the constructor via `Object.defineProperty`, and a class-field initializer compiles to an assignment after `super()` that uses `[[Define]]` to overwrite the accessor, silently breaking reactivity. 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.com/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
- lifecycle table above by `packages/server/test/elision/lifecycle-coverage.test.js`,
248
- which fails if a new `WebComponent` hook is added without teaching the
249
- analyser about it. If you add an interactivity feature to the framework,
250
- update that file.
251
-
252
- ## Pages and layouts: keep them carriers, out of the network tab
253
-
254
- The same elision applies to whole routes, and it is what keeps `page.ts`
255
- and `layout.ts` out of the browser's network tab. A page/layout NEVER
256
- hydrates: it loads in the browser only to register the components it
257
- imports (so their tags upgrade). When that registration is its sole
258
- browser-relevant job, the framework drops the page/layout module and the
259
- boot script imports those components directly. It is **import-only** (the
260
- page/layout is dropped but reaches shipping components, #605) or **inert**
261
- (it reaches none, so zero application JS, #179).
262
-
263
- A page/layout starts SHIPPING its own module the moment its closure does
264
- any OTHER client work. Because that is an elision verdict, not a behaviour
265
- change, it is invisible in `npm test` and easy to introduce by accident
266
- (a code sample mistaken for real code, #634; a util that touches a client
267
- global, #619). To keep a route's modules out of the network tab:
268
-
269
- - Do not give a page/layout its own module-scope client work: no top-level
270
- call, no `new SomethingNonData()`, no browser-global access
271
- (`window` / `document` / `customElements`) at module scope, no bare
272
- side-effect import of a non-component, and no
273
- `import '@webjsdev/core/client-router'` (routing is automatic, #620). A
274
- page-template `@event` or inline `<script>` is fine: it is SSR output,
275
- never module client work (the analyser scans route-module template
276
- content as inert, #623 / #634).
277
- - Do not import a client-effecting NON-component util into a page/layout
278
- (or into a component chain a page reaches). A helper that touches a
279
- client global or self-executes drags the whole page in. Put client-only
280
- behaviour inside a component; put server-only work in `.server.{js,ts}`
281
- (it never enters the client closure); and if a util MIXES a pure helper
282
- with client-global code (the `cn.ts` shape, #619), split the client part
283
- into its own module so the pure helper does not pin every importer.
284
-
285
- Self-check: `page.ts` / `layout.ts` should NOT appear in the network tab
286
- or the boot `<script type="module">`. If one does, something in its
287
- closure does client work and is not a component; that is the thing to move
288
- into a component or a `.server.{js,ts}` file.
289
-
290
- `webjs doctor` names this for you (#646). Its "Page/layout elision (carrier
291
- hygiene)" check runs the same elision verdict and, for every page/layout
292
- that ships whole, prints the FIRST client-effecting blocker by name, for
293
- example `app/page.ts ships whole. Its first client-effecting blocker is
294
- lib/track.ts, which references a browser global at module scope, runs code
295
- at module scope, or has a bare side-effect import and is not a component`.
296
- It names the first blocker only; if a module has several, it stays shipped
297
- until each is moved out. It is ADVISORY (a `warn`, never a hard fail): a page
298
- legitimately MAY ship, and the analyser is biased toward shipping by design,
299
- so this is a "you may not have intended this" hint. When the module's own
300
- code is the cause, it prints `ships whole because it <reason>`.
301
-
302
- ### Elision is what keeps a server import off a display-only page (and `webjs check` guards the seam)
303
-
304
- Elision is also why a page can call a server-only utility and stay
305
- browser-safe, and why the same code crashes once the page gains client
306
- work. A page that does `const s = await auth()` (where `auth` comes from
307
- a `lib/auth.server.ts` UTILITY, no `'use server'`) is fine while the page
308
- is display-only: the framework elides the page, strips the server import,
309
- and the browser never sees it. The moment the page also imports a
310
- component to register it (`import '../components/workspace.ts'`), enables
311
- the client router, or uses a reactive primitive, the page stops being
312
- display-only, must load in the browser to do that work, and drags the
313
- server import with it. In the browser that import is a throw-at-load
314
- stub, so the page crashes the instant its module loads. `webjs typecheck`
315
- and the rest of `webjs check` pass; only the running page fails. This was
316
- the single biggest source of extra AI iterations when porting a real app.
317
-
318
- `webjs check`'s `no-server-import-in-browser-module` rule catches it
319
- statically. It reuses the SAME elision verdict described above (over the
320
- module graph, scanned components, and route table), so it flags ONLY a
321
- module that genuinely ships: a display-only page the framework elides is
322
- never flagged, because its server import really is stripped. The fix it
323
- suggests is the three legitimate shapes: gate the route in
324
- `middleware.ts` (server-only, never shipped), call the server through a
325
- `'use server'` ACTION (its browser stub is a working RPC, so it is
326
- exempt), or register the component in a `layout.{ts,js}` so the page
327
- elides again. Server-to-server imports (`.server.ts` importing
328
- `.server.ts`) and `middleware.ts` / `route.ts` are never flagged.
329
-
330
- The rule covers every module the build ships, not just pages: a shipping
331
- component, and `error.{ts,js}` / `loading.{ts,js}` / `not-found.{ts,js}`
332
- modules, are checked too. Those three boundaries always ship and are
333
- never elided (only an elidable component import is ever stripped), so a
334
- personalized 404 that does `await auth()` is the same throw-at-load crash
335
- and is flagged. One known gap: a DYNAMIC `import('./x.server.ts')` is not
336
- caught, because the framework's import scanner tracks only static
337
- `import` / `export … from`, not the `import(` call form. That is
338
- consistent with the rest of the framework (a dynamic import is also not
339
- elided framework-wide, and its crash is deferred to call time, not module
340
- load), so the rule leaves it to the runtime.
341
-
342
- ### Turning elision off
343
-
344
- Elision is on by default. To disable it app-wide, set `elide` to `false`
345
- under the `webjs` key in `package.json`:
346
-
347
- ```jsonc
348
- { "webjs": { "elide": false } }
349
- ```
350
-
351
- With the switch off, every component and route module ships exactly as it
352
- did before the feature existed (no import stripping, no dropped preloads,
353
- the importmap keeps every vendor dep). The switch is pure opt-out, so any
354
- value other than the literal `false`, or an absent key, leaves elision on.
355
- Reach for it if the conservative analyser ever mis-elides a component, or
356
- to A/B the wire-byte difference. Because the analyser biases toward
357
- shipping, needing this should be rare.
358
-
359
- There is also a `WEBJS_ELIDE` environment override that wins over the
360
- `package.json` switch: `WEBJS_ELIDE=0` (also `false` / `off` / `no`) forces
361
- elision off, `WEBJS_ELIDE=1` (`true` / `on` / `yes`) forces it on, and any
362
- other value (or an unset variable) falls through to the `package.json`
363
- switch. It is the deploy-time escape hatch (rule elision out while
364
- debugging a suspected wrong-strip without editing committed code) and the
365
- seam the differential elision test uses to render the same app on and off
366
- in one process. Like the `package.json` switch, it is re-read on every
367
- rebuild.
368
-
369
- ### The differential guard: elision never changes observable output
370
-
371
- Elision's defining invariant is that removing the elided JS NEVER changes
372
- what the user sees or can do (the SSR'd HTML is the progressive-enhancement
373
- baseline; elision only drops JS that would have done nothing). Because the
374
- analyser is heuristic and its long tail of inputs (comments, dynamic tag
375
- strings, multi-line templates, vendor side-effects, future interactivity
376
- surfaces) is open-ended, that invariant is verified DIFFERENTIALLY rather
377
- than only by example: a test renders a corpus of routes with elision on and
378
- off and asserts the observable output is identical, both at the SSR layer
379
- (served HTML, modulo the boot script and modulepreload JS set) and in a
380
- real browser after hydration (DOM and key interactions). The conservative
381
- bias means a mistake almost always only over-ships (wastes bytes, ignored
382
- by the diff); the dangerous direction (a needed module wrongly dropped)
383
- changes post-hydration behaviour and fails the e2e diff loudly. This is the
384
- guard that lets per-component elision stay a safe default rather than a
385
- leap of faith, and it is what would have caught the comment-scanning (#179)
386
- and cross-module-observation (#169) bug classes instantly. The test lives
387
- at `packages/server/test/elision/differential-elision.test.js` (SSR layer)
388
- and the `differential elision` cases in `test/e2e/e2e.test.mjs` (browser
389
- layer).
390
-
391
- ## ReactiveControllers: composable lifecycle
392
-
393
- ```js
394
- class FetchController {
395
- constructor(host, url) {
396
- this.host = host;
397
- this.url = url;
398
- this.data = null;
399
- host.addController(this); // ← register
400
- }
401
- async hostConnected() {
402
- this.data = await (await fetch(this.url)).json();
403
- this.host.requestUpdate();
404
- }
405
- hostDisconnected() { /* cleanup */ }
406
- }
407
-
408
- class MyEl extends WebComponent {
409
- #users = new FetchController(this, '/api/users');
410
- render() { return html`${this.#users.data?.length} users`; }
411
- }
412
- ```
413
-
414
- Use controllers when the same lifecycle logic (fetch, timer, subscription,
415
- resize observer) is needed in multiple unrelated components. The built-in
416
- `Task`, `ContextProvider`, and `ContextConsumer` are all controllers.
417
-
418
- ## Light DOM (default) vs Shadow DOM (opt-in), full detail
419
-
420
- Light DOM is the default because global CSS and Tailwind utility classes
421
- apply directly, with no `::part`, no `:host`, no CSS-var plumbing, no
422
- `adoptedStyleSheets` needed. The browser renders a plain element with
423
- normal children, and hydration replaces SSR content in place.
424
-
425
- | Use case | Mode | How |
426
- |---|---|---|
427
- | Global / Tailwind CSS, simple composition | **Light DOM** (default) | Just use `class="..."` in your `html\`...\`` template |
428
- | Scoped styles via `static styles = css\`\`` | Shadow DOM | Set `static shadow = true`. `adoptedStyleSheets` + bare selectors are scoped |
429
- | `<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). |
430
- | Third-party embeds needing isolation | Shadow DOM | CSS can't leak in or out |
431
-
432
- Both modes are fully SSR'd (shadow DOM via Declarative Shadow DOM, light
433
- DOM as direct HTML with a `<!--webjs-hydrate-->` marker) and hydrate
434
- without flash on the client.
435
-
436
- ### Class-prefix rule for light-DOM components
437
-
438
- If a light-DOM component authors its own custom CSS (a `<style>` block
439
- inside `render()`, or an imported stylesheet), every class selector MUST
440
- be prefixed with the component's tag name. Pick one of these two
441
- patterns per component:
442
-
443
- ```ts
444
- // Pattern A: BEM-ish class names prefixed with tag
445
- class MyCard extends WebComponent {
446
- render() {
447
- return html`
448
- <style>
449
- .my-card__body { padding: 16px; }
450
- .my-card__title { font-weight: 600; }
451
- </style>
452
- <div class="my-card__body">
453
- <h3 class="my-card__title"><slot name="title"></slot></h3>
454
- </div>
455
- `;
456
- }
457
- }
458
-
459
- // Pattern B: descendant selector rooted at the tag
460
- class MyCard extends WebComponent {
461
- render() {
462
- return html`
463
- <style>
464
- my-card .body { padding: 16px; }
465
- my-card .title { font-weight: 600; }
466
- </style>
467
- <div class="body">
468
- <h3 class="title"><slot name="title"></slot></h3>
469
- </div>
470
- `;
471
- }
472
- }
473
- ```
474
-
475
- Prefer Tailwind utility classes first. They're unique by construction.
476
- Drop down to custom CSS only when Tailwind can't express it.
477
-
478
- ### When to opt in to shadow DOM
479
-
480
- Set `static shadow = true` when:
481
- - You author styles via `static styles = css\`...\`` and want them
482
- `adoptedStyleSheets`-scoped without a prefix discipline.
483
- - You're publishing a component for third parties who won't have your
484
- Tailwind build, and you need the embed to look right in any host.
485
- - You want the browser's built-in `::slotted()` CSS selector for
486
- styling projected children from inside the shadow tree.
487
-
488
- Slots themselves are no longer a reason to opt into shadow DOM. The
489
- same `<slot>` / `<slot name="x">` syntax works in light DOM with full
490
- shadow-DOM spec parity (`assignedNodes`, `assignedElements`,
491
- `assignedSlot`, `slotchange`, named slots, fallback content, first-wins
492
- resolution). See the "Slots" section below.
493
-
494
- `static styles` on a light-DOM component is silently ignored.
495
-
496
- ## Slots: full shadow-DOM parity in both DOM modes
497
-
498
- webjs supports the entire shadow-DOM `<slot>` surface in light DOM. The
499
- same `render()` template projects children identically whether your
500
- component declares `static shadow = true` or leaves it at the default
501
- `false`. Migrating between modes never requires a template rewrite.
502
-
503
- ### Syntax
504
-
505
- ```ts
506
- class MyCard extends WebComponent {
507
- // static shadow defaults to false. Either value works for everything
508
- // below.
509
- render() {
510
- return html`
511
- <header><slot name="header"></slot></header>
512
- <main><slot></slot></main>
513
- <footer><slot name="footer">no actions</slot></footer>
514
- `;
515
- }
516
- }
517
- MyCard.register('my-card');
518
- ```
519
-
520
- Author markup:
521
-
522
- ```html
523
- <my-card>
524
- <h2 slot="header">Title</h2>
525
- <p>Body content</p>
526
- <p>More body content</p>
527
- <button slot="footer">Save</button>
528
- </my-card>
529
- ```
530
-
531
- The `<h2>` projects into the `header` slot, both `<p>` elements into the
532
- default slot in source order, and the `<button>` into the `footer` slot.
533
-
534
- ### Default slot
535
-
536
- A `<slot>` without a `name` attribute receives all authored children
537
- without a `slot=""` attribute. Text nodes, comments, and whitespace also
538
- route to the default slot.
539
-
540
- ```ts
541
- class Wrapper extends WebComponent {
542
- render() { return html`<div><slot></slot></div>`; }
543
- }
544
- ```
545
-
546
- ```html
547
- <wrapper>
548
- Plain text
549
- <p>An element</p>
550
- <!-- a comment -->
551
- </wrapper>
552
- ```
553
-
554
- ### Named slot
555
-
556
- `<slot name="x">` receives authored children with `slot="x"`. A child
557
- with `slot=""` (empty string) routes to the default slot, matching the
558
- shadow-DOM spec.
559
-
560
- ### Fallback content
561
-
562
- A slot's authored inner content is its fallback. If no children match
563
- the slot, the fallback renders.
564
-
565
- ```ts
566
- render() { return html`<slot name="actions">no actions</slot>`; }
567
- ```
568
-
569
- When no `slot="actions"` child is provided, the slot shows "no actions".
570
- When projection happens, the fallback is replaced by the projected
571
- content.
572
-
573
- ### First-wins resolution
574
-
575
- Multiple slots with the same `name` (or multiple default slots) are
576
- permitted. Per shadow-DOM spec, the first one in document order receives
577
- the assignment; subsequent same-named slots show their fallback content.
578
-
579
- ```ts
580
- render() {
581
- return html`
582
- <slot name="title">Untitled</slot>
583
- <slot name="title">never shown</slot>
584
- `;
585
- }
586
- ```
587
-
588
- ### Dynamic slot name and child slot attribute
589
-
590
- A slot's `name` attribute can be a template hole. Re-projection happens
591
- automatically when the value changes. Likewise, a child's `slot=""`
592
- attribute can change at runtime; the child re-routes to the new slot.
593
-
594
- ```ts
595
- render() {
596
- return html`<slot name=${this.section}></slot>`;
597
- }
598
- ```
599
-
600
- ### DOM API
601
-
602
- Every shadow-DOM slot API is mirrored on light-DOM slots:
603
-
604
- | API | Returns |
605
- |---|---|
606
- | `slot.assignedNodes(options?)` | Projected nodes in source order; empty array when slot shows fallback |
607
- | `slot.assignedNodes({ flatten: true })` | Recursively unwraps nested forwarding slots to the leaf nodes |
608
- | `slot.assignedElements(options?)` | Element-only filter of `assignedNodes` |
609
- | `element.assignedSlot` | Returns the slot a child is projected into, or `null` |
610
- | `slotchange` event | Fires on a slot when its assigned-node set actually changes (with equality detection to avoid no-op fires) |
611
-
612
- The polyfills are gated on a `data-webjs-light` attribute that the
613
- framework places on its slots, so the polyfill never interferes with
614
- real shadow-DOM slots elsewhere on the page.
615
-
616
- ### SSR + hydration
617
-
618
- Both modes are SSR'd:
619
-
620
- - **Light DOM.** The server emits projected children directly inside
621
- `<slot data-webjs-light data-projection="actual">` elements. Without
622
- JavaScript, the page renders correctly because the projection is
623
- baked into the HTML. On hydration the framework adopts the SSR-placed
624
- Node references; DOM identity (event listeners, focus, scroll, input
625
- values) survives the round-trip.
626
- - **Shadow DOM.** The server emits Declarative Shadow DOM
627
- (`<template shadowrootmode="open">…<slot>…</slot>…</template>`). The
628
- browser opens the shadow root on parse and projects natively, again
629
- without JavaScript.
630
-
631
- ### Compound components read their parent via `closest()` at SSR
632
-
633
- A compound component (a tabs trigger, a toggle-group item) typically
634
- derives its active/pressed state by walking to the parent and reading
635
- its value:
636
-
637
- ```ts
638
- get _tabs() { return this.closest('ui-tabs'); }
639
- render() {
640
- const active = this._tabs?.value === this.value;
641
- this.dataset.state = active ? 'active' : 'inactive';
642
- return html`<button data-state=${active ? 'active' : 'inactive'}><slot></slot></button>`;
643
- }
644
- ```
645
-
646
- This works in the **first server paint**, not only after hydration. The
647
- SSR walker threads the chain of enclosing custom-element instances into
648
- each instance, and the server element shim's `closest()` resolves a
649
- parent over that chain (so `this.closest('ui-tabs').value` reads the
650
- live parent property the walker already applied). Host IDL properties a
651
- `render()` mutates on `this` (`this.dataset.*`, `this.className`,
652
- `this.hidden`, `this.ariaPressed`, the rest of the `aria*` mixin)
653
- reflect to the matching attribute on the SSR'd host tag, so the active
654
- tab is marked before any JavaScript runs. The first client render
655
- produces the identical state (the browser's real `closest()` against the
656
- real DOM), so there is no hydration flash.
657
-
658
- Limits:
659
-
660
- - Only **tag-name selectors** resolve at SSR (`closest('ui-tabs')`). A
661
- class, attribute, or descendant selector returns null server-side and
662
- resolves on the client. That covers the compound-component pattern;
663
- anything finer is client-only.
664
- - The compound **parent** must be light DOM (the default, and what every
665
- kit Tier-2 component uses). A shadow-DOM parent projects its children
666
- through a native `<slot>`, and those slotted children are not threaded
667
- the SSR ancestor chain, so their `closest(parent)` resolves to null in
668
- the first server paint (it still resolves on the client after
669
- hydration). Keep compound parents light DOM for a correct first paint.
670
- - Genuine layout / live-DOM reads (`querySelector`, `classList`,
671
- `attachShadow`, geometry) still throw at SSR, so keep them in
672
- `connectedCallback` / `firstUpdated`.
673
-
674
- ### Slot inside conditionals and lists
675
-
676
- A slot can live inside any `html\`\`` template fragment: conditional
677
- ternaries, `${repeat()}` iterations, async `Task` results. When a slot
678
- disappears (e.g., its containing template collapses), the projected
679
- children move to a per-host pending map and re-attach with DOM identity
680
- preserved when the slot reappears.
681
-
682
- ```ts
683
- render() {
684
- return html`
685
- <div>
686
- ${this.expanded
687
- ? html`<section><slot></slot></section>`
688
- : html`<i>collapsed</i>`}
689
- </div>
690
- `;
691
- }
692
- ```
693
-
694
- Toggling `this.expanded` between true and false preserves the projected
695
- child Node references.
696
-
697
- ### Composition with Suspense
698
-
699
- A slot composes naturally with `Suspense`. Authored children that
700
- include `${Suspense({ fallback, children })}` project the fallback HTML
701
- into the slot at SSR time; when the children promise resolves and
702
- streams in, the `data-webjs-resolve` swap targets the
703
- `<webjs-boundary>` element which lives inside the slot, updating the
704
- slot's content in place.
705
-
706
- ## Helper methods
707
-
708
- | Method | Purpose |
709
- |---|---|
710
- | `signal.set(v)` (instance signal) | Component-local reactive state; auto-tracked by SignalWatcher |
711
- | `this.requestUpdate()` | Manually schedule a re-render (controllers) |
712
- | `this.shadowRoot.querySelector(sel)` | Query shadow DOM (native API) |