Fix setAttribute() after mount not updating reflected props (closes #98)#109
Conversation
✅ Deploy Preview for nude-element ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
8e55503 to
7e541a5
Compare
`Class.observedAttributes` was wired inside `first_constructor_static`, which only runs on first instance construction. But `customElements.define(name, Class)` reads it at registration time — before any instance exists — so the browser caches the empty list and never fires `attributeChangedCallback` for subsequent attribute mutations. Move installation to a static getter on `providesStatic` keyed by `symbols.known.observedAttributes`. The cache is reserved as `[]` before `this.setup()` runs so a setup hook reading `Class.observedAttributes` hits the cache instead of recursing — same shape as `attachShadow`'s re-entry guard in shadow/index.js. Tests: - `FakeElement` now simulates the browser's observedAttributes cache in `setAttribute` / `removeAttribute`. With plugins passed through `with(...)` it reads the plugin's static getter (the `customElements.define` path); without plugins it falls back to the `Props` instance, preserving old test semantics. - The existing `"Post-mount setAttribute updates the property"` test now imports plugins dynamically and exercises the static-getter path — without this fix it fails with `Got undefined, expected 100`, the issue's literal symptom.
…98) The static observedAttributes getter previously called this.setup(), which fires every installed plugin's setup hook — implicitly moving setup() from first-construct to customElements.define time for *all* plugins, not just props. Switch to this.defineProps(): only the props plugin's own initialization fires early. Other plugins' setup timing is unchanged. define-props is the extension point for contributing observed attributes, so plugins that want to inject them aren't regressed. Add an idempotency guard (this[props].size > 0) to defineProps so the still-firing props setup hook at first construct is a no-op when the getter already populated this[props]. FakeElement: convert the parallel `defined` WeakMap to a per-class Symbol slot, captured once at FakeElement.with() time — mirrors customElements.define's one-time read at registration. setAttribute / removeAttribute consult the snapshot, not Class.observedAttributes live, so the test util truly reproduces the registration-time semantics the bug is about.
b0590be to
a630133
Compare
2ad9110 added `|| this[props].size > 0` to defineProps so the still-firing props setup hook would no-op after the observedAttributes getter populated this[props]. But the guard fires on *any* later call too, blocking plugin- contributed additive props — e.g. events/onprops appends synthetic on* props via `this.defineProps(newProps)`, which silently no-ops once the static install has run. Move the idempotency to the setup hook, where the asymmetry actually lives: only the props plugin has a registration-time install path, and the observedAttributes cache symbol is the natural marker for "the early path already ran." defineProps reverts to its original purely-additive shape — no size guard, no overloaded default-arg-means-install semantics. Regression test in test/Props.js asserts that defineProps remains additive after the registration-time install — pinning down the contract that 2ad9110's guard violated.
Move the patch from `first_constructor_static` into the eager `observedAttributes` getter so it lands on the prototype before `customElements.define` snapshots `lifecycleCallbacks`. Without this, post-mount `setAttribute` on a reflected prop did not update the prop in real browsers — the second half of #98 left open by PR #109. Strengthen FakeElement to dispatch `setAttribute`/`removeAttribute` through a snapshotted `CustomElementDefinition`-shaped record so the existing `Post-mount setAttribute updates the property` test exercises the full ACB chain.
a630133 to
5da1eb1
Compare
Empirical check in a real browser (Chromium 150) on Reduction (same page where nude-element is loaded)class Direct extends NudeElement { static props = { foo: { reflect: { from: true } } } };
customElements.define("x-direct", Direct);
// ✅ eager observedAttributes getter fires; ACB patch installed; post-mount
// setAttribute updates the prop.
class Mid extends NudeElement {}; // empty intermediate
class Leaf extends Mid { static props = { foo: { reflect: { from: true } } } };
customElements.define("x-leaf", Leaf);
// ❌ eager getter never fires (verified with console.warn at the top of the
// getter — silent during define). ACB patch never installed at registration.
// Post-mount setAttribute does NOT update the prop.
What I verified
A hand-rolled minimal mimic of the same shape (3- and 4-level inheritance with DirectionFor this PR to actually close #98, the eager install needs to land as an own property of the registered subclass before
Mea culpaMy implementation-notes comment on #98 called the unit-test approach "load-bearing" with a real-DOM smoke test as a complement. The complement was never actually exercised; without it, the unit suite certifies nothing about the snapshot path. A no-wrapper Playwright load of |
…#98) Per the HTML spec, `customElements.define` only reads observedAttributes if `attributeChangedCallback` is non-null on the prototype chain at the moment `define` runs. The previous fix installed ACB inside the eager `observedAttributes` getter — chicken-and-egg: the spec never reaches the getter because ACB hasn't been installed yet. Move ACB into the props plugin's `provides`, so `addPlugins` lands it on NudeElement.prototype where every subclass inherits it. Spec finds ACB via the chain → reads observedAttributes → eager getter fires → cache populated. Works at any depth of inheritance. Strip the now-redundant ACB install from the eager getter; its only job is to populate this[props] and return the list. Tighten FakeElement.with to mirror the spec faithfully: read attributeChangedCallback from the prototype chain first, and only read observedAttributes if ACB is non-null. The unit suite now exercises the spec path it was meant to certify — confirmed via negative control (removing the new provides.attributeChangedCallback makes \"Post-mount setAttribute updates the property\" fail with the literal symptom from #98).
Verified What I checkedCross-component smoke (programmatically created, mounted, then
Note on the approachPutting LGTM from my end. |
The plugin's ACB now lives on the inherited prototype (NudeElement.prototype via provides). A subclass that declares its own attributeChangedCallback shadows it: forgetting `super.attributeChangedCallback(...)` silently re-introduces #98 for that subclass. Add a regression test pinning both halves: override that calls super updates the reflected prop (expect 100); override that omits super leaves the prop unchanged (expect undefined). Verified each test fails in isolation when its branch is inverted. Extract `FakeElement.define(Class)` from `FakeElement.with` so the test can swap a class's ACB and re-snapshot — mirroring how `customElements.define` snapshots each registered class.
…98) Port of #109 from `main`. Per the HTML spec, `customElements.define` only reads `Class.observedAttributes` if `attributeChangedCallback` is non-null on the prototype at registration time. The previous `first_constructor_static` hook wired ACB at *first instance construction*, which fires after registration — so the browser snapshotted a `null` ACB and never read `observedAttributes`. `setAttribute` after mount silently dropped its update. Fix: make ACB a regular entry in the props plugin's `provides`, so `addPlugins` lands it on `NudeElement.prototype` before any subclass calls `customElements.define`. Subclasses chain via super, matching the convention used for `connectedCallback` / `disconnectedCallback`. The eager static `observedAttributes` getter on `providesStatic` now runs `defineProps()` lazily — that's the registration-time path. The `setup` hook is gated by `!Object.hasOwn(this, observedAttributes)` so it doesn't re-install when the static getter has already done so. Bonus: this also flips "removeAttribute collapses a reflected prop to its default" green — same root cause. Baseline: 91 → 93 pass / 7 → 5 fail / 4 skip. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…98) Port of #109 from `main`. Per the HTML spec, `customElements.define` only reads `Class.observedAttributes` if `attributeChangedCallback` is non-null on the prototype at registration time. The previous `first_constructor_static` hook wired ACB at *first instance construction*, which fires after registration — so the browser snapshotted a `null` ACB and never read `observedAttributes`. `setAttribute` after mount silently dropped its update. Fix: make ACB a regular entry in the props plugin's `provides`, so `addPlugins` lands it on `NudeElement.prototype` before any subclass calls `customElements.define`. Subclasses chain via super, matching the convention used for `connectedCallback` / `disconnectedCallback`. The eager static `observedAttributes` getter on `providesStatic` now runs `defineProps()` lazily — that's the registration-time path. The `setup` hook is gated by `!Object.hasOwn(this, observedAttributes)` so it doesn't re-install when the static getter has already done so. Bonus: this also flips "removeAttribute collapses a reflected prop to its default" green — same root cause. Baseline: 91 → 93 pass / 7 → 5 fail / 4 skip. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…98) Port of #109 from `main`. Per the HTML spec, `customElements.define` only reads `Class.observedAttributes` if `attributeChangedCallback` is non-null on the prototype at registration time. The previous `first_constructor_static` hook wired ACB at *first instance construction*, which fires after registration — so the browser snapshotted a `null` ACB and never read `observedAttributes`. `setAttribute` after mount silently dropped its update. Fix: make ACB a regular entry in the props plugin's `provides`, so `addPlugins` lands it on `NudeElement.prototype` before any subclass calls `customElements.define`. Subclasses chain via super, matching the convention used for `connectedCallback` / `disconnectedCallback`. The eager static `observedAttributes` getter on `providesStatic` now runs `defineProps()` lazily — that's the registration-time path. The `setup` hook is gated by `!Object.hasOwn(this, observedAttributes)` so it doesn't re-install when the static getter has already done so. Bonus: this also flips "removeAttribute collapses a reflected prop to its default" green — same root cause. Baseline: 91 → 93 pass / 7 → 5 fail / 4 skip. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…98) Port of #109 from `main`. Per the HTML spec, `customElements.define` only reads `Class.observedAttributes` if `attributeChangedCallback` is non-null on the prototype at registration time. The previous `first_constructor_static` hook wired ACB at *first instance construction*, which fires after registration — so the browser snapshotted a `null` ACB and never read `observedAttributes`. `setAttribute` after mount silently dropped its update. Fix: make ACB a regular entry in the props plugin's `provides`, so `addPlugins` lands it on `NudeElement.prototype` before any subclass calls `customElements.define`. Subclasses chain via super, matching the convention used for `connectedCallback` / `disconnectedCallback`. The eager static `observedAttributes` getter on `providesStatic` now runs `defineProps()` lazily — that's the registration-time path. The `setup` hook is gated by `!Object.hasOwn(this, observedAttributes)` so it doesn't re-install when the static getter has already done so. Bonus: this also flips "removeAttribute collapses a reflected prop to its default" green — same root cause. Baseline: 91 → 93 pass / 7 → 5 fail / 4 skip. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
TL;DR
Calling
el.setAttribute("v", "100")after mount on areflect: trueprop did not updateel.v, becausecustomElements.definecached an emptyobservedAttributeslist and anullattributeChangedCallbackat registration time. This PR makes ACB always present on the prototype chain via the props plugin'sprovides, which lets the spec readobservedAttributesand triggers the eager static getter that populates it.Why
Per HTML spec §CustomElementRegistry.define:
? Get(prototype, callbackName).observedAttributesis initialized to an empty sequence.lifecycleCallbacks["attributeChangedCallback"]is non-null,? Get(constructor, "observedAttributes").Pre-this-PR,
attributeChangedCallbackwas wired insidefirst_constructor_static(which fires on first instance construction, after registration). The browser snapshotted anullACB atdefinetime and so never readobservedAttributeseither. Pre-set attributes worked only becauseProps.initializeForre-walksobservedAttributeson mount.The natural fix is to make sure ACB is reachable through the prototype chain by the time
customElements.defineruns.What
src/plugins/props/index.jsattributeChangedCallbackbecomes a regular entry in the plugin'sprovides.addPluginslands it onNudeElement.prototypeonce, and every subclass — at any depth of inheritance — inherits it through the chain. SoGet(Class.prototype, "attributeChangedCallback")returns it duringdefine, which causes the spec to readClass.observedAttributes, which triggers the eager static getter and populates the cache.Chaining to a user-declared override follows the same pattern as
connectedCallbackin src/element/members.js:getSuperMethod(this, provides.attributeChangedCallback)?.call(this, ...)then the plugin's dispatch intoProps.attributeChanged. A subclass that overridesattributeChangedCallbackmust callsuper.attributeChangedCallback(...)to compose, which is standard JS — see "Known caveat" below.The eager static
observedAttributesgetter onprovidesStaticis kept — its only job now is to lazily calldefineProps()and return the list. The previous ACB install block inside the getter is gone (it was the chicken-and-egg: ACB-on-prototype is the precondition for the getter ever firing).The cache is still reserved as
[]beforedefinePropsruns, so adefine-propshook that readsClass.observedAttributeshits the cache instead of recursing — same re-entry-guard shape asattachShadowin src/plugins/shadow/index.js.The props plugin's
setuphook still skipsdefineProps()when the static getter has already cachedobservedAttributes, so plugin-contributed additive calls (e.g.events/onpropsappending syntheticon*props viathis.defineProps(newProps)) keep working after the static install.test/util/FakeElement.jsFakeElement.withnow mirrors the spec faithfully when building its per-classCustomElementDefinition-shaped snapshot:attributeChangedCallbackfrom the prototype chain first.observedAttributesfrom the constructor.This closes the loophole the previous test harness had: it used to read
Class.observedAttributesunconditionally, which triggered the eager install regardless of whether ACB was actually on the prototype — making the unit test pass even when the real-browser path was broken (the central complaint of the PR review). With this conditional, the unit suite is now load-bearing on the spec path.The snapshot logic was also extracted into a new
FakeElement.define(Class)static method, so a test can swap a class's prototype ACB and re-snapshot — modelling howcustomElements.definesnapshots each registered class independently. Used by the subclass-override regression test below.setAttribute/removeAttributecontinue to dispatch through the snapshot, the same path a real browser takes vialifecycleCallbacks.test/Props.jsThe existing
"Post-mount setAttribute updates the property"test now exercises the real registration path end-to-end. Withoutprovides.attributeChangedCallback, it fails withGot undefined, expected 100— the literal symptom from #98.The
Class.defineProps()additive-contract test (added in an earlier commit on this branch) still pins down thatdefineProps({ bar: {} })after the registration-time install must still registerbar.A new
Subclass attributeChangedCallback overridegroup pins both halves of the new contract (see "Known caveat" below):100) — verifies that chaining viasuper.attributeChangedCallback(...)still flows throughProps.attributeChanged.undefined) — verifies that forgettingsuperdoes silently drop the reflection, so the contract is observable in tests.Both halves were sanity-checked: inverting either branch causes exactly the opposite test to fail with the expected symptom.
Known caveat — behavior change vs pre-PR-109
Pre-PR-109,
first_constructor_staticunconditionally wrapped any existing ACB on the prototype chain. A subclass could declare its ownattributeChangedCallbackand still get the plugin's reflected-prop dispatch fired automatically, without calling super. PR-109 partially broke that auto-wrap (its!Object.hasOwn(this.prototype, "attributeChangedCallback")guard skipped wrapping for any subclass that had an own ACB). This PR drops the auto-wrap entirely.The new contract: any subclass that overrides
attributeChangedCallbackmust callsuper.attributeChangedCallback(name, oldValue, value)to compose with the plugin. The same convention already governsconnectedCallback/disconnectedCallbackin this codebase. Pinned by the new regression test intest/Props.js.Why auto-wrap can't be restored without an API change. JavaScript class-method syntax installs a data property on the subclass prototype via internal
DefineOwnProperty— that path bypasses any setter trap we could install on the base prototype, so there is no language-level way to intercept "subclass just defined an ACB" without consumer cooperation. The spec snapshots ACB atcustomElements.definetime and never looks at the prototype again, so wrapping later (eager getter, first construction, etc.) is invisible to the cached callback. The pre-PR-109 wrapping landed at first construction — which is precisely why the spec snapshot was already cached (withnull) by then, i.e. the auto-wrap was the bug. You can have auto-wrap or spec-correct registration, not both, without introducing aNudeElement.define(name)registration helper.Footgun severity. This is the worst lifecycle hook to require manual super-chaining on, from a footgun-cost perspective: forgetting
supersilently re-introduces #98 for that one subclass. By contrast, forgettingsuper.connectedCallback?.()only breaks theconnectedhook (propchange queue drain on reconnect — obscure). A registration helper that would restore auto-wrap without re-introducing the spec problem is proposed for discussion in #110.Out of scope
// FIXME how to combine with existing observedAttributes?(src/plugins/props/index.js) — preserved. Merging a user-declaredstatic observedAttributeswith derived ones is an open question; current shadowing semantics are kept.NudeElement.define(name)) that auto-wraps lifecycle callbacks uniformly — not introduced here. Proposed for discussion in RFC: registration helper to auto-wrap lifecycle callbacks (follow-up to #98 / #109) #110.Test plan
npm test— 102/103 pass (1 pre-existing skip insplit()).provides.attributeChangedCallback→"Post-mount setAttribute updates the property"fails withGot undefined, expected 100, proving the prototype-resident ACB is what enables the spec path.acb ?conditional inFakeElement.with→ tests still pass but the harness stops mirroring the spec; confirms the conditional itself is load-bearing on the regression check."Override that omits super leaves the reflected prop unchanged"fails withGot 100, expected undefined; never chaining →"Override that calls super updates the reflected prop"fails withGot undefined, expected 100. Both halves are load-bearing.<color-picker>(which inherits two levels —ColorPicker → ColorElement → NudeElement), callsetAttributeon a reflected prop from DevTools and confirm the JS property updates.