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
Following the Forms guide's Validation Context example, clicking Register on an empty form submitted successfully with no errors. That example is not merely mis-documented — it tripped five compounding defects.
Root cause
.Validate() was inert outside FormField. It only attached a ValidationAttached record; the sole consumer was CompositeLifecycle.Mount/UpdateFormField. A bare TextBox(...).Validate(...) registered no field and produced no message, so MarkAllTouched() marked nothing and IsValid() was trivially true. This contradicts spec 011 §1A.5, which is checked off but was only ever implemented for FormField.
The context was never provided.UseValidationContext() returned a component-local context it never published, so FormField and the visualizers couldn't find it either.
Reading the context in Render() was one pass stale, because validation ran during reconcile, after the tree was built.
Context mutations scheduled no re-render, so an invalid submit repainted nothing.
FormField's default ShowWhen.WhenTouched could never fire. The guide promises errors appear "after the field is touched (focus then blur)", but nothing ever called MarkTouched.
Approach
A render-pass-scoped ValidationRenderScope, opened by the reconciler around every Render(), links the rendering component's context to the value-carrying .Validate(field, value, …) overload:
Validators run during render. C# evaluates arguments left to right, so .Validate(…) produces the verdict that a later When(ctx.HasError(…)) sibling reads in the same pass.
The context is auto-provided to the rendered subtree when it is component-local.
ValidationContext.Changed (new public event) repaints subscribers when the context is mutated.
FormField marks its field touched on blur.
Loop safety is load-bearing throughout, because CreateComponentRerender re-renders inline:
Results are applied as one diffed replacement per producer, so re-running the same validators over an unchanged value is silent.
Each producer retracts only the exact instances it contributed, so a passing rule cannot erase a required-field error.
Add/Clear*/MarkTouched/MarkAllTouched/Reset/ResetAll all bump and notify only on a real state delta.
A notification raised during a render is deferred to the end of the outermost render frame and posted through the UI dispatcher, so it lands after the in-flight reconcile instead of re-entering it.
Review rounds
This went through a long automated-review loop. Every finding was reproduced against the source before being acted on; a few were disproven and declined with evidence (an identical-message producer collapse that did not reproduce, a "stale test" at lines that held no such test, a Where suggestion on a non-filtering loop).
The loop did not converge to zero, and it is worth saying why rather than implying it did:
The reviewer re-scans unchanged code each round. Later rounds reported "Findings: None" alongside a collapsed "Previously missed" block explicitly labelled "in code that hasn't changed since last review." It samples rather than exhausts, so "loop until zero comments" is not obviously a reachable state.
Each fix added surface the next round found. Ownership stamps produced a stamp-leak and a TOCTOU finding; the claim mechanism produced an aborted-render finding; the ownership keys produced the shared-slot finding below.
Findings drifted from the reported bug — from "the form submits while empty" toward Version churn under a pathological chain and partial batches on a throw. All real; all increasingly remote.
The loop was stopped deliberately at that point, with everything found so far fixed and CI green, rather than continued indefinitely.
The substantive classes of defect it surfaced, beyond the original five:
Verdicts outliving their control. A validated control behind a condition, a whole FormField, a removed child (which leaves via the pooling traversal, not the ordinary unmount), a root-host render, and an element built then dropped — each could leave a message in the context owned by nothing, keeping a form invalid over a field with no control.
Ownership identified by the wrong thing. Re-resolving the context and stamp at mount time is guesswork when an explicit .Provide(...) separates them, or when two siblings validate one field. The eager write now records what it actually reached, and the control inherits that exact claim.
Net-zero passes that were not silent. Chained value overloads churn the current value every render and land where they started; both Changed and Version had to learn to tell churn from change. With the comparison stubbed out the fixture's settle loop goes from 0 additional renders to ~48,000.
Non-atomic operations. A stamped retirement split across two lock acquisitions could delete a newer verdict; a rule batch installed earlier verdicts before rejecting a later async rule.
Notes for reviewers
Eight render call sites, not six: Reconciler.Mount.cs ×3, Reconciler.cs ×3, plus ReactorHost and ReactorHostControl root renders. The selftest fixtures caught the root sites — without them a single-component app, which is what the reporter writes, would have missed the fix entirely.
.Provide semantics are documented, not changed. An explicit provide is what descendants resolve; it does not redirect the providing component's own .Validate() calls, which already ran while the tree was built — the same reason UseContext can't see a value the same component provides.
skills/forms.md documented five APIs that don't exist (.IsValid/.IsDirty as properties, .ValidateAll(), .Reset(), positional FormField("Label", input), .Validate(ctx, "name", …)); corrected.
The forms guide documented ValidationContext.IsValidating, which does not exist, and claimed Validate.MustAsync runs automatically. The async section now states the real contract and the example is a compiled doc-app snippet.
ValidationContextTests.Version_Increments_On_Touch_And_Reset encoded the old unconditional-bump contract and now asserts both halves of the new one.
Known limits (deliberate, not oversights)
Two elements naming one field share the synchronous producer slot, so the last writer's verdict wins its contents. This is pre-existing behaviour. What this PR fixes is the erasure case found in review — a dropped element's claim clearing the slot a mounted control depends on, reporting an invalid field as valid. Giving each attachment chain a stable producer identity would fix the sharing too, but it has to be threaded through the render claim, the mounted binding andFormField's reconcile-time re-validation, which still uses the flat key. That is a design change to ownership and belongs in its own PR.
Stale-field pruning was deliberately not added. It would key off "did .Validate() run this pass", but a UseMemo-cached subtree doesn't re-invoke .Validate() while still on screen, so it would silently discard live validation for memoized forms.
Two callers sharing a named method predicate at the same position share a rule slot — use setId.
Some concurrency reasoning is correct by construction, not test-pinned. The lock relocations and the atomic begin have no deterministic UI-thread schedule that exercises the window they close; a timing probe there would pass for the wrong reason. Flagged rather than papered over.
Validation
Gate
Result
dotnet test tests/Reactor.Tests
14,258 total / 0 failed
Full selftest suite
0 failed
dotnet test tests/Reactor.DocPipeline.Tests
472 total / 0 failed
Release gate (split restore + build)
0 errors
Every oracle added for a review finding is mutation-probed — the fix is reverted and the check confirmed to redden. Two probes caught vacuous tests of my own: a rule regression mounted at the host root (where re-render only schedules, so the loop never formed) and a removal fixture whose validated control sat first in the children, so shortening the list replaced it instead of removing it and never touched the pooling path.
A process note worth recording: several rounds were slowed by incremental builds leaving the selftest host running a stale Reactor.dll, which produced four false results — tests failing on correct code and passing on mutated code. Mutation results here were re-taken with bin/obj cleared.
Verified end-to-end in the live docs app: empty submit blocks and shows both errors, the email error swaps between required and format as the value changes, errors clear per-field as they become valid, and a valid form submits and disables the button.
Following the Forms guide's "Validation Context" example produced a form that
submitted while empty. Five defects compounded:
1. `.Validate()` only attached a ValidationAttached record. The sole consumer
was CompositeLifecycle's FormField path, so validators on a plain control
never ran: no field registered, no message produced, IsValid() trivially
true. This contradicts spec 011 1A.5, which was checked off but only ever
implemented for FormField.
2. UseValidationContext() returned a component-local context it never
provided, so FormField and the visualizers could not find it either.
3. Validation ran during reconcile, after Render() had built the tree, so a
component reading the context inline was always one pass stale.
4. Mutating the context scheduled no re-render, so an invalid submit repainted
nothing.
5. FormField's default ShowWhen.WhenTouched could never fire: the guide
promises errors appear "after the field is touched (focus then blur)", but
nothing ever called MarkTouched.
A ValidationRenderScope, opened by the reconciler around every render, links
the rendering component's context to the value-carrying .Validate() overload so
validators run during render and are readable by the same pass. The hook
publishes a local context to the rendered subtree (an explicit .Provide still
wins), subscribes to a new ValidationContext.Changed event to repaint, and
FormField marks its field touched on blur.
Loop safety: requestRerender runs inline and throws past MaxRerenderReentrancy,
so results are applied through a diffing ReplaceInternal (identical
re-validation is silent) and Changed is suppressed while a render is in flight.
Also fixes NotifyValueChanged discarding external messages unconditionally,
which per-render validation would otherwise make destructive to AddExternal
server errors.
Fixes#1262
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 373c7212-34f6-4ddf-8ae9-59553c77cf48
Artifact sizes for 148e2eb vs the base branch (6d783c5).
Packages (compressed .nupkg)
Artifact
base
PR
Δ
Microsoft.UI.Reactor.nupkg
1.72 MB
1.75 MB
+30.1 KB (+1.71%)
⚠️
Microsoft.UI.Reactor.Advanced.nupkg
487.6 KB
487.6 KB
-10 B (0.00%)
≈
Microsoft.UI.Reactor.Devtools.nupkg
284.1 KB
284.0 KB
-5 B (0.00%)
≈
Assemblies in Microsoft.UI.Reactor
Artifact
base
PR
Δ
Reactor.Analyzers.dll
373.0 KB
373.0 KB
+0 B (0.00%)
≈
Reactor.dll
2.44 MB
2.47 MB
+31.0 KB (+1.24%)
⚠️
Reactor.Localization.Generator.dll
16.0 KB
16.0 KB
+0 B (0.00%)
≈
Reactor.Wrappers.Abstractions.dll
10.5 KB
10.5 KB
+0 B (0.00%)
≈
Reactor.Wrappers.Generator.dll
99.5 KB
99.5 KB
+0 B (0.00%)
≈
Assemblies in Microsoft.UI.Reactor.Advanced
Artifact
base
PR
Δ
Reactor.Advanced.dll
1021.5 KB
1021.5 KB
+0 B (0.00%)
≈
Assemblies in Microsoft.UI.Reactor.Devtools
Artifact
base
PR
Δ
Microsoft.UI.Reactor.Devtools.dll
785.0 KB
785.0 KB
+0 B (0.00%)
≈
✅ smaller / ⚠️ larger / ≈ within noise. Sizes come from a Release dotnet pack on the CI runner: packages are the compressed .nupkg download size, assemblies the uncompressed DLL inside it. workflow run.
Coverage for 148e2eb vs the base branch (6d783c5) — unit + selftest merged.
Metric
base
PR
Δ
Line
85.79%
85.95%
+0.16 pp
✅
Branch
77.58% (962/1240)
77.40% (966/1248)
-0.18 pp
⚠️
✅ higher / ⚠️ lower / ≈ within noise. Δ is in percentage points; coverage is unit + selftest merged (Debug x64) on the CI runner. Cobertura reports attached to the workflow run as artifacts.
Add every registered field unconditionally and compare the set size instead of
branching on HashSet.Add's return inside the loop. The suggested .Where(...)
rewrite would reach the same answer with a second hash lookup per field plus a
LINQ allocation, all inside the lock.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 373c7212-34f6-4ddf-8ae9-59553c77cf48
Four findings from the first review round, all reproduced before fixing:
- A failing ValidationRule drove the reconciler into its re-render re-entrancy
limit. Rules evaluate during reconcile, outside the render scope, and
cleared-then-re-added their message every pass, so each pass looked like a
change and requested another render. Applying the verdict as one diffed
ReplaceInternal makes re-evaluation silent. Restoring the old code in the new
regression drives 1604 renders and a version climbing 3207 -> 4763 in a single
pass, so the test is not vacuous.
- ValidateField/ValidateAttached registered the field, recorded the value and
installed results as three calls, so a subscriber woke mid-update and
re-rendered against the previous pass's messages. Replaced with a single
atomic ValidationContext.ApplyValidation: one lock, one version bump, one
notification raised only once everything is in place.
- The FormField blur binding outlived its field. TextBox is poolable, so a
control rented back out elsewhere kept marking the old context. It is now
cleared on unmount and whenever an update has no context or field. The lookup
is keyed by FormField root rather than walking the panel's children, so
unmount never reads the visual tree mid-teardown.
- The explicit-provide test was vacuous: FormField re-validated during reconcile
and masked where eager validation actually landed. Rewritten against a bare
control, and the docs now state the real semantics -- an explicit .Provide is
what descendants resolve, and does not redirect the providing component's own
.Validate() calls, which already ran while the tree was built.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 373c7212-34f6-4ddf-8ae9-59553c77cf48
Suppressing Changed whenever any render is active also drops notifications needed by other components sharing this context. For example, a subscribed parent can render ctx.IsValid() and provide the context, then a child’s eager .Validate() changes it while InRender is true; this return prevents the parent from repainting, so its summary or submit state remains stale. Suppress only the currently rendering component’s reentrant callback, or defer a notification until that render exits, while still notifying other subscribers; add a parent/child regression.
…binding swap
- Changed notifications raised mid-render are now deferred to the end of the
outermost render frame instead of dropped. Dropping them kept the rendering
component correct but starved every other subscriber: a parent that renders
ctx.IsValid() and provides the context never learned that a child's eager
.Validate() had invalidated it. Delivery is posted through the UI dispatcher
when one exists so it lands after the in-flight reconcile, and falls back to
an inline raise in headless hosts.
- Reset/ResetAll bumped Version and notified even with nothing to reset, so an
effect that reset on each render could repaint forever. Both now compute a
real state delta, matching ClearAll and MarkAllTouched. Reset no longer
creates a _currentValues entry for a field the context has never seen.
- The re-render subscription used UseState(threadSafe: true), whose setter
invokes the re-render callback on the calling thread; only the default setter
marshals. An async validator raising Changed from a worker could therefore
re-enter the reconciler off-thread. Interlocked already guards the ticket, so
the default marshaling setter is used instead.
- Re-pointing a FormField root at a new content control left the displaced
binding live, so a swapped-out editor could still mark the old field once
pooled and rented elsewhere. ReplaceRootBinding clears the previous binding
first.
ValidationContextTests.Version_Increments_On_Touch_And_Reset encoded the old
unconditional-bump contract and now asserts both halves of the new one.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 373c7212-34f6-4ddf-8ae9-59553c77cf48
- Issue1262_FailingRuleDoesNotLoop kept its probe state in static fields written
from RuleOwner.Render(). The probe is now an instance object passed through
Component<RuleOwner, RuleOwnerProps>, so nothing static is mutated and the
fixture carries no cross-run state.
- Both new fixtures asserted a control was non-null through H.Check and then
dereferenced it with the null-forgiving operator. They now bail out after the
check, so a missing control fails the check instead of throwing later.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 373c7212-34f6-4ddf-8ae9-59553c77cf48
Although ReplaceInternal is diffed, this first replaces the prior verdict with [] before awaiting. If an async rule was already failing, each re-evaluation therefore raises Changed once for the clear and again for the identical failure, briefly makes the context valid, and causes two repaints; the claim that an unchanged outcome stays silent is not true. Keep the previous verdict while the check is in flight and apply only the final replacement (or otherwise coalesce the two mutations).
…ble-raise
- SetInitialValue rewound _currentValues on every call. Components commonly run
RegisterField / SetInitialValue / NotifyValueChanged together on each render
(the repo's own DirtyResetDemo does), so once the user had typed, the rewind
and the re-notify took turns and the new change notification repainted
forever. The current value is now seeded only the first time a field is seen;
Reset remains the way to deliberately return a field to its baseline.
- ValidationRule.EvaluateAsync cleared the previous verdict before awaiting, so
an already-failing async rule raised Changed twice per evaluation and briefly
reported the field valid in between. The verdict is now kept in flight and
replaced once, which also makes the "unchanged outcome stays silent" property
actually true for the async path.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 373c7212-34f6-4ddf-8ae9-59553c77cf48
…micity, re-baseline
- ReactorHostControl never seeded ReactorApp.UIDispatcher, which ReactorHost
does precisely for embedded hosts. Cross-thread setState resolves its marshal
target from that static, so a background async validator raising Changed made
the re-render subscription throw instead of repainting in a standalone host.
- ValidationReconciler.ValidateFieldAsync added each result as it resolved: a
field with two failing async validators exposed a partial verdict, repainted
between messages, and appended duplicates on every re-run because nothing
retracted the previous pass. The whole result is now installed in one atomic
ApplyAsyncValidation, which retracts the exact instances the last async pass
contributed so synchronous validator messages survive.
- SetInitialValue could flip IsDirty without notifying, leaving subscribers
stale after a re-baseline. It is now delta-gated on the dirty result, so
adopting an edited value as the new baseline notifies once and an unchanged
re-seed stays silent.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 373c7212-34f6-4ddf-8ae9-59553c77cf48
A field's internal messages are written by several independent producers: the
synchronous .Validate() chain, each cross-field ValidationRule, and the async
validator pass. Every one of them replaced the whole field, so the last writer
won -- a passing rule could erase a required-field error and report the form
valid. Messages are now owned per producer: each retracts exactly the instances
it contributed last time and leaves the rest alone.
- Replacement happens in place rather than remove-then-append. Appending would
let two producers on one field swap positions every render, and an order change
is a structural change, so it would notify on every pass -- the very loop this
design exists to prevent. Interleaved_Producers_On_One_Field_Settle pins it.
- Ownership is now only repointed when the diff actually installed the new
instances. Overwriting it after an unchanged diff left nothing to retract, and
the pass after that appended a duplicate -- a bug introduced in the previous
round and caught here on the third identical async pass.
- Async passes carry a per-field generation token, so an older value's check that
resolves after a newer one's is discarded instead of overwriting the current
verdict with a stale error.
ValRule_AsyncPass asserted the old behaviour: it cleared a failing rule's error
by evaluating a *different*, passing rule on the same field. Rewritten to
re-evaluate the same rule, plus a new check that an unrelated passing rule leaves
another producer's error in place.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 373c7212-34f6-4ddf-8ae9-59553c77cf48
A host's root Render() runs in its own frame and closes it before Reconcile
opens the reconcile frame. Clearing claims on any top-level Begin therefore
discarded every root-level .Validate() claim in the gap between the two, so a
bare validated control rendered directly by ReactorHost never got a binding and
left its verdict behind when it was removed. Only a render frame starts a new
pass now; the reconcile frame is the consumer of what the render just claimed,
and does not clear.
That leaves the question of claims nobody takes. An element built inside a
render and then dropped has already written its verdict, and nothing will ever
be mounted to own it. The reconcile frame retires whatever it did not hand to a
control, before the depth drops so the retractions join the pass's own deferred
batch rather than announcing one at a time on the way out.
RetireProducer's stamped overload compared and withdrew under two separate lock
acquisitions. A writer installing a newer verdict in the window between them
would have it deleted by the check that exists to protect it; both halves now
share one acquisition, with Changed raised outside it as everywhere else.
Two documentation corrections. The Changed event said render-time mutations are
"not announced", which stopped being true when deferral was added - they are
held and delivered once the pass ends, and only dropped when the pass is
net-zero. And the changelog credited the visualizers as a consumer that ran
attached validators; they never did, and FormField was the only one.
ValCov_Issue1262 gains a root-host removal and a discarded-element case.
Reverting the reconcile frame to clear reddens both; disabling the unconsumed
retirement reddens the discarded one alone.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 373c7212-34f6-4ddf-8ae9-59553c77cf48
A sync-then-async chain loses the sync verdict's lifetime owner here. SupersedeEarlierLink removes the earlier attachment's ownership, but this overload never records ownership for the merged attachment. For a bare control, .Validate("f", value, ...).ValidateAsync("f", ...) therefore leaves the sync message behind after the control unmounts. Re-run only the merged synchronous validators when the chain already carries a value so the final attachment takes over the claim.
This issue also appears on line 153 of the same file.
Dropping t_owned here can orphan validation messages after a root render aborts. A root .Validate(...) writes its verdict before Render() returns; if a later expression throws (or the host returns before Reconcile), no reconcile frame consumes or retires the claim. The next render clears this map without calling RetireProducer, so a recovered tree that no longer renders the field retains the stale error indefinitely. Retire abandoned claims on the host's exceptional/no-tree paths, or when starting the next frame under notification deferral, rather than discarding them.
Two cases where a verdict was written and then left with no lifetime owner.
A chain ending on an async link lost its sync verdict's owner.
SupersedeEarlierLink takes the earlier attachment's claim, but the async
overloads never made one of their own, so .Validate(f, v, ...).ValidateAsync(f,
...) left a live sync message that no mounted control could withdraw - a bare
control kept it after unmount. Both async overloads now re-run the merged
synchronous validators when the chain already carries a value, so the surviving
attachment takes the claim over. The async validators are still attach-only:
RunDuringRender does nothing when there are no sync validators, so a purely
async attachment is unaffected.
A root render that writes a verdict and then throws never reaches
reconciliation, so nothing consumes or retires the claim it made. Claims left
over when a new render pass opens are now withdrawn rather than discarded -
after the frame is open, so the retractions defer into that pass instead of
announcing inline at depth 0.
Two fixtures, each reddening alone: a sync-then-async chain that is removed,
and a root render that aborts after validating and recovers into a tree without
the field.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 373c7212-34f6-4ddf-8ae9-59553c77cf48
The net-zero suppression does not snapshot _initialValues, even though SetInitialValue can change IsDirty without changing _currentValues. If a render re-baselines a dirty field, RaiseChanged marks non-message state as touched, but this method reports it unchanged and DeliverDeferred drops the notification; another component subscribed to the same context can therefore keep rendering stale dirty state. Include initial values (or the resulting dirty state) in the captured/comparison snapshot, and add an in-render re-baselining regression test.
Correct section count to reflect four behaviors
docs/_pipeline/templates/forms.md.dt:190
The section introduces four behaviors, not three: render-time validation, automatic context provision, context-driven repainting, and FormField blur handling.
Net-zero suppression snapshotted current values, touched flags and the
registered set, but not the baselines. SetInitialValue re-baselining an edited
field flips IsDirty without touching any of those, so a component that
re-baselines during render marked non-message state dirty and then had its
notification dropped as "nothing changed" - another subscriber rendering dirty
state kept the stale one. Baselines are now captured and compared alongside the
values.
The forms guide's async section documented ValidationContext.IsValidating,
which does not exist anywhere in the tree, and said Validate.MustAsync runs
automatically - the opposite of the ValidateAsync contract this PR documents
everywhere else. It now states the real one (attach-only, driven from an effect
through ValidateFieldAsync) and points at ValidationRuleAsync for the
cross-field case that is run for you. The example is a compiled doc-app snippet
rather than typed into the template, which is what the inline-snippet ledger
requires; the "three behaviours" intro above it also listed four.
ReBaseliningDuringRenderStillNotifies reddens when baselines are dropped from
the comparison.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 373c7212-34f6-4ddf-8ae9-59553c77cf48
Net-zero suppression does not currently preserve Version for value churn: only message-only writes are deferred here. A chain such as .Validate("f", "", ...).Validate("f", "bb", ...) changes _currentValues twice on every identical render, so ApplyValidation calls this with messagesOnly: false twice and Version grows forever even though DeliverDeferred correctly suppresses Changed. This breaks consumers using Version as a memo/effect dependency. Defer all render-frame version bumps and commit one only when the final message and non-message snapshots differ; cover the differing-value chained case explicitly.
Preflight async rules before mutating validation state
This loop mutates the context before it discovers that a later rule is asynchronous and throws. For example, the new test's sync-first/async-second batch leaves the first rule's error installed even though EvaluateRules failed, exposing a partial batch. Preflight async rules before evaluating any synchronous predicate so the rejected call is side-effect free.
…d slots
Three findings, all of which turn a net-zero or failed operation into observable
state change.
Version was held during a render only for message-only writes. A chain such as
.Validate("f", "", ...).Validate("f", "bb", ...) rewrites the current value twice
per pass and lands where it started, so Changed was correctly suppressed while
Version grew on every render forever - the one signal a UseMemo or UseEffect is
most likely to be keyed on, and therefore the one where a silent leak costs the
most. Every render-frame bump is now held and committed once, only when the
pass ended somewhere different from where it started. The held bump also
registers a deferred notification, so a bump with no notification of its own
(BeginAsyncValidation) cannot strand it.
EvaluateRules(ctx, rules...) evaluated rule by rule, so a batch containing an
async rule installed the earlier verdicts and then threw. A caller cannot reason
about a partial batch from a call that reported failure, so the batch is now
rejected before anything is installed. Each rule's own Evaluate still rejects an
async predicate; this makes the batch atomic rather than the rule safe.
The third is a collision in the ownership model this PR introduced. Every
value-carrying .Validate() on a field shares one producer slot, so a render that
returns one validated control and also builds and drops another naming the same
field leaves the dropped element holding the newer stamp - and retiring its
unconsumed claim cleared the slot the mounted control depended on, reporting an
invalid field as valid. A slot a mounted control has adopted is now left alone.
The dropped element's verdict still wins the slot's contents, which is the
pre-existing last-writer-wins behaviour for two elements naming one field; only
the erasure is fixed, and that limit is stated in the code.
Each fix reddens its own check: removing the deferral reddens four version
tests, removing the preflight reddens the batch test, and removing the adoption
guard reddens Issue1262_SameField_* with exactly the reported symptom
(valid=True for a mounted invalid field).
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 373c7212-34f6-4ddf-8ae9-59553c77cf48
The effect allocates a fresh source per run and only cancelled it, leaking one
per keystroke. Flagged by code quality on the doc app, and worth more than the
usual care because this is a snippet readers copy: an example that leaks is a
pattern that spreads. Cancel stays in try and Dispose in finally so the source
is released even if cancellation throws.
Guide regenerated.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 373c7212-34f6-4ddf-8ae9-59553c77cf48
The AOT selftest job caught this one, and it was my own bad oracle rather than a
product defect: Issue1262_OffThread_NoSynchronousRenderFromWorker compared a
render count taken on the worker against one taken before it, and read the
difference as "the worker rendered". A marshalled render landing on the UI
thread inside that same window moves the same counter, so the check reddened
under AOT while its sibling Issue1262_OffThread_AllRendersOnUiThread passed in
the very same run - which is the direct evidence that every render was, in fact,
on the UI thread. Healthy and broken were separated only by timing.
The check now attributes renders by thread id: no render may carry the worker's
id. The thread list also becomes a ConcurrentQueue, since it is appended from
the render and read from the worker, which raced even when nothing rendered
off-thread.
That leaves the question of whether the new check can fail at all, and honesty
requires answering it rather than banking a green. It cannot be falsified by
mutating the product: flipping UseValidationContext's setter to threadSafe: true
- the exact choice this fixture exists to justify - leaves it green, so the
re-render is marshalled somewhere below the setter. So the instrument is proven
directly instead, with a positive control that runs the same filter over a queue
deliberately holding a worker-thread entry and asserts it matches. Blinding that
probe reddens it.
The overstated claim in the fixture's header comment is corrected in place.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 373c7212-34f6-4ddf-8ae9-59553c77cf48
The previous commit's message said this was done; it was not. The header still
claimed the fixture "makes that choice falsifiable", which the same commit had
just demonstrated to be false - flipping UseValidationContext to threadSafe:
true leaves every check in it green. The comment now states what the fixture
does establish (no throw, no worker-thread render, repaint on the UI thread) and
what it does not, rather than the stronger claim.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 373c7212-34f6-4ddf-8ae9-59553c77cf48
Code quality is right that this loop filters its sequence implicitly. Unlike the
earlier Where suggestion on this PR, which was declined because that loop acted
on every element, this one really does select a subset - the async rules whose
presence rejects the batch - so the filter belongs in the sequence.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 373c7212-34f6-4ddf-8ae9-59553c77cf48
The Unreleased section had grown 33 separate #1262 bullets - 27 of them under
Fixed - because each review round appended its own. That reads as a pile of
unrelated changes when it is one story: the documented Validation Context
example did not work, and making it work is what everything else serves.
Now one entry per section, grouped by what a reader would actually look for.
The mechanism the old bullets described - producer stamps, ownership claims,
retirement, net-zero suppression - is implementation detail that belongs in the
commits and code comments, both of which carry it; a changelog reader needs the
symptom and the resolution.
Nothing user-visible is dropped. The individual defects survive as themed
sub-entries (stale verdicts, repaint loops, async ordering and lifetime, silent
async rules, rule identity, unregistered fields, wrong verdicts, missed
repaints, leaks, docs), and the two pre-existing #1268 entries are untouched.
199 lines to 89; 39 mentions of the issue to 3.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 373c7212-34f6-4ddf-8ae9-59553c77cf48
Only CHANGELOG.md conflicted, in all three Unreleased sections, and only
because both sides appended entries to them. Resolved as a union with this
branch's entries on top, matching how main placed its newer #1275 entries above
the older #1268 ones.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 373c7212-34f6-4ddf-8ae9-59553c77cf48
The reason will be displayed to describe this comment to others. Learn more.
Copilot review overview
🟡 Changes recommended
Async predicate cancellation is silently swallowed, baseline changes can miss notifications, and validation introduces avoidable duplicate and unrelated hot-path work.
Get a fresh assessment by requesting another Copilot review.
Five review findings.
FormField re-ran every synchronous validator that .Validate() had already run
during the render, for the ordinary FormField(TextBox(...).Validate(f, v, ...)).
The structural diff hid the duplicate notification but not the work, so an
expensive or custom validator paid twice on every render. The reconcile-time
pass is now a fallback: it skips when this render already produced a verdict for
this attachment against this context, and the content control takes the claim
moments later.
That context qualifier is load-bearing, and the existing Issue1262_ExplicitProvide
fixture is what proved it. An explicit .Provide(...) inside a component that also
owns a hook context separates the two: the eager write went to the hook's
context while FormField resolves the provided one, so a claim alone does not mean
THIS context was validated. Without the comparison the explicitly provided
context silently stopped being validated at all.
RunAsyncRuleAsync caught every OperationCanceledException as lifecycle churn. The
predicate takes no token of its own, so anything it cancels is the app's own
fault being hidden - and the stale verdict was left in place with no diagnostic.
Only our own token counts as lifecycle cancellation now; everything else reaches
the diagnostic arm.
SetInitialValue dropped a baseline that moved while the field stayed dirty -
initial a, current b, then initial c. IsDirty never flips, so neither Version nor
Changed moved, yet Reset(field) now returns c instead of a. The move itself
counts, scoped to a baseline that already existed and actually changed so that
first-time seeding and the identical per-render re-seed stay silent.
The mount and update validation hooks were gated on `Attached is not null`, which
is true for every Grid/Canvas/Flex-positioned element, so all of them paid an
attached-state DP read for a verdict they never had. Gated on ValidationAttached
itself; the update path tests both old and new so removal still retracts.
Each behavioural fix reddens its own check: removing the claim peek doubles the
counted validator runs (4 for 2 renders), catching every cancellation loses the
foreign-cancellation report, and dropping the baseline comparison loses the
re-baseline notification. The cancellation fixture carries a positive control -
the same subscription must stay silent for lifecycle cancellation - because a
sink that reports everything would satisfy the first check for the wrong reason.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 373c7212-34f6-4ddf-8ae9-59553c77cf48
The AOT selftest job caught this: EventListener callbacks for managed
EventSource events do not flow under NativeAOT publish - IsEnabled() returns
false on the emit side, so the listener observes nothing whatever the
classification did. Same limitation NativeDockingReliabilityFixture documents
and guards with the same RuntimeFeature.IsDynamicCodeSupported test.
Both checks are guarded, not just the failing one. The positive control asserts
that lifecycle cancellation stays SILENT, and a listener that can never observe
anything satisfies that vacuously - it would keep passing while controlling for
nothing, which is worse than not running. The JIT selftest run asserts both.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 373c7212-34f6-4ddf-8ae9-59553c77cf48
Guarding the two checks left the fixture emitting nothing at all under AOT, and
the harness fails that by design: a fixture that runs to completion without a
single check or skip is indistinguishable from one that was never reached. It
now emits an explicit skip with the reason, so the AOT run records why the
checks did not assert instead of quietly appearing to pass.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 373c7212-34f6-4ddf-8ae9-59553c77cf48
…its for
Found by building a sample app for every documented scenario and driving it
over UIA. FormField called ShouldShowErrors without the submitAttempted
argument, so it defaulted to false forever: ShowWhen.AfterFirstSubmit accepted
the setting and then behaved exactly like ShowWhen.Never. Verified live before
fixing - the probe read ctxHasError=True touched=True while the field rendered
no border and no message. This is the same shape as the WhenTouched defect this
PR already fixes, and it is pre-existing on main rather than introduced here.
MarkAllTouched() is the submit signal the guide tells callers to send, so the
context records it as SubmitAttempted and ResetAll() clears it. FormField and
the mounted visualizer now pass it. Nothing else in the framework had any
notion of a submit; only the manual .WithErrorStyling(...) path could supply
the flag, by hand.
That makes MarkAllTouched() observable on a context with nothing registered,
where it used to be silent, so the test pinning that is updated rather than the
fix weakened: SubmitAttempted is state a subscriber can render, and staying
quiet would leave an AfterFirstSubmit visualizer blank after a submit it was
told about. The flag only flips once, so the repaint stays bounded - which is
the property that test was really protecting.
Docs now name the signal each policy waits for, because two of them need one
the app has to send and neither said so. WhenDirty measures against a baseline
that only SetInitialValue records, so with no baseline it is silent forever -
the same trap, one method away. Added to skills/forms.md, the reactor-forms
SKILL, and the forms guide template.
SubmitAttempted is new public API, so both api.txt copies are regenerated via
--regen-api and verified byte-identical.
Unplumbing submitAttempted reddens Issue1262_AFS_ShownAfterSubmit with the
original symptom; WhenDirtyStaysSilentWithoutABaseline pins the documented
precondition from both sides.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 373c7212-34f6-4ddf-8ae9-59553c77cf48
Correct comment about cancellation during async validation
docs/_pipeline/apps/forms/App.cs:204
This cleanup does not “stop waiting”: Validate.MustAsync awaits IsEmailFree without a token and checks cancellation only after that task completes. In this example cancellation merely prevents the eventual result from being applied. Reword the comment so the published snippet does not promise prompt cancellation.
…ing cancellation
Two findings, both consequences of the previous round.
SubmitAttempted was missing from the net-zero comparison. Once every field is
already touched, that flag is the entire delta MarkAllTouched() produces - so a
submit raised from an effect during reconciliation looked net-zero, the
notification was dropped, and the held Version bump was cancelled. A
ShowWhen.AfterFirstSubmit field would then stay hidden after the very submit it
was told about, which is the defect this PR just fixed reappearing through the
suppression path. Same omission as the baselines a few rounds back, same fix:
snapshot it and compare it.
The async doc snippet claimed cancelling "stops waiting". It does not.
Validate.MustAsync awaits the caller's predicate with no token and only calls
ThrowIfCancellationRequested after it returns, so cancellation discards the
result rather than abandoning the request. The comment now says that, and points
at taking a token in the predicate for callers who want the work actually
stopped. The neighbouring claim about ValidationRuleAsync is left alone: that
path awaits through WaitAsync(token), which really does stop waiting.
SubmitDuringARenderFrameIsNotSuppressedAsNetZero reddens when the flag is
dropped from the comparison; RepeatedSubmitDuringARenderFrameStaysSuppressed
pins the other side, so the fix cannot degrade into suppressing nothing.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 373c7212-34f6-4ddf-8ae9-59553c77cf48
The new event does not cover all observable mutations as documented. RegisterField changes the public RegisteredFields set without bumping Version or raising Changed; the new ApplyValidation, async-validation, and rule-set paths also ignore a newly registered field when their value/messages are otherwise unchanged. A parent subscriber rendering field registration state can therefore remain stale. Treat first-time registration as a real non-message change in every registration path and notify once after the transaction.
Clear drops all producer-owned messages but leaves the field's entries in _producerStamp. That violates the invariant below that a stamp exists only while a producer owns messages, so repeated validation/clearing of dynamically named fields grows this dictionary indefinitely. Remove the field's stamp map together with _owned (as ClearAll already does).
This issue also appears in the following locations of the same file:
Two findings, both in the "previously missed" block rather than as inline
threads, and both real.
Clear, ClearInternal and Reset dropped _owned, _asyncGeneration and rule-set
membership for a field but not its _producerStamp entry. That breaks the
invariant this PR introduced and defended - a stamp exists exactly while its
producer owns messages - and grows the map without bound for dynamically named
fields. It is the same unbounded-growth defect already fixed on the retire path,
reappearing on the clear path because three call sites each had to remember four
removals and one of them was added later than the rest. Fixed with a single
DropFieldProducerStateLocked helper the three now share, so a fourth caller
cannot forget.
Registration was not observable. RegisteredFields is public and MarkAllTouched
iterates it, but five separate sites added to the set directly and none counted
it as a change, so a subscriber rendering the field set could stay stale. All
five now go through RegisterFieldLocked, which reports whether the field was
actually new, and each path folds that into its own change decision.
Registration is monotonic - nothing ever removes from the set - so this settles
after the first pass that introduces a field rather than repainting forever.
Two existing tests asserted zero notifications at exactly that first pass; both
are updated with the reason, and both keep their real point, which is that
repeating the pattern afterwards stays silent. A_Rule_Flipping gains an explicit
re-evaluation loop so that property is asserted rather than implied.
ClearingAFieldDropsItsProducerStamps cycles 100 dynamically named fields and
reddens when the stamp removal is dropped; RegisteringAFieldIsObservable and the
two updated settling tests redden when RegisterField goes silent again.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 373c7212-34f6-4ddf-8ae9-59553c77cf48
Sample discards validation task and hides exceptions
docs/_pipeline/apps/forms/App.cs:200
This shipped sample discards the validation task, so exceptions from a real uniqueness check are never observed or reported. Cancellation is handled by the token, but network/predicate failures still fault ValidateFieldAsync; copied as written, the guide silently loses those failures. Run it through an async helper that catches expected cancellation and reports unexpected exceptions (or expose a framework-supported observed-task pattern).
Abandoned claims are retired only when a later render frame opens. Both root hosts catch a render exception, install the error fallback, and return without calling Reconcile, so if no later render occurs this thread-static dictionary permanently retains the temporary attachment/validators and its verdict remains in the context. Retire the current frame's claims on the aborted-render path rather than relying on an unrelated future render to clean them up.
LostFocus marks fields touched during internal focus changes
LostFocus is routed, so this handler also runs when focus moves between descendants of a composite editor (for example, from a NumberBox's text part to one of its spin buttons). That marks the field touched while focus is still inside the field, contrary to the documented “focus then blur” behavior. Use LosingFocus and ignore transitions whose NewFocusedElement is still a descendant of fe (the file already has IsDescendantOf).
Producer ownership was tracked by object reference, which cannot tell two
contributions apart when they hold the same ValidationMessage instance.
ValidationMessage is immutable and Add(ValidationMessage) is public, so a
validator that caches its message - or a caller who adds one already installed -
puts the same instance in a field twice under two different owners. Retiring
either then matched both occurrences and removed the other's message, leaving
the field spuriously valid. Reproduced as a test before changing anything.
Ownership is now positional: a parallel owner list beside each field's messages,
same length and order, with null marking an entry added directly. That replaces
the instance-keyed map entirely, so ContainsReference is gone along with the
delicate rule about not overwriting ownership on an unchanged pass - positions
have no equal-but-distinct hazard, so the owner list is simply always rewritten
to describe what is installed.
Aborted renders leaked their claims. Both hosts catch a render exception,
install the error fallback and return without calling Reconcile, so nothing
consumed or retired what that render claimed; the cleanup relied on a later
render opening a frame, and for a terminal fallback there is no later render.
AbandonPendingClaims() is called from the two funnels every abort path goes
through - ShowErrorFallback and RecoverFromHookOrder, in both hosts - rather
than from the eight-odd individual return sites, which is the drift that caused
the last two findings.
Issue1262_Aborted_VerdictWritten asserted the old intermediate state, where the
verdict survived the abort and was only cleaned up by the next render. That is
the behaviour this fixes, so the fixture now asserts settlement at the abort
itself and keeps its original point as a second check.
The async doc sample discarded its validation task, so a uniqueness check that
failed for a real reason - network down, service erroring - vanished silently
and the field simply never got a verdict. It now awaits inside a local helper
that separates expected cancellation from real failure and surfaces the latter.
Each fix reddens its own check: breaking positional ownership reddens four
producer-isolation tests, no-opping AbandonPendingClaims reddens the aborted
claim test, and the shared-instance repro fails on the pre-fix matching.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 373c7212-34f6-4ddf-8ae9-59553c77cf48
LostFocus is routed, so it also fired when focus moved between descendants of
one editor - a NumberBox's text part to its spin buttons, a FormField whose
content holds several focusable parts. The field was not blurred at all in that
case, so marking it touched contradicted the documented "focus then blur" and
could reveal an error while the user was still inside the control.
LosingFocus carries the destination, which LostFocus does not: the new focus is
not yet set when LostFocus fires, so it cannot make this distinction at all. A
destination inside the same element is ignored; a null destination - focus
leaving the window - is a real blur and still marks touched.
The fixture took two attempts and the first was worth recording. It focused a
NumberBox's inner text part, which already held focus, so no transition ever
fired and "not touched" was true for the wrong reason - the mutant survived.
It now uses content with two focusable parts, asserts through FocusManager that
the move actually happened before asserting anything about touched state, and
keeps the leave-the-field positive control. Removing the guard reddens it.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 373c7212-34f6-4ddf-8ae9-59553c77cf48
Code quality flagged the bare catch in the async-validation sample. It is now
filtered the way the framework filters its own app-callback boundary in
RunAsyncRuleAsync: everything except OutOfMemoryException and
StackOverflowException.
Not narrowed to a list of expected exception types, which was the suggestion.
The predicate belongs to the reader, so neither the framework nor the sample can
know what it throws - an HttpRequestException list is right for one caller and
silently wrong for the next, and the type someone forgot is exactly the failure
that disappears. The point of the surrounding change was that these must not
disappear.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 373c7212-34f6-4ddf-8ae9-59553c77cf48
This branch has not been deployed
No deployments
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #1262.
Following the Forms guide's Validation Context example, clicking Register on an empty form submitted successfully with no errors. That example is not merely mis-documented — it tripped five compounding defects.
Root cause
.Validate()was inert outsideFormField. It only attached aValidationAttachedrecord; the sole consumer wasCompositeLifecycle.Mount/UpdateFormField. A bareTextBox(...).Validate(...)registered no field and produced no message, soMarkAllTouched()marked nothing andIsValid()was triviallytrue. This contradicts spec 011 §1A.5, which is checked off but was only ever implemented forFormField.UseValidationContext()returned a component-local context it never published, soFormFieldand the visualizers couldn't find it either.Render()was one pass stale, because validation ran during reconcile, after the tree was built.FormField's defaultShowWhen.WhenTouchedcould never fire. The guide promises errors appear "after the field is touched (focus then blur)", but nothing ever calledMarkTouched.Approach
A render-pass-scoped
ValidationRenderScope, opened by the reconciler around everyRender(), links the rendering component's context to the value-carrying.Validate(field, value, …)overload:.Validate(…)produces the verdict that a laterWhen(ctx.HasError(…))sibling reads in the same pass.ValidationContext.Changed(new public event) repaints subscribers when the context is mutated.FormFieldmarks its field touched on blur.Loop safety is load-bearing throughout, because
CreateComponentRerenderre-renders inline:Add/Clear*/MarkTouched/MarkAllTouched/Reset/ResetAllall bump and notify only on a real state delta.Review rounds
This went through a long automated-review loop. Every finding was reproduced against the source before being acted on; a few were disproven and declined with evidence (an identical-message producer collapse that did not reproduce, a "stale test" at lines that held no such test, a
Wheresuggestion on a non-filtering loop).The loop did not converge to zero, and it is worth saying why rather than implying it did:
Versionchurn under a pathological chain and partial batches on a throw. All real; all increasingly remote.The loop was stopped deliberately at that point, with everything found so far fixed and CI green, rather than continued indefinitely.
The substantive classes of defect it surfaced, beyond the original five:
FormField, a removed child (which leaves via the pooling traversal, not the ordinary unmount), a root-host render, and an element built then dropped — each could leave a message in the context owned by nothing, keeping a form invalid over a field with no control..Provide(...)separates them, or when two siblings validate one field. The eager write now records what it actually reached, and the control inherits that exact claim.ChangedandVersionhad to learn to tell churn from change. With the comparison stubbed out the fixture's settle loop goes from 0 additional renders to ~48,000.Notes for reviewers
Reconciler.Mount.cs×3,Reconciler.cs×3, plusReactorHostandReactorHostControlroot renders. The selftest fixtures caught the root sites — without them a single-component app, which is what the reporter writes, would have missed the fix entirely..Providesemantics are documented, not changed. An explicit provide is what descendants resolve; it does not redirect the providing component's own.Validate()calls, which already ran while the tree was built — the same reasonUseContextcan't see a value the same component provides.skills/forms.mddocumented five APIs that don't exist (.IsValid/.IsDirtyas properties,.ValidateAll(),.Reset(), positionalFormField("Label", input),.Validate(ctx, "name", …)); corrected.ValidationContext.IsValidating, which does not exist, and claimedValidate.MustAsyncruns automatically. The async section now states the real contract and the example is a compiled doc-app snippet.ValidationContextTests.Version_Increments_On_Touch_And_Resetencoded the old unconditional-bump contract and now asserts both halves of the new one.Known limits (deliberate, not oversights)
FormField's reconcile-time re-validation, which still uses the flat key. That is a design change to ownership and belongs in its own PR..Validate()run this pass", but aUseMemo-cached subtree doesn't re-invoke.Validate()while still on screen, so it would silently discard live validation for memoized forms.setId.Validation
dotnet test tests/Reactor.Testsdotnet test tests/Reactor.DocPipeline.TestsEvery oracle added for a review finding is mutation-probed — the fix is reverted and the check confirmed to redden. Two probes caught vacuous tests of my own: a rule regression mounted at the host root (where re-render only schedules, so the loop never formed) and a removal fixture whose validated control sat first in the children, so shortening the list replaced it instead of removing it and never touched the pooling path.
A process note worth recording: several rounds were slowed by incremental builds leaving the selftest host running a stale
Reactor.dll, which produced four false results — tests failing on correct code and passing on mutated code. Mutation results here were re-taken withbin/objcleared.Verified end-to-end in the live docs app: empty submit blocks and shows both errors, the email error swaps between required and format as the value changes, errors clear per-field as they become valid, and a valid form submits and disables the button.