From 8f2aa836d135cac8713b7bb6dc084f8a5972419e Mon Sep 17 00:00:00 2001 From: t Date: Thu, 18 Jun 2026 14:13:52 +0530 Subject: [PATCH 1/5] feat: declarative `default` option for reactive props Add a `default:` field to `static properties` declarations so the common case no longer needs a constructor purely to seed a value. A function default is invoked per instance (fresh object/array, no shared reference); an applied attribute still overrides it. This is the one DX improvement for the static-properties + declare pattern that holds the no-build + erasable-TS + no-decorator invariants (#531): the `accessor` keyword is a runtime syntax error and is not erasable, and a prop() field helper is clobbered by class-field semantics, so neither can avoid a build step. The declare-typed accessor itself is irreducible. --- packages/core/src/component.d.ts | 8 +++ packages/core/src/component.js | 12 ++++ .../lifecycle/component-lifecycle.test.js | 64 +++++++++++++++++++ packages/server/src/check.js | 4 +- 4 files changed, 86 insertions(+), 2 deletions(-) diff --git a/packages/core/src/component.d.ts b/packages/core/src/component.d.ts index 11bc9edd2..2e643db74 100644 --- a/packages/core/src/component.d.ts +++ b/packages/core/src/component.d.ts @@ -39,6 +39,14 @@ export interface PropertyDeclaration { }; /** Custom dirty check. Return `true` to schedule an update. */ hasChanged?: (newValue: T, oldValue: T) => boolean; + /** + * Declarative initial value, so the common case needs no constructor. + * A function `default` is CALLED to produce the value (a fresh object / + * array per instance, no shared reference); to default to a function + * value, wrap it as `default: () => theFn`. An applied attribute + * overrides it. + */ + default?: T | (() => T); } /** Reactive controller protocol (Lit-compatible). */ diff --git a/packages/core/src/component.js b/packages/core/src/component.js index 1c6645bac..3fe85cd4c 100644 --- a/packages/core/src/component.js +++ b/packages/core/src/component.js @@ -584,6 +584,18 @@ export class WebComponent extends Base { if (initial !== undefined) { this.__propValues[propName] = initial; + } else if (d.default !== undefined) { + // Declarative default: lets a property carry its initial value in + // `static properties` so the common case needs no constructor at + // all. A function `default` is CALLED to produce the value, so an + // object / array default is a fresh instance per element (no shared + // reference across instances); to default to a function value, wrap + // it: `default: () => theFn`. Applied straight to the backing store + // (like `initial`), so it does not fire the setter; reflection still + // happens on connect via `_reflectDeclaredAttributes`, and an + // applied attribute (which runs after construction) overrides it. + this.__propValues[propName] = + typeof d.default === 'function' ? d.default() : d.default; } } } diff --git a/packages/core/test/lifecycle/component-lifecycle.test.js b/packages/core/test/lifecycle/component-lifecycle.test.js index a805e6b9b..778b1678a 100644 --- a/packages/core/test/lifecycle/component-lifecycle.test.js +++ b/packages/core/test/lifecycle/component-lifecycle.test.js @@ -194,6 +194,70 @@ test('custom hasChanged short-circuits updates when false', async () => { assert.equal(renders, 1, 'second assignment did not schedule a render'); }); +/* -------------------- declarative default (#531) -------------------- */ + +test('default option seeds the value with no constructor', () => { + class C extends WebComponent { + static properties = { + count: { type: Number, default: 0 }, + label: { type: String, default: 'hi' }, + }; + } + C.register('default-literal'); + const el = document.createElement('default-literal'); + assert.equal(el.count, 0); + assert.equal(el.label, 'hi'); +}); + +test('a falsy default (0, false, "") is applied, not skipped', () => { + class C extends WebComponent { + static properties = { + n: { type: Number, default: 0 }, + b: { type: Boolean, default: false }, + s: { type: String, default: '' }, + }; + } + C.register('default-falsy'); + const el = document.createElement('default-falsy'); + assert.equal(el.n, 0); + assert.equal(el.b, false); + assert.equal(el.s, ''); +}); + +test('a function default is called to produce a FRESH value per instance', () => { + class C extends WebComponent { + static properties = { items: { type: Array, default: () => [] } }; + } + C.register('default-factory'); + const a = document.createElement('default-factory'); + const b = document.createElement('default-factory'); + assert.deepEqual(a.items, []); + a.items.push(1); + // b's default must be a separate array, not a shared reference. + assert.deepEqual(b.items, []); +}); + +test('an applied attribute overrides the default', () => { + class C extends WebComponent { + static properties = { size: { type: Number, default: 7 } }; + } + C.register('default-attr-override'); + const el = document.createElement('default-attr-override'); + assert.equal(el.size, 7); + el.attributeChangedCallback('size', null, '42'); + assert.equal(el.size, 42); +}); + +test('a default with reflect: true reflects to the attribute on connect', () => { + class C extends WebComponent { + static properties = { mode: { type: String, reflect: true, default: 'dark' } }; + } + C.register('default-reflect'); + const el = document.createElement('default-reflect'); + document.body.appendChild(el); + assert.equal(el.getAttribute('mode'), 'dark'); +}); + /* -------------------- lifecycle: connect / disconnect -------------------- */ test('connectedCallback marks _connected true and schedules first render', async () => { diff --git a/packages/server/src/check.js b/packages/server/src/check.js index 084aa534c..920cee486 100644 --- a/packages/server/src/check.js +++ b/packages/server/src/check.js @@ -76,7 +76,7 @@ export const RULES = [ { name: 'reactive-props-use-declare', description: - 'Reactive properties listed in `static properties = { … }` must be typed with `declare propName: Type` (no value), and have their default set in `constructor()`. Plain class-field initializers (`prop = value` or `prop: Type = value`) compile to Object.defineProperty *after* super() under modern class-field semantics, clobbering the framework\'s reactive accessor and silently breaking re-renders.', + 'Reactive properties listed in `static properties = { … }` must be typed with `declare propName: Type` (no value), and have their default set via the `default:` option in the declaration (`{ type: Number, default: 0 }`) or in `constructor()`. Plain class-field initializers (`prop = value` or `prop: Type = value`) compile to Object.defineProperty *after* super() under modern class-field semantics, clobbering the framework\'s reactive accessor and silently breaking re-renders.', }, { name: 'shell-in-non-root-layout', @@ -456,7 +456,7 @@ export async function checkConventions(appDir) { rule: 'reactive-props-use-declare', file: rel, message: `Reactive prop \`${bad}\` uses a class-field initializer; this clobbers the framework's reactive accessor under modern class-field semantics.`, - fix: `Replace with \`declare ${bad}: ;\` and set the default inside \`constructor()\` after \`super()\`.`, + fix: `Replace with \`declare ${bad}: ;\` and set the default via the \`default:\` option in \`static properties\` (\`${bad}: { type: …, default: … }\`) or inside \`constructor()\` after \`super()\`.`, }); } } From cb24104319659d4ab5d1347d1a5055020146833b Mon Sep 17 00:00:00 2001 From: t Date: Thu, 18 Jun 2026 14:16:45 +0530 Subject: [PATCH 2/5] docs: document the declarative `default` prop option and the @property() divergence Sync the reactive-prop DX across surfaces for the new `default` option: AGENTS.md prop-options sentence, the components deep-dive options table, and the docs-site components example (now constructor-free). Rewrite the lit-muscle-memory-gotchas `@property()` entry into the full deliberate-divergence rationale (#531): decorators are non-erasable so they would force a build step, the `accessor` keyword is a runtime syntax error and is not erasable, a prop() field helper is clobbered by class-field semantics, so the `declare`-typed accessor is irreducible and `default` is what removes the constructor. --- AGENTS.md | 2 +- agent-docs/components.md | 29 ++++++++++ agent-docs/lit-muscle-memory-gotchas.md | 72 ++++++++++++++++++++----- docs/app/docs/components/page.ts | 26 ++++----- 4 files changed, 98 insertions(+), 31 deletions(-) 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..03d261b15 100644 --- a/agent-docs/components.md +++ b/agent-docs/components.md @@ -9,11 +9,30 @@ | `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 (no shared reference); +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 +46,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..adffffe0b 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,53 @@ 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. A function `default` is invoked per +instance, so an object / array default is a fresh value (no shared +reference across elements), exactly the trap a `student = {…}` field +would also create if it worked. 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 +507,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 `