From faa9556fce81c531b80fd0e384a190cc412744db Mon Sep 17 00:00:00 2001 From: Dmitry Sharabin Date: Wed, 6 May 2026 11:45:44 +0200 Subject: [PATCH 01/15] Defaults don't reflect to attributes (closes #105) Gate the reflection block in #onComputedChange on source !== "default", and compute source dynamically in the subscriber: "default" iff the value falls through (rawSignal undefined), else the user-write source ("convert" if the prop has convert, else "property"). Fixes the pre-existing closure-captured-source quirk along the way. ## Root cause The signals migration (#91) moved default resolution into a Computed signal. Its subscriber reflects on every value change regardless of source. Pre-signals, defaults were resolved lazily in Prop#get and never went through Prop#set, so they never triggered reflection. ## Why it matters A child element whose Computed-backed prop is read externally before its own initializeFor runs has its default reflected. A subsequent programmatic write before initializeFor lands in rawSignal but the synthesized attribute remains. initializeFor then walks observedAttributes, sees the synthesized default, and clobbers the programmatic write. Surface: where color-picker reads space-picker.value during its own mount (forcing its lazy first compute), then writes space_picker.value = "lab". Without this fix, space-picker's initializeFor re-imports the synthesized "a98rgb" attribute and clobbers the parent's intent. ## Tests Corrects 4 existing assertions that encoded the buggy behavior. New coverage for the convergent restore-default path lands in #107. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/plugins/props/README.md | 2 ++ src/plugins/props/util/Prop.js | 13 ++++++++++--- test/Prop.js | 14 +++++++------- 3 files changed, 19 insertions(+), 10 deletions(-) diff --git a/src/plugins/props/README.md b/src/plugins/props/README.md index b8a34659..5ed36bb8 100644 --- a/src/plugins/props/README.md +++ b/src/plugins/props/README.md @@ -163,3 +163,5 @@ The `reflect` property takes the following values: - `to`: If `true`, reflect to the attribute with the same name as the prop. If a string, reflect to the attribute with the given name. By default, `reflect` is `true` **unless** `get` is also specified, in which case it defaults to `false`. + +**Defaults are not reflected to attributes** — only user-set values are. Restoring the default (via `el.prop = undefined` or `removeAttribute`) clears any previously-reflected attribute. diff --git a/src/plugins/props/util/Prop.js b/src/plugins/props/util/Prop.js index 2e072d7a..ba9a6823 100644 --- a/src/plugins/props/util/Prop.js +++ b/src/plugins/props/util/Prop.js @@ -104,9 +104,10 @@ let Self = class Prop { #onComputedChange (element, source, newValue, oldValue) { element.props[this.name] = newValue; - // Reflect to attribute if this prop opts in + // `null` for defaults: don't synthesize an attribute (would clobber pre-mount + // writes — #105) and clear any stale attribute left over from a prior write. if (this.toAttribute) { - let attributeValue = this.stringify(newValue); + let attributeValue = source === "default" ? null : this.stringify(newValue); let oldAttributeValue = element.getAttribute(this.toAttribute); if (oldAttributeValue !== attributeValue) { element.ignoredAttributes.add(this.toAttribute); @@ -157,7 +158,6 @@ let Self = class Prop { let rawSignal = new Signal(undefined, options); this.#rawSignals.set(element, rawSignal); - let source = this.spec.convert ? "convert" : "default"; signal = new Computed(() => { let value = rawSignal.value; if (value === undefined && this.spec.default !== undefined) { @@ -181,6 +181,13 @@ let Self = class Prop { return value; }, options); signal.subscribe((newValue, oldValue) => { + let source = + rawSignal.value === undefined + ? "default" + : this.spec.convert + ? "convert" + : "property"; + this.#onComputedChange(element, source, newValue, oldValue); }); } diff --git a/test/Prop.js b/test/Prop.js index 0b70ee48..76fc419a 100644 --- a/test/Prop.js +++ b/test/Prop.js @@ -303,7 +303,7 @@ export default { expect: [ "src/default", "mirror/default", - "src/default", + "src/property", "mirror/default", ], }, @@ -372,8 +372,8 @@ export default { "computed/get", "fnDefault/default", - // Update — Computed-backed: source stays construction-time - "plain/default", + // Update — source now reports the value's actual origin + "plain/property", "computed/get", "fnDefault/default", ], @@ -580,15 +580,15 @@ export default { expect: "42", }, { - name: "Default + reflect reflects on mount (plain)", + name: "Default does NOT reflect on mount (plain)", arg: { props: { plain: { type: Number, default: 7, reflect: true } }, attr: "plain", }, - expect: "7", + expect: null, }, { - name: "Default + convert + reflect reflects on mount", + name: "Default does NOT reflect on mount (with convert)", arg: { props: { val: { @@ -602,7 +602,7 @@ export default { }, attr: "val", }, - expect: "10", + expect: null, }, ], }, From 9d61367672b45ac23217030594f65fc210742cac Mon Sep 17 00:00:00 2001 From: Dmitry Sharabin Date: Wed, 6 May 2026 12:14:28 +0200 Subject: [PATCH 02/15] removeAttribute restores the default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-signals `Prop#get` had a `value === null` fallback that returned the default. The signals path stores user-set values in a rawSignal whose fall-through to the default only triggers on `undefined`, so a `null` left over from `removeAttribute()` settled as the actual prop value. Collapse `null → undefined` when source is `"attribute"` so the rawSignal returns to its empty state and the Computed re-engages the default fallthrough. Property writes of `null` remain a legitimate user value. Tests for this scenario land in #107. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/plugins/props/util/Prop.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/plugins/props/util/Prop.js b/src/plugins/props/util/Prop.js index ba9a6823..d97dc675 100644 --- a/src/plugins/props/util/Prop.js +++ b/src/plugins/props/util/Prop.js @@ -278,6 +278,13 @@ let Self = class Prop { return; } + // removeAttribute() arrives as null; collapse to undefined so the prop + // reverts to its natural empty state (default, if any, otherwise just + // undefined). Property writes of null remain a legitimate user value. + if (source === "attribute" && parsedValue === null) { + parsedValue = undefined; + } + if (this.equals(parsedValue, oldInternalValue)) { return; } From 124924c91c347d4f9407bd1dc87b02d2de8a59ec Mon Sep 17 00:00:00 2001 From: Dmitry Sharabin Date: Wed, 6 May 2026 12:58:46 +0200 Subject: [PATCH 03/15] Reflect explicit user writes via Computed forceNotify MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-signals, storage started `undefined`, so the equals check at the top of `Prop#set` was always false on the first user write — regardless of whether the value matched the default. Reflection always reached the DOM. The signals Computed dedupes against its cached default-resolved value, so an explicit write equal to the prior default never fires the subscriber, and reflection silently drops. Add a `forceNotify` option to `Signal`: subscribers fire on every write, even when `equals` reports no change. The cached value still respects `equals` (no-op writes don't update it), so the existing spec.equals contract — Computed cache reflects only meaningful changes — is preserved. Drop the redundant equals guard in `Computed#compute` since the setter now decides. Use `forceNotify: true` on the prop Computeds. `#onComputedChange` gates the propchange event on actual value change to keep main's value-change semantics. Reflection runs whenever the subscriber fires (driven by user intent); the event still tracks the value. Tests for this scenario land in #107. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/plugins/props/util/Prop.js | 20 ++++++++++++-------- src/signals.js | 29 ++++++++++++++--------------- 2 files changed, 26 insertions(+), 23 deletions(-) diff --git a/src/plugins/props/util/Prop.js b/src/plugins/props/util/Prop.js index d97dc675..e8e05b6d 100644 --- a/src/plugins/props/util/Prop.js +++ b/src/plugins/props/util/Prop.js @@ -121,12 +121,15 @@ let Self = class Prop { } } - this.changed(element, { - element, - source, - parsedValue: newValue, - oldInternalValue: oldValue, - }); + // Gate event on value change (the Computed uses forceNotify). + if (!this.equals(newValue, oldValue)) { + this.changed(element, { + element, + source, + parsedValue: newValue, + oldInternalValue: oldValue, + }); + } } /** @@ -143,8 +146,9 @@ let Self = class Prop { let signal = this.#signals.get(element); if (!signal) { - // Delegate equality to the prop's type-aware equality. - let options = { equals: (a, b) => this.equals(a, b) }; + // `forceNotify` so explicit user writes still reach the subscriber when + // the Computed dedupes against a cached default-resolved value (#105). + let options = { equals: (a, b) => this.equals(a, b), forceNotify: true }; if (this.spec.get) { signal = new Computed(() => this.spec.get.call(element), options); diff --git a/src/signals.js b/src/signals.js index 38dd9579..bcd8b457 100644 --- a/src/signals.js +++ b/src/signals.js @@ -47,16 +47,21 @@ function flushDirty () { export class Signal extends EventTarget { #value; #subscribers = new Set(); + #forceNotify; /** * @param {*} value - Initial value. * @param {object} [options] * @param {(a: *, b: *) => boolean} [options.equals] - Custom equality * check. Set as an instance override of the default `===` method. + * @param {boolean} [options.forceNotify] - If true, subscribers fire on + * every write, even when `equals` reports no change. The cached value + * still respects `equals` (no-op writes don't update it). */ - constructor (value, { equals } = {}) { + constructor (value, { equals, forceNotify = false } = {}) { super(); this.#value = value; + this.#forceNotify = forceNotify; if (equals) { this.equals = equals; } @@ -68,12 +73,15 @@ export class Signal extends EventTarget { } set value (v) { - if (this.equals(v, this.#value)) { + let same = this.equals(v, this.#value); + if (same && !this.#forceNotify) { return; } let old = this.#value; - this.#value = v; + if (!same) { + this.#value = v; + } this.#notify(old); } @@ -187,17 +195,8 @@ export class Computed extends Signal { })); } - // Check if value actually changed, and if so, notify subscribers. - // Temporarily suspend tracking so the internal read doesn't - // register this Computed as a dependency of an outer Computed. - let prev2 = tracking; - tracking = null; - let old = super.value; - tracking = prev2; - - if (!this.equals(value, old)) { - // Use Signal.prototype.value setter directly (bypasses no-op Computed setter) - Object.getOwnPropertyDescriptor(Signal.prototype, "value").set.call(this, value); - } + // Delegate the equals/forceNotify decision to the Signal setter. + // Use Signal.prototype.value setter directly (bypasses no-op Computed setter). + Object.getOwnPropertyDescriptor(Signal.prototype, "value").set.call(this, value); } } From 7541a94a7f23db6e053fab865b4541ee15838807 Mon Sep 17 00:00:00 2001 From: Dmitry Sharabin Date: Wed, 6 May 2026 13:37:28 +0200 Subject: [PATCH 04/15] Remove unnecessary words --- test/Prop.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/Prop.js b/test/Prop.js index 76fc419a..5069414c 100644 --- a/test/Prop.js +++ b/test/Prop.js @@ -372,7 +372,7 @@ export default { "computed/get", "fnDefault/default", - // Update — source now reports the value's actual origin + // Update "plain/property", "computed/get", "fnDefault/default", From 93991c7286784f13d5cca3c7b0b6af8a13a70ecb Mon Sep 17 00:00:00 2001 From: Dmitry Sharabin Date: Wed, 6 May 2026 13:37:57 +0200 Subject: [PATCH 05/15] Tweak comment --- src/plugins/props/util/Prop.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/plugins/props/util/Prop.js b/src/plugins/props/util/Prop.js index e8e05b6d..d9b903a4 100644 --- a/src/plugins/props/util/Prop.js +++ b/src/plugins/props/util/Prop.js @@ -146,7 +146,8 @@ let Self = class Prop { let signal = this.#signals.get(element); if (!signal) { - // `forceNotify` so explicit user writes still reach the subscriber when + // Delegate equality to the prop's type-aware equality. + // Explicit user writes still reach the subscriber when // the Computed dedupes against a cached default-resolved value (#105). let options = { equals: (a, b) => this.equals(a, b), forceNotify: true }; From b35b8c3c49a4288abc91e3da352b7fe1dc3c6e88 Mon Sep 17 00:00:00 2001 From: Dmitry Sharabin Date: Wed, 6 May 2026 13:46:25 +0200 Subject: [PATCH 06/15] Add default value --- src/signals.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/signals.js b/src/signals.js index bcd8b457..c7b67acf 100644 --- a/src/signals.js +++ b/src/signals.js @@ -54,7 +54,7 @@ export class Signal extends EventTarget { * @param {object} [options] * @param {(a: *, b: *) => boolean} [options.equals] - Custom equality * check. Set as an instance override of the default `===` method. - * @param {boolean} [options.forceNotify] - If true, subscribers fire on + * @param {boolean} [options.forceNotify=false] - If true, subscribers fire on * every write, even when `equals` reports no change. The cached value * still respects `equals` (no-op writes don't update it). */ From 08290da0adf6e993c69ea8b5b604e8290f37bc70 Mon Sep 17 00:00:00 2001 From: Dmitry Sharabin Date: Wed, 6 May 2026 14:05:25 +0200 Subject: [PATCH 07/15] Add regression tests for #108 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three new tests cover the fixes in this PR (T0 already covered by the "Default does NOT reflect on mount" tests): - "removeAttribute restores default" (Final value group) — verifies the null → undefined collapse in Prop#set. - "removeAttribute clears the reflected attribute" (Attribute reflection group) — verifies the gate at #onComputedChange suppresses default re-reflection. - "Explicit write equal to default still reflects" (Attribute reflection group) — verifies the Computed forceNotify path. Each test was confirmed to fail with its corresponding fix disabled. FakeElement.setAttribute / removeAttribute now call attributeChanged() on Props, mirroring real custom elements' attributeChangedCallback path. Without this, source="attribute" writes never reach Prop#set and the removeAttribute tests would silently pass even with the fix disabled. The ignoredAttributes guard inside attributeChanged prevents reflection round-trips, so Prop's existing inline reflection still works. Co-Authored-By: Claude Opus 4.7 (1M context) --- test/Prop.js | 45 ++++++++++++++++++++++++++++++++++++++++ test/util/FakeElement.js | 4 ++++ 2 files changed, 49 insertions(+) diff --git a/test/Prop.js b/test/Prop.js index 5069414c..31020249 100644 --- a/test/Prop.js +++ b/test/Prop.js @@ -561,6 +561,18 @@ export default { }, expect: 42, }, + { + name: "removeAttribute restores default", + arg: { + props: { v: { type: Number, reflect: true, default: 5 } }, + actions: [ + el => el.setAttribute("v", "6"), + el => el.removeAttribute("v"), + ], + read: "v", + }, + expect: 5, + }, ], }, { @@ -604,6 +616,39 @@ export default { }, expect: null, }, + { + name: "removeAttribute clears the reflected attribute", + arg: { + props: { v: { type: Number, reflect: true, default: 5 } }, + actions: [ + el => el.setAttribute("v", "6"), + el => el.removeAttribute("v"), + ], + attr: "v", + }, + expect: null, + }, + { + name: "Explicit write equal to default still reflects", + arg: { + props: { v: { type: Number, reflect: true, default: 5 } }, + actions: [el => (el.v = 5)], + attr: "v", + }, + expect: "5", + }, + { + name: "Restoring the default clears the previously-reflected attribute", + arg: { + props: { v: { type: Number, reflect: true, default: 5 } }, + actions: [ + el => (el.v = 6), + el => (el.v = undefined), + ], + attr: "v", + }, + expect: null, + }, ], }, ], diff --git a/test/util/FakeElement.js b/test/util/FakeElement.js index a3e3f34e..fc8630a7 100644 --- a/test/util/FakeElement.js +++ b/test/util/FakeElement.js @@ -16,11 +16,15 @@ export default class FakeElement extends EventTarget { } setAttribute (name, value) { + let oldValue = this.#attrs.get(name) ?? null; this.#attrs.set(name, String(value)); + this.constructor.props?.attributeChanged(this, name, oldValue); } removeAttribute (name) { + let oldValue = this.#attrs.get(name) ?? null; this.#attrs.delete(name); + this.constructor.props?.attributeChanged(this, name, oldValue); } mount () { From 774cfa2fa9da306e6e228fd532a69e98defc66b0 Mon Sep 17 00:00:00 2001 From: Dmitry Sharabin Date: Wed, 6 May 2026 14:23:03 +0200 Subject: [PATCH 08/15] Minimize diff --- src/plugins/props/util/Prop.js | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/plugins/props/util/Prop.js b/src/plugins/props/util/Prop.js index d9b903a4..69310d7d 100644 --- a/src/plugins/props/util/Prop.js +++ b/src/plugins/props/util/Prop.js @@ -122,14 +122,16 @@ let Self = class Prop { } // Gate event on value change (the Computed uses forceNotify). - if (!this.equals(newValue, oldValue)) { - this.changed(element, { - element, - source, - parsedValue: newValue, - oldInternalValue: oldValue, - }); + if (this.equals(newValue, oldValue)) { + return; } + + this.changed(element, { + element, + source, + parsedValue: newValue, + oldInternalValue: oldValue, + }); } /** From 47d8d78b0b060ee92507a54a8c1f98f8c1d437b5 Mon Sep 17 00:00:00 2001 From: Dmitry Sharabin Date: Wed, 6 May 2026 14:46:03 +0200 Subject: [PATCH 09/15] Minimize diff --- src/plugins/props/util/Prop.js | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/src/plugins/props/util/Prop.js b/src/plugins/props/util/Prop.js index 69310d7d..c4bdcf65 100644 --- a/src/plugins/props/util/Prop.js +++ b/src/plugins/props/util/Prop.js @@ -165,6 +165,7 @@ let Self = class Prop { let rawSignal = new Signal(undefined, options); this.#rawSignals.set(element, rawSignal); + let source = this.spec.convert ? "convert" : "property"; signal = new Computed(() => { let value = rawSignal.value; if (value === undefined && this.spec.default !== undefined) { @@ -188,14 +189,8 @@ let Self = class Prop { return value; }, options); signal.subscribe((newValue, oldValue) => { - let source = - rawSignal.value === undefined - ? "default" - : this.spec.convert - ? "convert" - : "property"; - - this.#onComputedChange(element, source, newValue, oldValue); + let newSource = rawSignal.value === undefined ? "default" : source; + this.#onComputedChange(element, newSource, newValue, oldValue); }); } else { From 4f05f3d182de5ff8b66b31aeca699cbd3014f49a Mon Sep 17 00:00:00 2001 From: Dmitry Sharabin Date: Wed, 6 May 2026 15:01:18 +0200 Subject: [PATCH 10/15] =?UTF-8?q?Rename:=20`forceNotify`=20=E2=86=92=20`fo?= =?UTF-8?q?rce`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/plugins/props/util/Prop.js | 4 ++-- src/signals.js | 14 +++++++------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/plugins/props/util/Prop.js b/src/plugins/props/util/Prop.js index c4bdcf65..21efefbf 100644 --- a/src/plugins/props/util/Prop.js +++ b/src/plugins/props/util/Prop.js @@ -121,7 +121,7 @@ let Self = class Prop { } } - // Gate event on value change (the Computed uses forceNotify). + // Gate event on value change (the Computed uses force). if (this.equals(newValue, oldValue)) { return; } @@ -151,7 +151,7 @@ let Self = class Prop { // Delegate equality to the prop's type-aware equality. // Explicit user writes still reach the subscriber when // the Computed dedupes against a cached default-resolved value (#105). - let options = { equals: (a, b) => this.equals(a, b), forceNotify: true }; + let options = { equals: (a, b) => this.equals(a, b), force: true }; if (this.spec.get) { signal = new Computed(() => this.spec.get.call(element), options); diff --git a/src/signals.js b/src/signals.js index c7b67acf..ec27deba 100644 --- a/src/signals.js +++ b/src/signals.js @@ -47,21 +47,21 @@ function flushDirty () { export class Signal extends EventTarget { #value; #subscribers = new Set(); - #forceNotify; + #force; /** * @param {*} value - Initial value. * @param {object} [options] * @param {(a: *, b: *) => boolean} [options.equals] - Custom equality * check. Set as an instance override of the default `===` method. - * @param {boolean} [options.forceNotify=false] - If true, subscribers fire on - * every write, even when `equals` reports no change. The cached value + * @param {boolean} [options.force=false] - If true, subscribers are notified + * on every write, even when `equals` reports no change. The cached value * still respects `equals` (no-op writes don't update it). */ - constructor (value, { equals, forceNotify = false } = {}) { + constructor (value, { equals, force = false } = {}) { super(); this.#value = value; - this.#forceNotify = forceNotify; + this.#force = force; if (equals) { this.equals = equals; } @@ -74,7 +74,7 @@ export class Signal extends EventTarget { set value (v) { let same = this.equals(v, this.#value); - if (same && !this.#forceNotify) { + if (same && !this.#force) { return; } @@ -195,7 +195,7 @@ export class Computed extends Signal { })); } - // Delegate the equals/forceNotify decision to the Signal setter. + // Delegate the equals/force decision to the Signal setter. // Use Signal.prototype.value setter directly (bypasses no-op Computed setter). Object.getOwnPropertyDescriptor(Signal.prototype, "value").set.call(this, value); } From b0d1615c990e3cc8afd72772f4a142f7b619a40c Mon Sep 17 00:00:00 2001 From: Dmitry Sharabin Date: Wed, 6 May 2026 15:45:05 +0200 Subject: [PATCH 11/15] Minimize Signal setter diff via early-exit The forceNotify path is a 2-line branch inside the dedupe-bail's `if`, not a restructure of both branches. Behavior is identical: subscribers for a no-op write with force=true receive (currentValue, currentValue) either way. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/signals.js | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/signals.js b/src/signals.js index ec27deba..0be15878 100644 --- a/src/signals.js +++ b/src/signals.js @@ -73,15 +73,16 @@ export class Signal extends EventTarget { } set value (v) { - let same = this.equals(v, this.#value); - if (same && !this.#force) { + if (this.equals(v, this.#value)) { + if (!this.#force) { + return; + } + this.#notify(this.#value); return; } let old = this.#value; - if (!same) { - this.#value = v; - } + this.#value = v; this.#notify(old); } From 7ed8d2348037692902b51482d6acc37a0eec90be Mon Sep 17 00:00:00 2001 From: Dmitry Sharabin Date: Wed, 6 May 2026 15:54:42 +0200 Subject: [PATCH 12/15] Minimize Signal setter further --- src/signals.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/signals.js b/src/signals.js index 0be15878..a0deca07 100644 --- a/src/signals.js +++ b/src/signals.js @@ -74,10 +74,9 @@ export class Signal extends EventTarget { set value (v) { if (this.equals(v, this.#value)) { - if (!this.#force) { - return; + if (this.#force) { + this.#notify(this.#value); } - this.#notify(this.#value); return; } From e66446551161d4e182815de25e9910baf53b414c Mon Sep 17 00:00:00 2001 From: Dmitry Sharabin Date: Wed, 6 May 2026 17:12:22 +0200 Subject: [PATCH 13/15] Update comment --- src/plugins/props/util/Prop.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/plugins/props/util/Prop.js b/src/plugins/props/util/Prop.js index 21efefbf..83bbf7f0 100644 --- a/src/plugins/props/util/Prop.js +++ b/src/plugins/props/util/Prop.js @@ -104,9 +104,10 @@ let Self = class Prop { #onComputedChange (element, source, newValue, oldValue) { element.props[this.name] = newValue; - // `null` for defaults: don't synthesize an attribute (would clobber pre-mount - // writes — #105) and clear any stale attribute left over from a prior write. + // Reflect to attribute if this prop opts in if (this.toAttribute) { + // Don't synthesize attributes for defaults (it would clobber pre-mount writes; see #105). + // Clear any stale attribute left over from a prior write. let attributeValue = source === "default" ? null : this.stringify(newValue); let oldAttributeValue = element.getAttribute(this.toAttribute); if (oldAttributeValue !== attributeValue) { From 832858c6286ded6028b3e3ce3f7fa93aac2301fe Mon Sep 17 00:00:00 2001 From: Dmitry Sharabin Date: Thu, 7 May 2026 10:39:40 +0200 Subject: [PATCH 14/15] =?UTF-8?q?Rename:=20`force`=20=E2=86=92=20`notifyOn?= =?UTF-8?q?Equals`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/plugins/props/util/Prop.js | 4 ++-- src/signals.js | 16 ++++++++-------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/plugins/props/util/Prop.js b/src/plugins/props/util/Prop.js index 83bbf7f0..635b3042 100644 --- a/src/plugins/props/util/Prop.js +++ b/src/plugins/props/util/Prop.js @@ -122,7 +122,7 @@ let Self = class Prop { } } - // Gate event on value change (the Computed uses force). + // Gate event on value change (the Computed uses notifyOnEquals). if (this.equals(newValue, oldValue)) { return; } @@ -152,7 +152,7 @@ let Self = class Prop { // Delegate equality to the prop's type-aware equality. // Explicit user writes still reach the subscriber when // the Computed dedupes against a cached default-resolved value (#105). - let options = { equals: (a, b) => this.equals(a, b), force: true }; + let options = { equals: (a, b) => this.equals(a, b), notifyOnEquals: true }; if (this.spec.get) { signal = new Computed(() => this.spec.get.call(element), options); diff --git a/src/signals.js b/src/signals.js index a0deca07..d709d4da 100644 --- a/src/signals.js +++ b/src/signals.js @@ -47,21 +47,21 @@ function flushDirty () { export class Signal extends EventTarget { #value; #subscribers = new Set(); - #force; + #notifyOnEquals; /** * @param {*} value - Initial value. * @param {object} [options] * @param {(a: *, b: *) => boolean} [options.equals] - Custom equality * check. Set as an instance override of the default `===` method. - * @param {boolean} [options.force=false] - If true, subscribers are notified - * on every write, even when `equals` reports no change. The cached value - * still respects `equals` (no-op writes don't update it). + * @param {boolean} [options.notifyOnEquals=false] - If true, subscribers are + * notified on every write, even when `equals` reports no change. The cached + * value still respects `equals` (no-op writes don't update it). */ - constructor (value, { equals, force = false } = {}) { + constructor (value, { equals, notifyOnEquals = false } = {}) { super(); this.#value = value; - this.#force = force; + this.#notifyOnEquals = notifyOnEquals; if (equals) { this.equals = equals; } @@ -74,7 +74,7 @@ export class Signal extends EventTarget { set value (v) { if (this.equals(v, this.#value)) { - if (this.#force) { + if (this.#notifyOnEquals) { this.#notify(this.#value); } return; @@ -195,7 +195,7 @@ export class Computed extends Signal { })); } - // Delegate the equals/force decision to the Signal setter. + // Delegate the equals/notifyOnEquals decision to the Signal setter. // Use Signal.prototype.value setter directly (bypasses no-op Computed setter). Object.getOwnPropertyDescriptor(Signal.prototype, "value").set.call(this, value); } From eaa649c2d370f81f63f3a17811e0d9739f1d60bb Mon Sep 17 00:00:00 2001 From: Dmitry Sharabin Date: Fri, 8 May 2026 10:19:39 +0200 Subject: [PATCH 15/15] Replace `notifyOnEquals` with inline reflection in `prop.set` Also fix `applyChange` to honor explicit `null` attributeValue as "remove". --- src/plugins/props/util/Prop.js | 76 +++++++++++++++++++++------------- src/signals.js | 13 +----- 2 files changed, 50 insertions(+), 39 deletions(-) diff --git a/src/plugins/props/util/Prop.js b/src/plugins/props/util/Prop.js index 635b3042..c4626c86 100644 --- a/src/plugins/props/util/Prop.js +++ b/src/plugins/props/util/Prop.js @@ -104,28 +104,9 @@ let Self = class Prop { #onComputedChange (element, source, newValue, oldValue) { element.props[this.name] = newValue; - // Reflect to attribute if this prop opts in - if (this.toAttribute) { - // Don't synthesize attributes for defaults (it would clobber pre-mount writes; see #105). - // Clear any stale attribute left over from a prior write. - let attributeValue = source === "default" ? null : this.stringify(newValue); - let oldAttributeValue = element.getAttribute(this.toAttribute); - if (oldAttributeValue !== attributeValue) { - element.ignoredAttributes.add(this.toAttribute); - if (attributeValue === null) { - element.removeAttribute(this.toAttribute); - } - else { - element.setAttribute(this.toAttribute, attributeValue); - } - element.ignoredAttributes.delete(this.toAttribute); - } - } - - // Gate event on value change (the Computed uses notifyOnEquals). - if (this.equals(newValue, oldValue)) { - return; - } + // Defaults don't synthesize attributes (would clobber pre-mount writes; see #105); + // passing `null` clears any stale attribute left from a prior user write. + this.#reflect(element, source === "default" ? null : newValue); this.changed(element, { element, @@ -135,6 +116,30 @@ let Self = class Prop { }); } + /** + * The single reflection site, called from both user writes (`prop.set`) and + * dep-driven recomputes (`#onComputedChange`). Mirrors pre-signals' shape + * where `set()` was the one place attribute reflection happened. + * + * @param {HTMLElement} element + * @param {*} value - Post-convert value to reflect; `null` removes the attribute. + */ + #reflect (element, value) { + if (!this.toAttribute) { + return; + } + + let attributeName = this.toAttribute; + let attributeValue = this.stringify(value); + if (element.getAttribute(attributeName) === attributeValue) { + return; + } + + element.ignoredAttributes.add(attributeName); + this.applyChange(element, { source: "attribute", attributeName, attributeValue }); + element.ignoredAttributes.delete(attributeName); + } + /** * Get or lazily create the Signal for this prop on a given element. * - Computed props (spec.get): Computed that auto-tracks dependencies @@ -150,9 +155,7 @@ let Self = class Prop { if (!signal) { // Delegate equality to the prop's type-aware equality. - // Explicit user writes still reach the subscriber when - // the Computed dedupes against a cached default-resolved value (#105). - let options = { equals: (a, b) => this.equals(a, b), notifyOnEquals: true }; + let options = { equals: (a, b) => this.equals(a, b) }; if (this.spec.get) { signal = new Computed(() => this.spec.get.call(element), options); @@ -294,9 +297,24 @@ let Self = class Prop { if (rawSignal) { // Computed-backed: write to the raw signal. The Computed recomputes, - // and its subscriber (#onComputedChange) handles element.props, - // reflection, and events. + // and its subscriber (#onComputedChange) handles element.props and events. rawSignal.value = parsedValue; + + // Why reflect here too, when the subscriber already calls `#reflect`? + // - The Computed dedupes recomputes whose new value equals its + // cached value. `el.v = 5` when the default resolves to 5 produces + // no subscriber fire, so reflection from `#onComputedChange` is + // skipped (#105). + // - We can't read `signal.value` to get the post-convert form: that + // would force a sync recompute, fire subscribers immediately, and + // split multi-prop dep cascades across two propchange drains. + // So apply `spec.convert` manually and call `#reflect` directly. + if (source === "property" && parsedValue !== undefined) { + this.#reflect( + element, + this.spec.convert ? this.spec.convert.call(element, parsedValue) : parsedValue, + ); + } } else { // For plain props: update signal, element.props, reflect, and fire events @@ -347,7 +365,9 @@ let Self = class Prop { if (element.setAttribute) { let attributeName = change.attributeName ?? this.toAttribute; let attributeValue = - change.attributeValue ?? change.element.getAttribute(attributeName); + change.attributeValue !== undefined + ? change.attributeValue + : change.element.getAttribute(attributeName); if (attributeValue === null) { element.removeAttribute(attributeName); diff --git a/src/signals.js b/src/signals.js index d709d4da..474a573c 100644 --- a/src/signals.js +++ b/src/signals.js @@ -47,21 +47,16 @@ function flushDirty () { export class Signal extends EventTarget { #value; #subscribers = new Set(); - #notifyOnEquals; /** * @param {*} value - Initial value. * @param {object} [options] * @param {(a: *, b: *) => boolean} [options.equals] - Custom equality * check. Set as an instance override of the default `===` method. - * @param {boolean} [options.notifyOnEquals=false] - If true, subscribers are - * notified on every write, even when `equals` reports no change. The cached - * value still respects `equals` (no-op writes don't update it). */ - constructor (value, { equals, notifyOnEquals = false } = {}) { + constructor (value, { equals } = {}) { super(); this.#value = value; - this.#notifyOnEquals = notifyOnEquals; if (equals) { this.equals = equals; } @@ -74,9 +69,6 @@ export class Signal extends EventTarget { set value (v) { if (this.equals(v, this.#value)) { - if (this.#notifyOnEquals) { - this.#notify(this.#value); - } return; } @@ -195,8 +187,7 @@ export class Computed extends Signal { })); } - // Delegate the equals/notifyOnEquals decision to the Signal setter. - // Use Signal.prototype.value setter directly (bypasses no-op Computed setter). + // Bypass Computed's read-only setter; Signal#set handles the equals dedupe. Object.getOwnPropertyDescriptor(Signal.prototype, "value").set.call(this, value); } }