Skip to content

Declare attributes as data: replace per-element attributeChangedCallback switches with static property tables #391

Description

@willeastcott

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

  • Property-table helper: lookup, observedAttributes derivation, defaults snapshot (clone-aware), enumOf
  • Retarget attributes-plugin.mjs at the table; keep validate.mjs assertions unchanged as the regression net
  • Pilot: migrate pc-light; confirm manifest byte-identical
  • Migrate remaining elements in small batches (component elements, then pc-app/pc-entity/pc-node, then pc-material/pc-asset/pc-scene/pc-sky)
  • Remove dead switch-parsing paths from the plugin once the last element migrates

🤖 Generated with Claude Code

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions