Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
33 changes: 33 additions & 0 deletions agent-docs/components.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<string, oldValue>`, where keys are reactive-property names).
Expand Down
75 changes: 61 additions & 14 deletions agent-docs/lit-muscle-memory-gotchas.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Comment thread
vivek7405 marked this conversation as resolved.
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

Expand Down Expand Up @@ -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 `<style>` with semantic class names in a light-DOM component | Tailwind utilities (the default); or `static shadow = true` for genuinely scoped CSS |
| Plain `.map()` for an interactive/stateful list | Works (reconciles in place, keeps node identity); use `repeat(items, key, t)` only when the list **reorders** |
| `willUpdate` for SSR-visible derived state | Works (runs at SSR); keep it a pure derivation |
Expand Down
26 changes: 10 additions & 16 deletions docs/app/docs/components/page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,30 +59,22 @@ UserCard.register('user-card');</pre>
<pre>class UserCard extends WebComponent {

static properties = {
name: { type: String },
age: { type: Number },
active: { type: Boolean },
config: { type: Object },
tags: { type: Array },
name: { type: String, default: 'Anonymous' },
age: { type: Number, default: 0 },
active: { type: Boolean, default: false },
config: { type: Object, default: () =&gt; ({}) }, // function default → fresh per instance
tags: { type: Array, default: () =&gt; [] },
};
// Compile-time types only. Never use class-field initializers for
// reactive props. They would clobber the framework's accessor under
// modern class-field semantics. Set defaults in the constructor.
// reactive props (\`name = 'x'\`). They would clobber the framework's
// accessor under modern class-field semantics. The \`declare\` line types
// the accessor; the \`default\` option above seeds the initial value.
declare name: string;
declare age: number;
declare active: boolean;
declare config: Record&lt;string, unknown&gt;;
declare tags: string[];

constructor() {
super();
this.name = 'Anonymous';
this.age = 0;
this.active = false;
this.config = {};
this.tags = [];
}

render() {
return html\`
&lt;p&gt;\${this.name} (age \${this.age})&lt;/p&gt;
Expand All @@ -93,6 +85,8 @@ UserCard.register('user-card');</pre>
}
UserCard.register('user-card');</pre>

<p>The <code>default</code> option carries a property's initial value, so the common case needs no constructor at all. A <strong>function</strong> default is called once per instance, so an object or array default is a fresh value rather than one shared across every element. An applied attribute overrides the default. Reach for a <code>constructor()</code> default only when the value must be computed from other constructor state.</p>

<h3>Attribute-to-Property Coercion</h3>
<p>When an attribute changes on the DOM element, webjs coerces the string value to the declared type:</p>

Expand Down
8 changes: 8 additions & 0 deletions packages/core/src/component.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,14 @@ export interface PropertyDeclaration<T = unknown> {
};
/** 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). */
Expand Down
12 changes: 12 additions & 0 deletions packages/core/src/component.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
}
Expand Down
78 changes: 78 additions & 0 deletions packages/core/test/lifecycle/component-lifecycle.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,84 @@ 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('a LITERAL object default is shared across instances (documented hazard)', () => {
// This locks the documented contract: a non-function default is the SAME
// reference for every element (evaluated once in `static properties`), so
// objects / arrays must use the function form. If this ever stops being
// true, the docs warning in lit-muscle-memory-gotchas.md must change.
class C extends WebComponent {
static properties = { bag: { type: Object, default: {} } };
}
C.register('default-literal-shared');
const a = document.createElement('default-literal-shared');
const b = document.createElement('default-literal-shared');
assert.equal(a.bag, b.bag, 'a literal default is shared by reference');
});

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 () => {
Expand Down
29 changes: 29 additions & 0 deletions packages/core/test/rendering/render-server.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,35 @@ test('custom element injects declarative shadow DOM', async () => {
assert.match(out, /<\/template><\/g-reet>/);
});

test('declarative `default` prop value is baked into the SSR first paint (#531)', async () => {
class DefaultCard extends WebComponent {
static properties = {
count: { type: Number, default: 7 },
items: { type: Array, default: () => ['a', 'b'] },
};
render() {
return html`<p>count=${this.count} items=${this.items.join(',')}</p>`;
}
}
DefaultCard.register('default-card');
const out = await renderToString(html`<default-card></default-card>`);
// PE-critical: the default must be in the server HTML, not applied only
// on hydration (JS-off must read the real value).
assert.match(out, /count=7 items=a,b/);
});

test('a `reflect: true` default reflects to the attribute in the SSR markup (#531)', async () => {
class ReflectDefault extends WebComponent {
static properties = { mode: { type: String, reflect: true, default: 'dark' } };
render() {
return html`<p>${this.mode}</p>`;
}
}
ReflectDefault.register('ssr-reflect-default');
const out = await renderToString(html`<ssr-reflect-default></ssr-reflect-default>`);
assert.match(out, /mode="dark"/);
});

test('async component render is awaited', async () => {
class AsyncGreet extends WebComponent {
static shadow = true;
Expand Down
Loading
Loading