Fix early-return conflation in Prop.set (oldValue + propchange skip)#122
Fix early-return conflation in Prop.set (oldValue + propchange skip)#122DmitrySharabin wants to merge 1 commit into
Conversation
✅ Deploy Preview for nude-element ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
48cd5c9 to
bd3dc85
Compare
367075e to
2e0062a
Compare
Was? Wait, is it now populated with the default? That's wrong. Then how can we tell the difference between an explicitly written value that happens to be the same as the default vs a prop that's just set to its default value? Caching resolved values makes sense, but we should still have access to the actual, real internal value. They shouldn't clobber it. |
2e0062a to
3deda12
Compare
The single `if (equals(parsedValue, oldValue)) return;` in `set()` skipped three concerns at once: attribute reflection, propchange dispatch, and the cache write. Because `element.props[name]` was never populated with a default, `oldValue` was `undefined` for the first write — masking the real bug behind a false equality. Fix: - `initializeFor` now caches resolved literal defaults so `oldValue` and the equality check see what the getter returns. Function defaults stay lazy; `defaultProp` resolves via the dependency cascade. - `set()` runs the reflection branch before the equality check, preserving the "explicit equal write reflects" contract (#113). The attribute- mismatch gate continues to skip redundant DOM writes. Adds a regression test pinning `propchange.detail.oldValue` to the resolved default on first write. Uncovered while implementing propchange coalescing — the latent bug surfaced once consumers started reading `detail.oldValue` on coalesced events. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
3deda12 to
2f3fe15
Compare
|
I think caching resolved values is the right way to go, we just need to cache them separately. With the recent change, it now resolves old values based on current data. But this may have changed, e.g. if the default value is dynamic. When we're monitoring for changes, we're usually interested in changes in resolved values. I'm not sure we're ever interested in changes in internal values, but this will become clearer with use cases. Also, we currently have this class-wide Another thing to keep in mind is that down the line we want to split this plugin into separate plugins:
|
|
Took your feedback and reworked the design. Wanted to run it by you before implementing. Two stores
The two stores never clobber each other. The current PR's "resolve default on the fly inside Responsibility split
APIclass ElementProps {
getExplicit(name) getCached(name)
hasExplicit(name) hasCached(name)
set(name, value) // both stores (the Prop.set happy path)
cache(name, value) // cache only (after the getter resolves a default)
reset(name) // both stores (e.g., removeAttribute → undefined)
invalidate(name) // cache only (defaultProp cascade)
}One verb per intent. Reads pair with How
|
| Location | Diff |
|---|---|
initializeFor — "at default" guard |
- else if (element.props[name] === undefined && !this.defaultProp) { |
get — explicit read (×2 sites in the resolution chain) |
- let value = element.props[this.name]; |
set / update — oldValue (semantic shift: now reads cached resolved) |
- let oldValue = element.props[this.name]; |
dependsOn — "at default" check |
- (this.defaultProp === prop && element.props[this.name] === undefined) |
Three structural changes — each its own diff:
set write site — undefined → reset, value → set:
- element.props[this.name] = parsedValue;
+ if (parsedValue === undefined) {
+ element.props.reset(this.name);
+ } else {
+ element.props.set(this.name, parsedValue);
+ }set — remove the lazy resolution branch this PR added; this.get(element) above already populates the cache and oldValue reads from it:
- // Resolve the default lazily so equality and propchange.oldValue match the getter.
- if (
- oldValue === undefined &&
- !this.spec.get &&
- this.default !== undefined &&
- typeof this.default !== "function"
- ) {
- oldValue = this.get(element);
- }get — cache-check fast path on entry; populate-on-miss before return:
get (element) {
+ if (element.props.hasCached(this.name)) {
+ return element.props.getCached(this.name);
+ }
+
let value = element.props.getExplicit(this.name);
// … existing resolution chain unchanged …
+ element.props.cache(this.name, value);
return value;
}update — defaultProp cascade captures oldResolved before invalidating, threads it through:
if (dependency === this.defaultProp) {
- this.changed(element, { element, source: "default" });
+ let oldResolved = this.get(element);
+ element.props.invalidate(this.name);
+ this.changed(element, { element, source: "default", oldValue: oldResolved });
return;
}Cache lifecycle
- Populated on every
Prop.get(element)(cache-miss runs the resolution chain, thencache(name, value)), and on every successfulProp.setwrite. - Invalidated explicitly only on the
defaultPropcascade (invalidate) and on attribute removal (reset). Forspec.get/convertcascades, the dependent's ownset()overwrites the cache atomically — no separate eviction needed. oldValuealways reads from the cache viaProp.get(element)(populates on miss). For dynamic defaults that change between reads, this captures what consumers actually saw — not a fresh re-resolution against current data.
Test pinning the dynamic-default fix
let counter = { value: 1 };
defineProps({ size: { type: Number, default: () => counter.value } });
// counter is closed-over external state; inferDependencies only matches `this.x`,
// so no defaultProp is created and no cascade fires when counter changes.
element.size; // → 1, cache captures 1
counter.value = 2; // no cascade — cache stays at 1
element.size = 5; // explicit write
propchange.detail.oldValue === 1 // ← cached value consumers actually sawUnder this PR currently: oldValue === undefined (the lazy-resolution branch in set() excludes function defaults). Under the proposed design: oldValue === 1.
What this leaves alone
- Future plugin split (reflection / defaults / convert / computed) not blocked by this design.
Open question: verb migration in this PR?
The proposal above moves the storage onto ElementProps but keeps the methods (set / get / update / initializeFor) on Prop with (element, …) signatures. That half-addresses your "weird hybrid OOP-procedural" point. Wanted to give you a clear prompt to decide whether to go the rest of the way in this PR.
Call sites:
// Today + proposal as written:
prop.set(element, value, { source });
prop.get(element);
prop.update(element, dep);
// With verb migration:
element.props.set(name, value, { source });
element.props.get(name);
element.props.update(name, dep);What changes structurally:
Propshrinks to a pure definition object:name,spec,type,dependencies,parse,stringify,equals,reflectconfig. No methods that takeelement.ElementPropsowns the per-element runtime in full — storage and verbs.Props(class-level registry) still holds prop definitions and the dependency graph; unchanged otherwise.
What we get:
- Full fix for the OOP-procedural concern — receiver always matches scope.
- Call shape reads more naturally throughout the plugin internals.
- Future plugin-split PR becomes a focused carve — no structural move on top of the semantic work.
Propbecomes the obvious extension point for plugin-contributed metadata, since it's pure definition.
Cost:
- Roughly 2× this PR's diff. ~80 lines of method bodies migrate from
ProptoElementProps; call sites inProps.jsandProp.getDescriptorupdate accordingly. - Mechanical — no behavior change, no new abstractions. Covered by the existing test suite plus the new dynamic-default test.
Trade-off:
Total refactoring work is the same either way — the method bodies eventually move and get carved into plugin hooks. Question is whether the move lands here or in the plugin-split PR. Doing it here means this PR addresses your architecture point in full; deferring keeps this PR tightly scoped to the bug fix.
Your call. Full spec is in a local doc (lifecycle invariants, full migration table with file:line refs, complete test plan); happy to push it to the branch if useful. Does the rest of the design land where you'd want, and do you want the verb migration folded in?
|
I think that adding So, this might be a good first step if done right. Speaking of the API, it feels like a bit too much hustle to do simple things: we should know what method to call to store a value (either as an explicit value, resolved, or both). Having two maps might also make them out of sync (probably, not sure). I honestly don’t know what a good design might look like here. |
Agreed.
Yup. I think Claude has overcomplicated this, as it often does. I suspect the reason might be that we are trying to do it as a drive-by part of another PR, that is ultimately unrelated. I suspect we'll have better results if it's done as a separate piece of work. But And we may end up having to introduce |
|
I'm going to close this in favor of #129. |
Summary
set()inProp.jshad a singleif (equals(parsedValue, oldValue)) return;that skipped three concerns at once — attribute reflection, propchange dispatch, and the storage write. Becauseelement.props[name]was never populated with a literal default,oldValuewasundefinedon the first write, masking the bug behind a false equality.This PR splits those concerns:
set()resolves the default lazily whenelement.props[name]isundefinedand the prop has a literal default. That gives the equality check andpropchange.detail.oldValuethe same value the getter would return — without writing intoelement.props[name], which stays as the "explicit value" indicator. Function defaults stay lazy (their original call-site timing);defaultPropresolves through the dependency cascade.set()runs the reflection branch before the equality check, preserving the "explicit equal write reflects" contract (Roll back signals and batching and reimplement them #113). The inner attribute-mismatch gate still skips redundant DOM writes.New regression test pins
propchange.detail.oldValueto the resolved default on first write — the only test in the suite asserting ondetail.oldValue. The pre-existingAssigning current default-resolved value is a no-opbaseline was also tightened to use a strict count delta (events.length - before === 0) rather than relying on hTest's prefix-based array equality, which silently allowed the bug.Uncovered while implementing propchange coalescing — the latent bug surfaced once consumers started reading
detail.oldValueon coalesced events.Evolution
The first version of this PR cached resolved literal defaults into
element.props[name]duringinitializeFor. @LeaVerou flagged that this collapses two distinct states ("explicit equal write" vs "at default") into one. The current version resolves lazily inset()instead, preserving the distinction.Test plan
npm test— both new and pre-existing target tests pass with the fixProp.jsis reverted (RED state verified)Explicit write equal to the default still reflects to the attribute(the Roll back signals and batching and reimplement them #113 contract) still passeselement.props[name]remainsundefinedfor "at default" state — Lea's invariant preserved🤖 Generated with Claude Code