Skip to content

Fix validation context: .Validate() never ran outside FormField - #1270

Open
Alexandre Zollinger Chohfi (azchohfi) wants to merge 70 commits into
mainfrom
azchohfi-fix-validation-context-empty-values
Open

Alexandre Zollinger Chohfi (azchohfi) wants to merge 70 commits into
mainfrom
azchohfi-fix-validation-context-empty-values

Conversation

@azchohfi

@azchohfi Alexandre Zollinger Chohfi (azchohfi) commented Sep 23, 2026 •

Copy link
Copy Markdown
Collaborator

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

  1. .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.
  2. The context was never provided. UseValidationContext() returned a component-local context it never published, so FormField and the visualizers couldn't find it either.
  3. Reading the context in Render() was one pass stale, because validation ran during reconcile, after the tree was built.
  4. Context mutations 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.

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 and 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.
  • 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
@github-actions

github-actions Bot commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

📦 Build metrics

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.

Comment thread src/Reactor/Controls/Validation/ValidationContext.cs Fixed
@github-actions

github-actions Bot commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

🧪 Merged coverage

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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Unresolved critical issues include rerender loops, incorrect explicit-provider routing, and non-atomic validation notifications.

Get a fresh assessment by requesting another Copilot review.

Review effort: Balanced
Findings: 3 High severity · 1 Medium severity

Open (4)
What changed in this PR

Fixes #1262 by enabling validation outside FormField through render-scoped contexts, rerender notifications, and blur-based touch tracking.

Changes:

  • Adds eager validation and automatic context propagation.
  • Adds validation change notifications and touch handling.
  • Expands tests and updates documentation/API indexes.
File Summary
tests/​Reactor.Tests/​ValidationRenderScopeTests.cs Adds render-scope validation tests.
tests/​Reactor.AppTests.Host/​SelfTest/​SelfTestFixtureRegistry.cs Registers validation self-tests.
tests/​Reactor.AppTests.Host/​SelfTest/​Fixtures/​ValidationCoverageFixtures.cs Adds end-to-end validation fixtures.
src/​Reactor/​Hosting/​ReactorHostControl.cs Scopes root control renders.
src/​Reactor/​Hosting/​ReactorHost.cs Scopes root application renders.
src/​Reactor/​Core/​V1Protocol/​CompositeLifecycle.cs Adds blur-based touched tracking.
src/​Reactor/​Core/​Reconciler.Mount.cs Scopes component mounting.
src/​Reactor/​Core/​Reconciler.cs Scopes component updates.
src/​Reactor/​Controls/​Validation/​ValidationRenderScope.cs Implements render-scoped validation contexts.
src/​Reactor/​Controls/​Validation/​ValidationReconciler.cs Applies diffed validation results.
src/​Reactor/​Controls/​Validation/​ValidationContext.cs Adds change notifications and conditional updates.
src/​Reactor/​Controls/​Validation/​ValidateExtensions.cs Runs value validation during rendering.
src/​Reactor/​Controls/​Validation/​UseValidationContext.cs Provides contexts and subscribes to changes.
skills/​reactor.api.txt Updates the API index.
skills/​forms.md Corrects forms guidance.
plugins/​reactor/​skills/​reactor-forms/​SKILL.md Updates the forms skill.
plugins/​reactor/​skills/​reactor-dsl/​references/​reactor.api.txt Synchronizes the plugin API index.
docs/​guide/​forms.md Documents validation behavior.
docs/​_pipeline/​templates/​forms.md.dt Updates the forms documentation source.
CHANGELOG.md Records validation fixes and API changes.

💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/Reactor/Controls/Validation/ValidationContext.cs Outdated
Comment thread src/Reactor/Controls/Validation/ValidationReconciler.cs Outdated
Comment thread src/Reactor/Controls/Validation/ValidationRenderScope.cs
Comment thread src/Reactor/Core/V1Protocol/CompositeLifecycle.cs
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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Critical and moderate threading, notification, reset, async-validation, and binding lifecycle issues remain unresolved.

Get a fresh assessment by requesting another Copilot review.

Review effort: Balanced
Findings: 2 High severity · 1 Medium severity

Open (3)
Resolved since last review (4)
Previously missed (1)

In code that hasn't changed since last review

Medium severity Preserve notifications for other subscribers during renders

src/​Reactor/​Controls/​Validation/​ValidationContext.cs:56

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.

Comment thread src/Reactor/Controls/Validation/UseValidationContext.cs Outdated
Comment thread src/Reactor/Controls/Validation/ValidationContext.cs Outdated
Comment thread src/Reactor/Core/V1Protocol/CompositeLifecycle.cs Outdated
…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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

A critical rerender loop and multiple moderate validation and lifecycle issues remain unresolved.

Get a fresh assessment by requesting another Copilot review.

Review effort: Balanced
Findings: 1 High severity

Open (1)
Resolved since last review (3)
Previously missed (1)

In code that hasn't changed since last review

Medium severity Avoid clearing validation results before async re-evaluation

src/​Reactor/​Controls/​Validation/​ValidationRule.cs:97

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).

Comment thread src/Reactor/Controls/Validation/ValidationContext.cs
…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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Critical dispatching and async-validation correctness issues remain unresolved.

Get a fresh assessment by requesting another Copilot review.

Review effort: Balanced
Findings: 2 High severity · 1 Medium severity

Open (3)
Resolved since last review (1)

