Fix setAttribute() after mount not updating reflected props (closes #98)#117
Fix setAttribute() after mount not updating reflected props (closes #98)#117DmitrySharabin wants to merge 1 commit into
setAttribute() after mount not updating reflected props (closes #98)#117Conversation
6deab19 to
ed6b090
Compare
e202c15 to
cb8cb73
Compare
ed6b090 to
23ed648
Compare
cb8cb73 to
605981c
Compare
23ed648 to
a0e7afb
Compare
605981c to
22d7769
Compare
a0e7afb to
52aa111
Compare
22d7769 to
025f2b5
Compare
| // Must be on the prototype chain by the time customElements.define runs: | ||
| // the spec only reads observedAttributes if attributeChangedCallback is non-null. | ||
| // https://html.spec.whatwg.org/multipage/custom-elements.html#element-definition | ||
| attributeChangedCallback (name, oldValue, value) { |
There was a problem hiding this comment.
What happens when this plugin is used on an element class with its own attributeChangedCallback?
There was a problem hiding this comment.
It should call super.attributeChangedCallback to make it work correctly. It's a trade-off of the change.
More on this here: #110
|
|
||
| // Reserve the cache before defineProps so any consumer that reads | ||
| // Class.observedAttributes during the install (e.g., a define-props | ||
| // hook listener) gets the in-flight list instead of recursing. |
There was a problem hiding this comment.
How could that happen? There's nothing async here, so nothing will be executed between L88 and L89 (though I haven't checked defineProps, maybe there are async parts)
Also, this doesn't take parent classes into account at all (but maybe we're fixing that later?)
There was a problem hiding this comment.
How could that happen? There's nothing async here
It's synchronous re-entry, not async. defineProps() synchronously fires this.$hook("define-props", env) (props/index.js:71), and hook listeners run while we're still inside the getter. A hypothetical plugin that hooks define-props and peeks at the attr list before contributing its own:
const hooks = {
define_props (env) {
if (this.observedAttributes.includes("foo")) { /* … */ }
},
};Without the reservation:
- Outer read → getter, cache empty, no
Object.hasOwnhit. - Getter calls
defineProps(). definePropsfires thedefine-propshook.- The listener above reads
Class.observedAttributes. - Getter fires again. Cache is still empty (the assignment is after
defineProps). - Step 5 → step 2 → step 3 → step 4 → step 5 …
- 💥 stack overflow.
With the reservation, step 4's inner read hits Object.hasOwn and returns the in-flight [] immediately — same re-entry-guard shape as attachShadow in src/plugins/shadow/index.js.
Today no plugin actually hooks define-props, so the guard is purely defensive — but it's a public extension point and a third-party listener would legitimately reach for Class.observedAttributes.
doesn't take parent classes into account at all (but maybe we're fixing that later?)
Right — that's the // FIXME how to combine with existing observedAttributes? on L95. Out of scope for #117; merging derived attrs with user-declared/inherited static observedAttributes is the open question from #109.
There was a problem hiding this comment.
It's questionable whether an empty array is better in that case, but it's not a blocking issue
025f2b5 to
9895813
Compare
0366bc0 to
52aa111
Compare
025f2b5 to
787e027
Compare
a3ed3b1 to
e82d409
Compare
787e027 to
80e7485
Compare
…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>
80e7485 to
e72477a
Compare
Port of #109 from
main. Same root cause, same fix; tests are already in place.Per the HTML spec,
customElements.defineonly readsClass.observedAttributesifattributeChangedCallbackis non-null on the prototype at registration time. Pre-this-PR,first_constructor_staticwired ACB at first instance construction — after registration — so the browser snapshotted anullACB and never readobservedAttributes.setAttributeafter mount silently dropped its update.Fix: make ACB a regular entry in the props plugin's
providessoaddPluginslands it onNudeElement.prototypebefore any subclass callscustomElements.define. The eager staticobservedAttributesgetter onprovidesStaticrunsdefineProps()lazily. Subclasses chain viasuper.attributeChangedCallback(...)— same convention asconnectedCallback.Bonus: also flips "removeAttribute collapses a reflected prop to its default" green — same root cause.
Stacked on #116.
Test plan
npx htest test/index.js→ 93 pass / 5 fail / 4 skip (was 91/7/4)setAttribute()after mount does not update the property on plainreflectprops #98)" and "removeAttribute collapses a reflected prop to its default"🤖 Generated with Claude Code