You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
fix(reactor): §8.2 — setters run inside echo-suppression scope
`ApplySetters` previously ran outside any echo-suppression scope, so a
user-authored `.Set(c => c.X = ...)` write on a value-bearing control
echoed back into the OnXChanged callback the engine had already wired.
The headline reproducer (M13 in spec 047 Phase 0):
`ToggleSwitch(false, onIsOnChanged: …).Set(ts => ts.IsOn = true)` fires
the callback once at mount, which then writes the engine-driven value
back into the owning component's state — the same shape as the spec-030
cross-row swap, just triggered by the setter chain instead of an Update
path.
This carve-out plumbs a scope-based suppression mode on the control's
`ReactorState` for the duration of `ApplySetters`. A depth counter on
`ReactorState` (`EchoSuppressScopeDepth`) sits alongside the existing
paired counter; `ShouldSuppress` drops events when the scope depth is
nonzero without consuming a token, so the existing paired
BeginSuppress / change-event flow keeps working for declarative property
writes. Scope is only entered when `ReactorState` already exists, so
bare leaves (TextBlock / RichTextBlock) don't pay an extra allocation.
Validation:
- M13 `OnIsOnChangedFireCount` flips from 1 → 0 across all 5 reps on
both ReactorToday and ReactorV2 (was the §8.2 baseline witness in
PR #411). Phase-0 JSONL is left intact as the pre-fix witness; the
follow-up note in `baseline-results/summary.md` records the flip.
- Reactor.Tests: 9036 passed, 0 failed.
- Reactor.SelfTests: 827 passed; three new SettersScope_* fixtures
(ToggleSwitch / Slider / NumberBox) lock the behavior with a
mount-no-echo + user-edit-still-fires pair so a future regression
on any of the three shapes surfaces immediately.
Spec docs updated: §8.2 marked Resolved, factoring-recommendation.md
carve-out marked landed, task file 0.7 + M13 lines note the fix.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy file name to clipboardExpand all lines: docs/specs/047-extensible-control-model.md
+2Lines changed: 2 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -458,6 +458,8 @@ This is the version of the design where the framework gets a *fundamentally diff
458
458
459
459
A latent issue worth fixing as part of this work, called out in §13 Q8 below: `ApplySetters` runs after declarative property writes today and bypasses any echo suppression scope. A user writing `Set(ts => ts.IsOn = true)` on a `ToggleSwitch` whose `el.IsOn = false` produces an unmasked write that *will* fire `Toggled` and feed back into state on the next event-loop tick. The descriptor model should require setters to either run inside the same suppression / round-trip scope as declared props, or be opted into an explicit raw-write mode (`Set.Raw(...)`) that the author has audited and accepted responsibility for. Default behavior should match declared props.
460
460
461
+
**Resolved** as a carve-out ahead of Phase 1 (per [`047/factoring-recommendation.md`](047/factoring-recommendation.md)). `ApplySetters` now enters a scope-based suppression mode on the control's `ReactorState` (a depth counter alongside the existing paired `EchoSuppressCount`) for the duration of the setter chain, so any change event raised by a setter-driven write is dropped without consuming a paired token. The M13 perf-bench check flips from `OnIsOnChangedFireCount = 1` to `0` on both `ReactorToday` and `ReactorV2`. Default behavior now matches declared props as called for above; an explicit raw-write opt-out (`Set.Raw(...)`) is still a future refinement should it become needed.
0 commit comments