Comment thread src/Reactor/Controls/Validation/UseValidationContext.cs
Comment thread src/Reactor/Controls/Validation/ValidationContext.cs Outdated
Comment thread src/Reactor/Controls/Validation/ValidationContext.cs
…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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Critical validation-message replacement and async ordering defects can produce missing, duplicated, or stale errors.

Get a fresh assessment by requesting another Copilot review.

Review effort: Balanced
Findings: 2 High severity · 1 Medium severity

Open (3)
Resolved since last review (3)

Comment thread src/Reactor/Controls/Validation/ValidationContext.cs Outdated
Comment thread src/Reactor/Controls/Validation/ValidationReconciler.cs Outdated
Comment thread src/Reactor/Controls/Validation/ValidationContext.cs Outdated
Comment thread src/Reactor/Controls/Validation/ValidationContext.cs Fixed
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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🔵 Needs a closer look

Mixed sync/async chains and aborted root renders can leave validation errors without lifecycle ownership.

Review effort: Balanced
Findings: None

Resolved since last review (4)
Previously missed (2)

In code that hasn't changed since last review

Medium severity Sync verdict ownership is lost in sync-then-async validation chains

src/​Reactor/​Controls/​Validation/​ValidateExtensions.cs:120

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.

Medium severity Abandoned root validation claims survive render aborts

src/​Reactor/​Controls/​Validation/​ValidationRenderScope.cs:149

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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Deferred suppression can hide dirty-state transitions, and the forms guide still contradicts async validation behavior.

Get a fresh assessment by requesting another Copilot review.

Review effort: Balanced
Findings: 1 Low severity

Open (1)
Previously missed (2)

In code that hasn't changed since last review

Medium severity Include initial values in deferred notification snapshots

src/​Reactor/​Controls/​Validation/​ValidationContext.cs:280

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.

Low severity 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.

Comment thread docs/_pipeline/templates/forms.md.dt
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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Shared sync ownership, net-zero version churn, and partial batch mutation can still produce incorrect validation state.

Get a fresh assessment by requesting another Copilot review.

Review effort: Balanced
Findings: 1 High severity

Open (1)
Resolved since last review (1)
Previously missed (2)

In code that hasn't changed since last review

Medium severity Preserve Version across net-zero validation value churn

src/​Reactor/​Controls/​Validation/​ValidationContext.cs:323

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.

Medium severity Preflight async rules before mutating validation state

src/​Reactor/​Controls/​Validation/​ValidationReconciler.cs:113

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.

Comment thread src/Reactor/Controls/Validation/ValidationContext.cs
Comment thread docs/_pipeline/apps/forms/App.cs Fixed
…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
Comment thread docs/_pipeline/apps/forms/App.cs Fixed
Comment thread src/Reactor/Controls/Validation/ValidationReconciler.cs Fixed
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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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.

Review effort: Balanced
Findings: 5 Medium severity

Open (5)
Resolved since last review (1)

Comment thread src/Reactor/Controls/Validation/ValidationContext.cs
Comment thread src/Reactor/Core/Reconciler.Mount.cs Outdated
Comment thread src/Reactor/Core/Reconciler.Update.cs Outdated
Comment thread src/Reactor/Core/V1Protocol/CompositeLifecycle.cs
Comment thread src/Reactor/Core/V1Protocol/CompositeLifecycle.cs Outdated
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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Deferred snapshots omit SubmitAttempted, and the async guide incorrectly promises prompt cancellation.

Get a fresh assessment by requesting another Copilot review.

Review effort: Balanced
Findings: 1 Medium severity

Open (1)
Resolved since last review (5)
Previously missed (1)

In code that hasn't changed since last review

Low severity 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.

Comment thread src/Reactor/Controls/Validation/ValidationContext.cs
…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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🔵 Needs a closer look

Field clearing leaves stale producer stamps, and the new Changed event misses observable field-registration mutations.

Review effort: Balanced
Findings: None

Resolved since last review (1)
Previously missed (2)

In code that hasn't changed since last review

Medium severity Field registration changes are not notified

src/​Reactor/​Controls/​Validation/​ValidationContext.cs:55

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.

Medium severity Clear leaves producer stamps and grows dictionary

src/​Reactor/​Controls/​Validation/​ValidationContext.cs:442

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:

  • line 462
  • line 1443

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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Validation cleanup, composite-focus handling, producer ownership, and async sample error observation have unresolved correctness issues.

Get a fresh assessment by requesting another Copilot review.

Review effort: Balanced
Findings: 1 High severity

Open (1)
Previously missed (3)

In code that hasn't changed since last review

Medium severity 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).

Medium severity Aborted renders leak unretired validation claims

src/​Reactor/​Controls/​Validation/​ValidationRenderScope.cs:208

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.

Medium severity LostFocus marks fields touched during internal focus changes

src/​Reactor/​Core/​V1Protocol/​CompositeLifecycle.cs:757

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).

Comment thread src/Reactor/Controls/Validation/ValidationContext.cs Outdated
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
Comment thread docs/_pipeline/apps/forms/App.cs
Comment thread docs/_pipeline/apps/forms/App.cs Fixed
Comment thread docs/_pipeline/apps/forms/App.cs
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
…on-context-empty-values

# Conflicts:
#	CHANGELOG.md
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] [Form Validation Context] Following the documentation example does not reproduce the expected result

2 participants