Every attribute an element supports is declared in five places today. Taking intensity on <pc-light> (light-component.ts):
private _intensity = 1; // 1. field, with default
intensity: this._intensity, // 2. getInitialComponentData()
set intensity(value: number) { ... } // 3. accessor pair
get intensity() { ... }
'intensity', // 4. observedAttributes
case 'intensity': // 5. dispatch — default restated
this.intensity = parseNumber(newValue, 1, name);
break;
Across src/ that is 332 switch cases and matching observedAttributes lists over ~30 elements — material.ts alone is 2,572 lines for 88 properties. Nothing ties the five declarations together: TypeScript cannot see that the 1 in the case must equal the 1 in the field initializer, that every observedAttributes entry needs a case (or vice versa), or that a raw-string case's newValue ?? '' must match the field's ''.
This has already bitten
- Default drift is the library's known bug family. attribute-removal.test.ts exists because 23 attributes assigned removal's
null straight through where the backing field said '' — and, per its own docblock, "attributeChangedCallback declared newValue: string, so TypeScript never flagged any of it."
- The build already treats the switch as a schema — by parsing it. attributes-plugin.mjs is 444 lines of AST analysis whose whole job is recovering
{attribute, property, type, default, enum values} from the shape of each case. It needs a further 448-line validate.mjs because, again per its own docblock, a silent regression in that recovery "would otherwise ship an empty-looking manifest unnoticed." That is ~900 lines of tooling maintaining lockstep with data we could just... store as data.
Proposal
Declare the schema once, as a static table, and give the base classes a generic dispatch. The pattern is the industry-standard one (Lit's static properties, FAST's attribute maps):
// components/light-component.ts
static properties = {
...ComponentElement.properties,
castShadows: { attribute: 'cast-shadows', parse: parseBool },
intensity: { parse: parseNumber }, // attribute name defaults to kebab-case
shadowType: { parse: enumOf(shadowTypes) }, // valid set carried once, for dispatch AND manifest
type: { parse: enumOf(['directional', 'omni', 'spot']) }
};
// shared helper (class-agnostic — MaterialElement extends HTMLElement directly)
static get observedAttributes() { /* table keys → attribute names */ }
attributeChangedCallback(name, _old, value) {
const [prop, entry] = lookup(this.constructor.properties, name);
this._defaults ??= snapshotDefaults(this); // see below
this[prop] = entry.parse(value, this._defaults[prop], name);
}
The default is stated once — in the field initializer. Custom element reactions never run mid-constructor, so by the first attributeChangedCallback every field initializer has run; snapshotting the properties then (cloning math types) gives removal its restore values without restating a single default. The 23-case drift bug becomes impossible by construction, as does an observedAttributes/case mismatch.
The CEM plugin then reads the table instead of reconstructing it from a switch: attribute name, property, type (from the parse helper identity), enum values (from enumOf) become direct reads, and defaults come from the field initializers it already has access to. The plugin shrinks substantially and stops being coupled to the syntactic shape of a case statement.
Non-goals and constraints
- Accessors stay hand-written. They are the typed public surface — TypeDoc,
.d.ts, and the CEM analyzer all read real class declarations, and runtime-generated accessors would vanish from custom-elements.json and break the VS Code/JetBrains integrations. This proposal only replaces observedAttributes + the switch (declarations 4 and 5), not the accessors.
getInitialComponentData() stays as-is for now. Some entries transform on the way to the engine (shadowType: shadowTypes.get(...)), so deriving it needs per-entry mappers — possible follow-up, not this issue.
- Behavior is identical. Same parse helpers, same warnings, same removal semantics. The table is an
@internal static, so the public .d.ts is unchanged.
- A few elements keep a small
attributeChangedCallback override for their non-property attributes (the onpointer* inline-handler bookkeeping on pc-entity/pc-node), exactly as they chain super today.
Migration
Per-element, mechanical, and independently landable — the base machinery plus one pilot element first (pc-light exercises bool, number, color and two enum shapes), then the rest in small PRs. Each migration is pinned by:
custom-elements.json diff empty for the migrated element (the strongest guard — the manifest encodes exactly the metadata being moved)
- existing element-tier suites green (
attribute-names, attribute-removal)
utils/cem/validate.mjs green
- no public
.d.ts change
Net effect: roughly a thousand lines of dispatch deleted, ~900 lines of manifest tooling reduced to direct reads, and two classes of silent bug eliminated structurally.
Tasks
🤖 Generated with Claude Code
Every attribute an element supports is declared in five places today. Taking
intensityon<pc-light>(light-component.ts):Across
src/that is 332 switch cases and matchingobservedAttributeslists over ~30 elements — material.ts alone is 2,572 lines for 88 properties. Nothing ties the five declarations together: TypeScript cannot see that the1in thecasemust equal the1in the field initializer, that everyobservedAttributesentry needs acase(or vice versa), or that a raw-string case'snewValue ?? ''must match the field's''.This has already bitten
nullstraight through where the backing field said''— and, per its own docblock, "attributeChangedCallbackdeclarednewValue: string, so TypeScript never flagged any of it."{attribute, property, type, default, enum values}from the shape of eachcase. It needs a further 448-linevalidate.mjsbecause, again per its own docblock, a silent regression in that recovery "would otherwise ship an empty-looking manifest unnoticed." That is ~900 lines of tooling maintaining lockstep with data we could just... store as data.Proposal
Declare the schema once, as a static table, and give the base classes a generic dispatch. The pattern is the industry-standard one (Lit's
static properties, FAST's attribute maps):The default is stated once — in the field initializer. Custom element reactions never run mid-constructor, so by the first
attributeChangedCallbackevery field initializer has run; snapshotting the properties then (cloning math types) gives removal its restore values without restating a single default. The 23-case drift bug becomes impossible by construction, as does anobservedAttributes/casemismatch.The CEM plugin then reads the table instead of reconstructing it from a switch: attribute name, property, type (from the parse helper identity), enum values (from
enumOf) become direct reads, and defaults come from the field initializers it already has access to. The plugin shrinks substantially and stops being coupled to the syntactic shape of acasestatement.Non-goals and constraints
.d.ts, and the CEM analyzer all read real class declarations, and runtime-generated accessors would vanish fromcustom-elements.jsonand break the VS Code/JetBrains integrations. This proposal only replacesobservedAttributes+ the switch (declarations 4 and 5), not the accessors.getInitialComponentData()stays as-is for now. Some entries transform on the way to the engine (shadowType: shadowTypes.get(...)), so deriving it needs per-entry mappers — possible follow-up, not this issue.@internalstatic, so the public.d.tsis unchanged.attributeChangedCallbackoverride for their non-property attributes (theonpointer*inline-handler bookkeeping onpc-entity/pc-node), exactly as they chainsupertoday.Migration
Per-element, mechanical, and independently landable — the base machinery plus one pilot element first (
pc-lightexercises bool, number, color and two enum shapes), then the rest in small PRs. Each migration is pinned by:custom-elements.jsondiff empty for the migrated element (the strongest guard — the manifest encodes exactly the metadata being moved)attribute-names,attribute-removal)utils/cem/validate.mjsgreen.d.tschangeNet effect: roughly a thousand lines of dispatch deleted, ~900 lines of manifest tooling reduced to direct reads, and two classes of silent bug eliminated structurally.
Tasks
observedAttributesderivation, defaults snapshot (clone-aware),enumOfattributes-plugin.mjsat the table; keepvalidate.mjsassertions unchanged as the regression netpc-light; confirm manifest byte-identicalpc-app/pc-entity/pc-node, thenpc-material/pc-asset/pc-scene/pc-sky)🤖 Generated with Claude Code