Skip to content

Declare attributes as data: property tables, piloted on pc-light - #392

Closed
willeastcott wants to merge 2 commits into
mainfrom
attribute-property-tables
Closed

Declare attributes as data: property tables, piloted on pc-light#392
willeastcott wants to merge 2 commits into
mainfrom
attribute-property-tables

Conversation

@willeastcott

@willeastcott willeastcott commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

Part of #391 — the base machinery plus the pilot element. Follow-up PRs migrate the remaining elements in small batches.

Revised after review. The first version derived defaults from a lazy per-instance snapshot of the field initializers. Review showed that to be a behavioral defect, not a trade — a property written programmatically before the element's first attribute reaction shifted the fallback baseline, making removal order-dependent and able to restore values that contradict the published defaults — and rejected synthetic construction (new element.constructor()) as a fix, since it would run consumer subclass constructors invisibly and still leave runtime and manifest recovering defaults through two separate mechanisms. The design below makes the descriptor the single authoritative declaration.

What this adds

src/properties.ts — descriptor-owned attribute schemas:

const lightProperties = defineProperties({
    castShadows: booleanProperty(false),
    color: colorProperty(() => new Color(1, 1, 1)),
    intensity: numberProperty(1),
    shadowType: enumProperty(shadowTypes, 'pcf3-32f'),
    type: enumProperty(['directional', 'omni', 'spot'], 'directional'),
    ...
});

class LightComponentElement extends ComponentElement {
    /** @internal */
    static properties = lightProperties;

    private _intensity = lightProperties.intensity.initial();
    ...
}
  • Defaults are static and explicit. The descriptor owns them; the backing field references descriptor.initial() instead of restating the value. Mutable defaults are factories (colorProperty(() => new Color(1, 1, 1))), so removal always assigns a fresh instance — no snapshot, no aliasing, no instance-history dependence.
  • Runtime dispatch and the CEM plugin read the same declaration. Attribute name (kebab-cased key, or attribute:), target property (property:), type (factory identity), enum values (the enumProperty argument), and published default (the declared initial, or invalid for a property that starts unset) are all direct reads.
  • invalid separates the malformed-value fallback from the initial value — pc-asset's texture options are unset (null) initially and on removal, but fall back to an engine constant ('repeat') on invalid input. One undifferentiated default cannot express that; the descriptor can.
  • apply replaces the assignment for presence-dependent attributes — pc-material's roughness writes gloss, sets glossInvert from the attribute's presence, and warns on conflicts. The hook receives the parsed value plus the raw attribute value.
  • Inheritance is a chain walk, not a spread. Each class declares only its own table; observedAttributes and dispatch merge the constructor prototype chain at lookup (cached per class). A forgotten spread can no longer silently drop base attributes, and the runtime now matches what the plugin already assumed (own tables + the analyzer's inheritedFrom step).

pc-light (the pilot — bool, number, color, and both enum shapes) drops its 22-line observedAttributes list and 19-case switch. A typing bonus: the shadow-type union is now spelled once (the shadowTypes map) instead of twice — the field's type is inferred from initial().

utils/cem/attributes-plugin.mjs resolves static properties = <const> through defineProperties(...) and reads the descriptor calls. Switch parsing remains for unmigrated elements and retires with the last migration.

Two notes for reviewers on non-obvious choices:

  • defineProperties is deliberately unconstrained — a PropertyTable bound contextually types every entry as PropertyDeclaration<any>, collapsing enumProperty's literal-union inference to string. Shape checking happens at the static properties declarations instead.
  • The type is named PropertyDeclaration because PropertyDefinition is an ambient DOM global (CSS Properties API) — a shadowing hazard for any file that forgets the import.

Verification (the pins from the issue)

  • dist/custom-elements.json byte-identical to main, as are vscode.html-custom-data.json and web-types.json
  • utils/cem/validate.mjs untouched and green (29 elements)
  • 782 tests green, including the review's regression case (a property written before the first attribute reaction no longer shifts what removal or an invalid value restores) and a machinery suite pinning the chain merge, the initial/invalid split, the attribute/property overrides, and the apply hook through scratch elements
  • npm run lint, npm run type-check, prettier on changed files clean
  • Both emitted declaration trees compile standalone; properties.d.ts strips to export {} (every declaration @internal, including each overload signature — stripInternal is per-declaration)
  • The only .d.ts change: light-component.d.ts loses its two now-inherited lifecycle redeclarations (identical signatures remain on ComponentElement)

🤖 Generated with Claude Code

willeastcott and others added 2 commits August 16, 2026 12:01
Part of #391. Every attribute is currently declared in five places (field
default, getInitialComponentData, accessors, observedAttributes, a switch
case restating the default), and nothing ties them together - default
drift across 23 attributes is the library's known bug family.

