@webjsdev/cli 0.10.12 → 0.10.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,456 +0,0 @@
1
- # Lit muscle-memory gotchas
2
-
3
- AI agents trained on lit will reach for patterns that look correct but
4
- break webjs's SSR contract, reactivity model, or styling defaults. This
5
- file catalogs those failures with the webjs-shaped fix for each.
6
-
7
- The architectural disagreement underneath all of these. Lit is JS-first
8
- (hydration is the API). Webjs is HTML-first (first paint is real HTML,
9
- JS is opt-in per interactive behavior). Every gotcha below is
10
- downstream of that one disagreement.
11
-
12
- ## Mental model. Progressive enhancement, JS opt-in per behavior not per component
13
-
14
- Webjs is a progressive enhancement framework. Pages render as real HTML
15
- on the server, and every web component renders to real HTML on the
16
- server. With JavaScript disabled in the browser, the page is still
17
- readable, `<a>` links still navigate, and `<form action method>`
18
- submissions still hit server actions. Display-only custom elements
19
- still render their server-produced HTML.
20
-
21
- JavaScript is opt-in **per interactive behavior, not per component**.
22
- This is the distinction most lit-shaped intuitions miss.
23
-
24
- In lit and most modern frameworks, hydration is a per-component
25
- decision. You decide whether a given component is interactive (and
26
- therefore needs JS shipped and run) at the component boundary.
27
- "Hydrate this island, skip that one."
28
-
29
- In webjs, the granularity is different. Every component is server
30
- rendered. JavaScript is requested **by the specific interactive holes
31
- you write in the template**. A `@click=${...}` binding requests JS for
32
- click handling. A `signal.set(...)` call (instance or module-scope)
33
- requests JS for reactive updates. A property binding
34
- `.data=${richObject}` requests JS for property hydration. A controller
35
- like `Task` requests JS for that async behavior. A plain `<a href>`
36
- does not request JS. A `<form action="...">` does not request JS. A
37
- purely display-time component (no event listeners, no signal
38
- mutations, no property bindings to hydrate) does not request JS.
39
-
40
- A single component can mix both. A product card that shows
41
- server-rendered title, price, image, and a "View" link
42
- (no JS needed) plus an "Add to cart" button with a `@click`
43
- (JS needed for that one behavior). The framework loads JS for the
44
- component because of the `@click`, runs it, and the rest of the card
45
- stays exactly as the server painted it. You do not pick a hydration
46
- mode for the component. You write the markup, and the JS budget
47
- follows from which interactive behaviors that markup uses.
48
-
49
- Practical consequences for agents writing webjs code.
50
-
51
- 1. Never reach for `fetch()` plus a JS click handler when a `<form>`
52
- plus a server action would do. The form is free (no JS), the
53
- server action is typed and CSRF-protected, the result reaches the
54
- page through normal navigation.
55
- 2. Never make first paint depend on hydration. If the user sees a
56
- blank skeleton until JS runs, you wrote the feature wrong.
57
- 3. Never assume "this component needs JS" or "this component is
58
- static" as a binary. Pick interactive primitives per behavior. A
59
- shopping cart page can have ten components, eight of them adding
60
- zero JS bytes, two of them adding the handlers they need.
61
- 4. When choosing between a server action invoked from `<form>` and a
62
- client-side action invoked from `@click`, default to the form
63
- unless the interaction genuinely needs client-only state
64
- (optimistic UI, in-flight indicators tied to client state,
65
- keyboard shortcuts).
66
-
67
- ## Use lit idioms, not vanilla DOM (the whole point of lit-style components)
68
-
69
- webjs components are lit-shaped on purpose: the value is the declarative
70
- DX (typed reactive props, signals, `html` templates, declarative
71
- bindings), not raw DOM scripting. Reaching for vanilla web-component
72
- muscle memory (`this.getAttribute`, `this.setAttribute`, `this.classList`,
73
- `this.addEventListener`, `this.innerHTML`, `document.createElement`,
74
- manual `observedAttributes` / `attributeChangedCallback`, manual
75
- `customElements.define`) inside a component is the anti-pattern. Use the
76
- lit form unless the vanilla API is genuinely unavoidable.
77
-
78
- | Vanilla muscle memory | Lit-style webjs form |
79
- |---|---|
80
- | `this.getAttribute('x')` / `this.hasAttribute('x')` for own config | a reactive prop: `static properties = { x: {...} }` + `declare x`, read `this.x` (the prop rides the `x` attribute) |
81
- | `this.setAttribute('x', v)` / `removeAttribute` to reflect own state | a reactive prop with `reflect: true`, or for non-attribute state a `signal` |
82
- | `state: true` reactive prop for internal state | a `signal` (instance signal in the constructor, or module-scope) |
83
- | `this.classList.add/toggle(...)` on self | a `class=${...}` binding in `render()` |
84
- | `this.innerHTML = ...` / `appendChild` / `document.createElement` | return the markup from `render()` as `` html`...` `` |
85
- | `this.addEventListener('click', ...)` on own/child elements | a `@click=${...}` binding in the template |
86
- | `this.querySelector(...)` to reach own rendered DOM | the `ref()` directive + `createRef()`, or read a `<form>` with `new FormData(form)` |
87
- | manual `observedAttributes` + `attributeChangedCallback` | `static properties` (the framework derives both) |
88
- | manual `customElements.define('x', C)` | `C.register('x')` |
89
-
90
- Emitting an event with `this.dispatchEvent(new CustomEvent(...))` is the
91
- correct lit form, not a vanilla smell. Reading form values with
92
- `new FormData(e.currentTarget)` inside a `@submit` handler is also fine.
93
-
94
- **When vanilla DOM is genuinely needed (these stay):**
95
-
96
- - **Ancestor lookup in a compound component.** `this.closest('ui-tabs')`
97
- to read a parent's state. There is no declarative lit equivalent. This
98
- resolves at SSR too (tag-name selectors, against the SSR ancestor
99
- chain), so a compound child's active/pressed state is correct in the
100
- first server paint, not just after hydration. Host attributes the
101
- child sets in `render()` (`this.dataset.* =`, `this.className =`,
102
- `this.ariaPressed =`) reflect onto the SSR'd host tag. A class or
103
- attribute selector still resolves to null server-side.
104
- - **Slotted / projected content.** `this.querySelector(...)` reaching a
105
- `<slot>`-projected child or a sibling sub-component the template does
106
- not own. `ref()` only binds elements this component's own `render()`
107
- creates, so it cannot reach slotted content.
108
- - **Host attributes in light DOM.** A light-DOM `render()` template
109
- cannot bind attributes or listeners on the host element itself, so a
110
- component that must style or listen on its own host writes
111
- `this.dataset.* =` / `this.className =` / `this.addEventListener` on
112
- `this` in a lifecycle hook. Shadow-DOM components avoid this.
113
- - **Global listeners.** `document` / `window` `addEventListener` for
114
- click-away, global keys, resize, or reposition.
115
- - **Reading another element's attribute.** `contentHost.getAttribute('side')`
116
- reads a different element's config, not `this`.
117
- - **Browser-only globals.** `localStorage`, `matchMedia`, `navigator`,
118
- `document.documentElement` mutations (a theme toggle setting `<html>`),
119
- clipboard. These belong in `connectedCallback` or an event handler.
120
- - **Imperative focus.** `el.focus()` has no declarative form.
121
-
122
- The rule of thumb: if a reactive prop, a signal, an `html` binding, or
123
- `ref()` expresses it, use that. Reach for vanilla DOM only for the cases
124
- above, where the platform offers nothing declarative.
125
-
126
- This is a **convention, not a lint rule.** `webjs check` is reserved for
127
- general correctness (SSR safety, server-only imports, erasable TS, and
128
- so on), not for policing every vanilla call, which would be noisy and
129
- poor DX. Use your judgment: prefer the lit form by default, and when a
130
- vanilla API genuinely has no declarative equivalent (the cases above),
131
- just use it.
132
-
133
- ## The SSR contract: the pre-render lifecycle plus `render()`
134
-
135
- By design, the webjs SSR pipeline constructs the instance, applies
136
- attributes, runs the **pre-render value-deriving hooks** (`willUpdate`,
137
- then controllers' `hostUpdate`), reflects `reflect: true` properties,
138
- and calls `instance.render()`. Nothing past render fires server-side.
139
- Not `connectedCallback`, not `shouldUpdate`, not the `update` DOM
140
- commit, not `firstUpdated`, not `updated`, not controllers'
141
- `hostConnected` / `hostUpdated`. See
142
- `packages/core/src/render-server.js` around line 357.
143
-
144
- The mental model is one sentence. Code in the constructor, `willUpdate`,
145
- and `render()` must avoid the genuinely browser-only surface (`document`,
146
- `window`, `localStorage`, `navigator`, `querySelector`, layout reads),
147
- though the attribute, event, and `attachInternals` methods are backed by
148
- a server shim and are safe. Code in every other hook is client-only and
149
- can freely use any browser API without an `isServer` guard.
150
-
151
- The gotchas below are all violations of that rule.
152
-
153
- ## Patterns that produce visibly broken SSR
154
-
155
- ### 1. Fetching data in `connectedCallback` or `firstUpdated`
156
-
157
- The lit pattern is to subscribe or fetch on connect, then update
158
- state when the data arrives. In webjs the first paint is empty because
159
- neither hook runs server-side. Content pops in after hydration, often
160
- with a layout shift.
161
-
162
- Fix. Fetch in the page function and pass the data as props or
163
- attributes.
164
-
165
- ```ts
166
- // app/users/[id]/page.ts (correct)
167
- import { fetchUser } from '../../modules/users/queries/fetch-user.server.ts';
168
- export default async function User({ params }) {
169
- const user = await fetchUser(params.id);
170
- return html`<user-card .user=${user}></user-card>`;
171
- }
172
- ```
173
-
174
- ### 2. Using `Task` for initial-paint data
175
-
176
- Lit's canonical async pattern. The `Task` controller wires up a fetcher
177
- that runs on host update. Controllers' `hostUpdate` does fire at SSR, but
178
- `Task` deliberately does not auto-run server-side: it keeps its `INITIAL`
179
- state and runs only on hydration, so no request fires during SSR. The
180
- client then renders the resolved state, causing a flash.
181
-
182
- `Task` is still useful for client-time async (interaction-triggered
183
- mutations, polling, websocket reactions). For initial-paint data, fetch
184
- in the page function instead.
185
-
186
- ### 3. Browser-only APIs in the constructor or `render()`
187
-
188
- Calls like `window.matchMedia(...)`, `localStorage.getItem(...)`,
189
- `navigator.userAgent`, `document.querySelector(...)` in the constructor
190
- or render path crash SSR. The constructor is for pure-JS init
191
- (defaults, method binding, instance fields). Browser APIs belong in
192
- `connectedCallback` or later hooks (which are client-only by
193
- construction).
194
-
195
- ```ts
196
- // wrong
197
- constructor() {
198
- super();
199
- this.dark = window.matchMedia('(prefers-color-scheme: dark)').matches;
200
- }
201
-
202
- // right
203
- constructor() {
204
- super();
205
- this.dark = false;
206
- }
207
- connectedCallback() {
208
- super.connectedCallback();
209
- this.dark = window.matchMedia('(prefers-color-scheme: dark)').matches;
210
- }
211
- ```
212
-
213
- This applies only to the genuinely browser-only HTMLElement members on
214
- `this` (`this.classList`, `this.querySelector(...)`,
215
- `this.attachShadow(...)`, `this.getBoundingClientRect(...)`, `this.focus()`):
216
- the SSR-time instance has no DOM, so they throw. The attribute methods
217
- (`this.getAttribute` / `setAttribute` / `hasAttribute` / `toggleAttribute`),
218
- the event methods (`addEventListener` / `removeEventListener` /
219
- `dispatchEvent`), and `this.attachInternals()` ARE backed by a server shim,
220
- so reading an attribute in `render()`, wiring a delegated listener in the
221
- constructor, or reflecting a property during the SSR update cycle all work.
222
- Reading attributes that drive render through a reactive property
223
- (`static properties` + `declare`) is still the idiomatic path, but
224
- `this.hasAttribute(...)` no longer crashes.
225
-
226
- Two guards catch the browser-only cases. `webjs check` flags browser
227
- globals and the still-unsupported HTMLElement members used in a constructor
228
- or render body (the `no-browser-globals-in-render` rule). And if one slips
229
- through, the SSR crash is actionable: the log names the offending member and
230
- tells you to move it to `connectedCallback` or a lifecycle hook, instead of
231
- a raw `document is not defined`.
232
-
233
- ### 4. Top-level imports of browser-only libraries
234
-
235
- `import * as d3 from 'd3'`, `import Chart from 'chart.js'`, or any
236
- library that touches `window` at import time. The page module loads on
237
- the server during SSR, so the offending top-level access crashes.
238
-
239
- Two fixes. Use a dynamic `import()` inside `connectedCallback` for
240
- client-only behavior. Or wrap server-side work in a `.server.ts` file
241
- if the library has both server and client uses.
242
-
243
- ```ts
244
- connectedCallback() {
245
- super.connectedCallback();
246
- import('chart.js').then(({ Chart }) => {
247
- this.chart = new Chart(this.canvas, this.config);
248
- });
249
- }
250
- ```
251
-
252
- ## Patterns that compile but silently break reactivity
253
-
254
- ### 5. Class-field initializers for reactive properties
255
-
256
- This looks fine in TypeScript. It silently breaks the framework's
257
- accessor.
258
-
259
- ```ts
260
- // wrong (the initializer overwrites the framework accessor after super())
261
- class StudentCard extends WebComponent {
262
- static properties = { student: { type: Object } };
263
- student: Student = { name: '', email: '' };
264
- }
265
-
266
- // right
267
- class StudentCard extends WebComponent {
268
- static properties = { student: { type: Object } };
269
- declare student: Student;
270
- constructor() {
271
- super();
272
- this.student = { name: '', email: '' };
273
- }
274
- }
275
- ```
276
-
277
- `webjs check` flags this via the `reactive-props-use-declare` rule, but
278
- AI agents emit the broken form on autopilot. The convention check is
279
- the safety net, not the primary defense. Authoring code should use
280
- `declare` plus constructor defaults from the start.
281
-
282
- ### 6. The `@property()` decorator
283
-
284
- Banned by framework invariant 10 (erasable TS). The replacement is
285
- `static properties = { ... }` plus a matching `declare` for the typed
286
- accessor, as shown above. Decorators are non-erasable, so they would
287
- force the framework to depend on a build step.
288
-
289
- ## Patterns that produce different visual output
290
-
291
- ### 7. Expecting shadow DOM by default (and reaching for scoped CSS instead of Tailwind)
292
-
293
- Lit components default to shadow DOM. `static styles = css` scoping
294
- works automatically. Webjs defaults to light DOM. A `static styles`
295
- block without `static shadow = true` does nothing useful (the framework
296
- warns at runtime), and styles authored for the component bleed into
297
- the global namespace.
298
-
299
- This is also the **styling reflex** to unlearn, not just a config
300
- default. Because lit scopes, the lit habit is to author scoped CSS
301
- (`static styles = css\`\``) or an inline `<style>` with semantic class
302
- names (`.hero`, `.feature`, `.card`) for every component. In a webjs
303
- light-DOM component that CSS either does nothing (the scoped block) or
304
- leaks globally (the inline `<style>` with bare class names). **The
305
- webjs-shaped fix is Tailwind utilities, which apply directly in light
306
- DOM and are webjs's strong styling default.** Reach for raw CSS only for
307
- the short allowlist (design tokens, `@property` + `@keyframes`,
308
- `::-webkit-scrollbar`, `prefers-reduced-motion`, complex `color-mix()` /
309
- gradients); see `agent-docs/styling.md` for the full Tailwind-first rule
310
- and that allowlist.
311
-
312
- Three correct paths. Use Tailwind utilities in light DOM (the default,
313
- and the answer for the vast majority of components). Or add
314
- `static shadow = true` to the class and keep `static styles` (scoped CSS
315
- genuinely belongs in a shadow root). Or, if authoring vanilla CSS in
316
- light-DOM mode anyway, prefix every selector with the component tag, per
317
- the styling invariant. When a utility bundle repeats across light-DOM
318
- components, extract it into a `lib/utils/ui.ts` helper returning an
319
- `` html`...` `` fragment, never a shared CSS class.
320
-
321
- ### 8. `<slot>` timing differs across DOM modes
322
-
323
- Both modes accept `<slot>` syntax in templates and provide
324
- `assignedNodes`, `assignedElements`, `slotchange`, named slots,
325
- fallback content, and first-wins resolution. The public surface
326
- aligns.
327
-
328
- What differs is timing. In shadow DOM, slot projection is a browser
329
- primitive that fires synchronously on parse. In light DOM, projection
330
- is framework-driven (`packages/core/src/slot.js`) and observes
331
- mutations via `MutationObserver`. Code that reads
332
- `slot.assignedNodes()` synchronously in `connectedCallback` may see an
333
- empty list in light DOM and a populated list in shadow DOM. Use
334
- `slotchange` to react instead of reading synchronously.
335
-
336
- ## Lifecycle subtleties at SSR
337
-
338
- ### 9. `willUpdate` computing state for SSR (this now works)
339
-
340
- This used to be a gotcha. The SSR pipeline now runs `willUpdate` (and
341
- controllers' `hostUpdate`) before `render()`, so deriving render state
342
- there is correct in the first paint:
343
-
344
- ```ts
345
- willUpdate(changedProperties) {
346
- this.fullName = `${this.first} ${this.last}`;
347
- }
348
- render() {
349
- return html`<p>${this.fullName}</p>`;
350
- }
351
- ```
352
-
353
- The SSR HTML now shows `<p>Ada Lovelace</p>`. The value must still be a
354
- pure function of constructor state plus applied attributes (no
355
- browser-only APIs), since SSR has no DOM. What still does NOT run
356
- server-side is the post-render and connection hooks (`update` commit,
357
- `firstUpdated`, `updated`, `connectedCallback`, controllers'
358
- `hostConnected` / `hostUpdated`), so state those compute is absent from
359
- the first paint.
360
-
361
- One tradeoff to know: overriding `willUpdate` is an interactivity
362
- signal for the elision analyser, so a component that uses it (even
363
- purely to derive SSR state) ships its JS to the browser and is never
364
- elided. For a truly display-only component, prefer computing the value
365
- inline in `render()` so the module can still be elided; reach for
366
- `willUpdate` when the component is interactive anyway, or when the
367
- derivation is shared across `render()` and a client hook.
368
-
369
- ### 10. `ContextProvider` for server-known data
370
-
371
- Context providers in lit publish on connect via `hostConnected`. In
372
- webjs SSR, `connectedCallback` does not run, so descendants that read
373
- context during SSR see the default value (or undefined). On hydration
374
- the provider connects and consumers re-render, causing a content
375
- shift.
376
-
377
- Rule of thumb. For data known on the server (session, user, theme,
378
- locale, feature flags, A/B variants), pass it through props from the
379
- page function rather than through context. Reserve `ContextProvider`
380
- for client-time concerns (interaction state, focus management,
381
- transient UI state).
382
-
383
- ## List rendering
384
-
385
- ### 11. Reordering a `.map()` list needs a keyed `repeat()`
386
-
387
- A plain `.map()` list reconciles in place, matching lit-html's non-keyed
388
- child-part behaviour. When one item's binding changes (a card flips its
389
- `dragging` class on `@dragstart`, a row's input is edited), the framework
390
- patches that item's existing nodes instead of rebuilding the whole list,
391
- so DOM node identity survives. That is what makes native drag-and-drop,
392
- focus, caret, text selection, scroll position, and uncontrolled input
393
- value all survive an item-level update, no `repeat()` required. (This
394
- used to be a real gotcha. Before the fix, any change to a `.map()`'s
395
- output tore down and replaced every node, which silently aborted a
396
- drag-in-progress and lost focus and input state.)
397
-
398
- ```ts
399
- // Item updates preserve node identity. Drag-and-drop, focus, and
400
- // input state all survive. No repeat() needed.
401
- render() {
402
- return html`<ul>${this.cards.map((c) => html`
403
- <li class=${c.id === this.draggingId ? 'dragging' : 'idle'}
404
- draggable="true"
405
- @dragstart=${() => (this.draggingId = c.id)}>${c.text}</li>`)}</ul>`;
406
- }
407
- ```
408
-
409
- What plain `.map()` still does NOT do is **keyed reordering**.
410
- Reconciliation is positional (by index): if the array is reordered or an
411
- item is inserted/removed in the MIDDLE, index *i* is patched from the new
412
- item at *i*, so the nodes stay put and their contents are rewritten
413
- rather than the nodes themselves moving. For an item carrying live state
414
- (a focused input, a playing media element, an in-flight CSS transition)
415
- across a reorder, that state stays with the old position. When a list
416
- **reorders** or splices in the middle and node identity must follow the
417
- item, reach for the keyed directive, exactly as in lit:
418
-
419
- ```ts
420
- import { repeat } from '@webjsdev/core/directives';
421
- render() {
422
- return html`<ul>${repeat(this.cards, (c) => c.id, (c) => html`
423
- <li>${c.text}</li>`)}</ul>`;
424
- }
425
- ```
426
-
427
- Rule of thumb. Append-only or update-in-place list, where items keep
428
- their position, plain `.map()` is fine and preserves identity. List that
429
- **reorders or splices in the middle** and each item owns DOM state that
430
- must move with it, use `repeat()` with a stable key.
431
-
432
- ## Quick reference
433
-
434
- | Lit pattern | Webjs equivalent |
435
- |---|---|
436
- | Fetch in `connectedCallback` or `firstUpdated` | Fetch in the page function, pass as props |
437
- | `Task` for initial-paint data | Page function fetch and pass as props |
438
- | `Task` for client-time async | `Task` (no change, that's its job) |
439
- | `window.X` or `document.X` in constructor or `render()` | Move to `connectedCallback` |
440
- | Top-level `import` of browser-only library | Dynamic `import()` inside `connectedCallback` |
441
- | `student: Student = { ... }` field initializer | `declare student: Student` plus constructor default |
442
- | `@property()` decorator | `static properties = { ... }` plus `declare` |
443
- | `static styles = css` / inline `<style>` with semantic class names in a light-DOM component | Tailwind utilities (the default); or `static shadow = true` for genuinely scoped CSS |
444
- | Plain `.map()` for an interactive/stateful list | Works (reconciles in place, keeps node identity); use `repeat(items, key, t)` only when the list **reorders** |
445
- | `willUpdate` for SSR-visible derived state | Works (runs at SSR); keep it a pure derivation |
446
- | `this.hasAttribute` / `getAttribute` in `render()` | Works (server attribute shim) |
447
- | `ContextProvider` for server-known data | Pass via props from the page function |
448
-
449
- ## When in doubt
450
-
451
- If a pattern needs to influence the first paint, it has to be in the
452
- constructor, the page function, or `render()`. If a pattern needs the
453
- DOM, the event loop, or a browser API, it has to be in
454
- `connectedCallback` or later. There is no third category. Anything
455
- that violates this split either crashes SSR or produces a hydration
456
- flash.