diff --git a/AGENTS.md b/AGENTS.md index b1682c16d..ec994a8da 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -208,7 +208,7 @@ class MyThing extends WebComponent { MyThing.register('my-thing'); ``` -**Signals are the default state primitive.** Import `signal` / `computed` from `@webjsdev/core`, read with `signal.get()` inside `render()`, and the built-in `SignalWatcher` re-renders on change. Module-scope signals share state across components and survive navigations; instance signals (constructor) are component-local. `static properties` is reserved for values that ride an HTML attribute, reflect to one, or arrive via `.prop=${value}` SSR hydration. **Typed props in TS use the `declare` pattern** (a `student: Student = {…}` class-field initializer overwrites the reactive accessor after `super()` and breaks reactivity; instead declare the runtime in `static properties`, the type via `declare student: Student`, and set defaults in the constructor, enforced by `reactive-props-use-declare`). Property options: `type` (default `String`), `reflect`, `state`, `hasChanged`, `converter`. +**Signals are the default state primitive.** Import `signal` / `computed` from `@webjsdev/core`, read with `signal.get()` inside `render()`, and the built-in `SignalWatcher` re-renders on change. Module-scope signals share state across components and survive navigations; instance signals (constructor) are component-local. `static properties` is reserved for values that ride an HTML attribute, reflect to one, or arrive via `.prop=${value}` SSR hydration. **Typed props in TS use the `declare` pattern** (a `student: Student = {…}` class-field initializer overwrites the reactive accessor after `super()` and breaks reactivity; instead declare the runtime in `static properties`, the type via `declare student: Student`, and set defaults via the `default` option or in the constructor, enforced by `reactive-props-use-declare`). Property options: `type` (default `String`), `reflect`, `state`, `hasChanged`, `converter`, `default` (declarative initial value, so the common case needs no constructor; a function `default` is called per instance for fresh object / array defaults, and an applied attribute overrides it). **Lifecycle (lit-aligned), in order:** `shouldUpdate`, `willUpdate`, controllers' `hostUpdate()`, `update` (calls `render()` + commits), controllers' `hostUpdated()`, `firstUpdated`, `updated`, `updateComplete`, each receiving a `changedProperties` Map. **SSR runs only the constructor, attribute application, the pre-render hooks (`willUpdate` / `hostUpdate`), `reflect: true` reflection, and `render()`; it does NOT call `connectedCallback`, `firstUpdated`, `updated`, or any browser-only hook.** So defaults for first paint go in the constructor; browser-only data (localStorage, viewport, `navigator.*`) goes in `connectedCallback` writing a signal; server-known data arrives via the page function. Never ship a placeholder first paint that fetches in `connectedCallback`. A browser-only global in the constructor/`render()` throws at SSR (flagged by `no-browser-globals-in-render`; attribute methods and `closest()` are shimmed). diff --git a/agent-docs/components.md b/agent-docs/components.md index abcd16f45..13e082dd7 100644 --- a/agent-docs/components.md +++ b/agent-docs/components.md @@ -9,11 +9,34 @@ | `state` | `boolean` | `false` | Internal-only. No attribute, not in `observedAttributes` | | `hasChanged` | `(newVal, oldVal) => boolean` | strict `!==` | Custom change detection | | `converter` | `{ fromAttribute?, toAttribute? }` | type-based | Custom attribute ↔ property serialization | +| `default` | `T \| (() => T)` | none | Declarative initial value, so the common case needs no constructor | Built-in constructors (`String`, `Number`, `Boolean`, `Array`, `Object`) feed the default attribute coercion. For anything the default can't parse correctly (Date, Map, Set, discriminated unions) supply a custom `converter`. +The `default` option carries a property's initial value declaratively: + +```ts +static properties = { + count: { type: Number, default: 0 }, + items: { type: Array, default: () => [] }, // function default → fresh per instance +}; +declare count: number; +declare items: string[]; +``` + +A function `default` is **called** to produce the value, so an object / +array default is a separate instance per element. Always use the function +form for an object or array: a LITERAL `default: {}` / `default: []` is +evaluated once when `static properties` is created, so every element +would share one reference and a mutation in one would leak into the rest. +A primitive literal (`0`, `false`, `'x'`) is safe. To default a property +to a function VALUE, wrap it as `default: () => theFn`. An applied +attribute (it runs after construction) overrides the default. Set the +default in the `constructor()` instead only when it must be computed from +other constructor state. + ## Why `declare` is required in TypeScript The framework installs reactive getter/setter on `this` inside the @@ -27,6 +50,16 @@ The `.d.ts` overlay shipped with the framework makes every other class member fully typed, so only the reactive properties need the `declare` line, and only in TypeScript files. +The `declare` line is irreducible: TypeScript has no decorator-free way +to add a typed instance member from the static runtime `properties` +value, and decorators (`@property()`) are banned because they are +non-erasable and webjs strips types with no build step. What the +`default` option removes is the *constructor*, bringing the common case +to two lines (`static properties` + `declare`). See the +"@property() decorator" entry in `lit-muscle-memory-gotchas.md` for the +full divergence rationale (including why the `accessor` keyword and a +`prop()` helper do not work either). + ## Lifecycle hooks (lit-aligned) `WebComponent` ships lit's full reactive lifecycle. Every update cycle runs these hooks in order; each receives a `changedProperties` Map (`Map`, where keys are reactive-property names). diff --git a/agent-docs/lit-muscle-memory-gotchas.md b/agent-docs/lit-muscle-memory-gotchas.md index d3aa926c7..5f3feb6b6 100644 --- a/agent-docs/lit-muscle-memory-gotchas.md +++ b/agent-docs/lit-muscle-memory-gotchas.md @@ -288,7 +288,15 @@ class StudentCard extends WebComponent { student: Student = { name: '', email: '' }; } -// right +// right (declarative default, no constructor needed) +class StudentCard extends WebComponent { + static properties = { + student: { type: Object, default: () => ({ name: '', email: '' }) }, + }; + declare student: Student; +} + +// also right (constructor default, when the default depends on other state) class StudentCard extends WebComponent { static properties = { student: { type: Object } }; declare student: Student; @@ -299,17 +307,56 @@ class StudentCard extends WebComponent { } ``` -`webjs check` flags this via the `reactive-props-use-declare` rule, but -AI agents emit the broken form on autopilot. The convention check is -the safety net, not the primary defense. Authoring code should use -`declare` plus constructor defaults from the start. - -### 6. The `@property()` decorator - -Banned by framework invariant 10 (erasable TS). The replacement is -`static properties = { ... }` plus a matching `declare` for the typed -accessor, as shown above. Decorators are non-erasable, so they would -force the framework to depend on a build step. +`webjs check` flags the broken form via the `reactive-props-use-declare` +rule, but AI agents emit it on autopilot. The convention check is the +safety net, not the primary defense. Authoring code should use `declare` +plus a default from the start. + +Use the `default` option for the default value whenever it does not +depend on other instance state. One footgun to know: a LITERAL object or +array default (`default: {}`, `default: []`) is evaluated once when the +`static properties` object is created, so every instance shares the same +reference and one element's mutation leaks into the others. For an object +or array default, pass a FUNCTION (`default: () => ({})`), which the +framework calls once per instance to produce a fresh value. A primitive +default (`0`, `false`, `'x'`) has no such hazard, so a literal is fine +there. Use a constructor default only when the value must be computed +from other constructor state. Either way, the `declare` line stays (it is +the only decorator-free way to type the accessor; see the next entry). + +### 6. The `@property()` decorator (the deliberate Lit divergence) + +Lit's one-liner `@property() name = 'x'` is **deliberately unavailable** +in webjs, and the `static properties` + `declare` (+ `default`) pattern +is the intended replacement, not a stopgap. The reasoning, since this is +exactly the muscle-memory trap this file exists for: + +- **Decorators are non-erasable, and webjs strips types with no build + step** (invariant 10). Types are erased by Node 24+'s + `module.stripTypeScriptTypes` or amaro on Bun, both of which only + remove *type* syntax. A decorator emits runtime calls, so it is not + erased; supporting `@property()` would force a tsc / Babel build step, + which is the one thing webjs does not have. +- **The TC39 `accessor` keyword does not rescue it.** `accessor name = + 'x'` is tempting because auto-accessors are not legacy decorators, but + (1) it is a *runtime* syntax error on the engines webjs targets (V8 in + Node, JSC in Bun) when written without the decorators proposal, and + the stripper leaves it intact because it is not a type, so it crashes + at load with no build step; and (2) even where it parses, a bare + `accessor` does not register the property as reactive without a + decorator, which is the part that is banned. +- **A `prop()` field helper cannot work either.** `name = prop('x')` is + a class-field initializer, so it is clobbered after `super()` by the + same class-field semantics that break `name = 'x'` (entry 5); it would + store the helper's return value as a plain field, not a reactive + accessor, and there is no decorator-free hook to convert it back at the + right time. +- **So the `declare` line is irreducible.** TypeScript has no + decorator-free way to add a typed instance member from a static runtime + value, so the type must be stated once via `declare name: string`. What + webjs *can* remove is the constructor: the `default` option (entry 5) + carries the initial value declaratively, bringing the common case to + two lines (`static properties` + `declare`). ## Patterns that produce different visual output @@ -463,8 +510,8 @@ must move with it, use `repeat()` with a stable key. | `Task` for client-time async | `Task` (no change, that's its job) | | `window.X` or `document.X` in constructor or `render()` | Move to `connectedCallback` | | Top-level `import` of browser-only library | Dynamic `import()` inside `connectedCallback` | -| `student: Student = { ... }` field initializer | `declare student: Student` plus constructor default | -| `@property()` decorator | `static properties = { ... }` plus `declare` | +| `student: Student = { ... }` field initializer | `declare student: Student` plus a `default` option (or constructor default) | +| `@property() name = 'x'` decorator one-liner | `static properties = { name: { default: 'x' } }` plus `declare name: string` (decorators are non-erasable, no build step) | | `static styles = css` / inline `