This adds the machinery and migrates the pilot element:

- src/properties.ts: a static per-class `properties` table maps property
  names to parse helpers; `observedAttributes` derives from the table and
  a generic `applyAttribute` dispatch replaces the per-element switch.
  Defaults are stated once, in the field initializer: reactions never run
  mid-constructor, so a clone-aware snapshot of the properties at the
  first attributeChangedCallback captures exactly the initializer values
  for removal and invalid-value fallbacks. `enumOf` carries an enum's
  valid names once, for dispatch and for the manifest.
- ComponentElement hosts the generic dispatch; unmigrated subclasses
  chain super exactly as before.
- pc-light (bool, number, color, and both enum shapes) drops its
  observedAttributes list and 19-case switch for a 19-line table.
- attributes-plugin.mjs reads the table where one exists (type from the
  parse helper identity, enum values from the enumOf argument, defaults
  from the field initializers); switch parsing remains for unmigrated
  elements and retires with the last migration.

dist/custom-elements.json, vscode.html-custom-data.json and
web-types.json are byte-identical to main; validate.mjs assertions are
untouched and green. The only .d.ts change is pc-light losing its two
now-inherited lifecycle redeclarations (identical signatures on
ComponentElement). New element-tier tests pin the table semantics.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review on the pilot identified the lazy instance snapshot as a defect,
not a trade: a property written programmatically before the element's
first attribute reaction shifted the fallback baseline, making removal
order-dependent and able to restore values that contradict the
published defaults. Synthetic construction was rejected as a fix - it
would run consumer subclass constructors invisibly and still leave
runtime and manifest recovering defaults through two mechanisms.

The descriptor is now the single authoritative declaration, read by
both runtime dispatch and the manifest plugin:

- Factories (booleanProperty, numberProperty, stringProperty,
  colorProperty, enumProperty) own the defaults. Mutable initial
  values are factories, so removal always assigns a fresh instance;
  backing fields reference descriptor.initial() instead of restating
  the value. The snapshot machinery is deleted outright.
- `invalid` declares a malformed-value fallback distinct from the
  initial value (pc-asset's texture options: unset initially, engine
  constant on invalid input). `attribute`, `property` and `apply`
  cover aliases and presence-dependent side effects (pc-material's
  roughness) declaratively.
- Each class declares only its own table; observedAttributes and
  dispatch merge the constructor chain at lookup, so a base table
  cannot be dropped by a forgotten spread.
- defineProperties is deliberately unconstrained: a PropertyTable
  bound contextually typed every entry as PropertyDeclaration<any>,
  collapsing enumProperty's literal-union inference to string. Shape
  checking happens at the static declarations instead. A side effect
  of the descriptor types: pc-light's shadow-type union is now spelled
  once (the Map) instead of twice.
- New regression test: a property-before-first-attribute write no
  longer shifts what removal or an invalid value restores. New
  machinery suite pins the chain merge, the initial/invalid split, the
  overrides, and the apply hook through scratch elements.

dist/custom-elements.json, vscode.html-custom-data.json and
web-types.json remain byte-identical to main; validate.mjs untouched
and green; the d.ts diff is unchanged from the previous commit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@willeastcott

Copy link
Copy Markdown
Contributor Author

Closing after reassessing the trade-off ahead of 1.0. The attribute-drift problem is real, but this implementation adds a descriptor framework and a second CEM source syntax whose return only appears after a full migration. We will keep #391 and reframe it around invariant checks first, with a smaller typed handler-map experiment against pc-material. This exploration was still valuable: it surfaced the initial-versus-invalid fallback distinction, mutable-default requirements, and inheritance cases that any future approach must handle.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant