diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1b5843cbd..7e4e64169 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -28,6 +28,30 @@ Conventions for contributors:
### Added
+- **Validation now works on any control, not only inside `FormField`** — the
+ Forms guide's "Validation Context" example needed new surface to work as
+ written (issue #1262):
+ - `ValidationContext.Changed`, raised when the context's observable state
+ changes (a message appearing or disappearing, a field becoming touched, a
+ reset). `UseValidationContext()` subscribes to it, so mutating the context
+ from an event handler repaints the form.
+ - `ValidationReconciler.EvaluateRulesAsync(…)`, the asynchronous counterpart to
+ `EvaluateRules` for rules built with `ValidationRuleAsync`. Rules run in
+ order, so the resulting message order matches the order given.
+ - `EvaluateRules(ctx, setId, …)` and `EvaluateRulesAsync(ctx, setId, …)`, named
+ rule sets: the call owns that set, so a rule that disappears from it has its
+ message withdrawn. The identity is explicit because ownership is destructive
+ — two unrelated callers sharing one context must not silently retract each
+ other's rules.
+ - `FormField` marks its field touched when the editor loses focus, so the
+ default `ShowWhen.WhenTouched` reveals errors on blur as the guide describes.
+ Nothing in the framework called `MarkTouched` before, leaving that default
+ unreachable unless the app marked fields by hand (spec 011 §1E.1).
+ - `ValidationContext.SubmitAttempted`, recorded by `MarkAllTouched()` and
+ cleared by `ResetAll()`. This is what `ShowWhen.AfterFirstSubmit` waits for;
+ the framework had no notion of a submit before, so that policy could never
+ display anything.
+
- **Framework mechanics are searchable in the ReactorGallery index (spec 064,
issue #1275).** `find-ui --source reactor` answered "what is control X" but not
"how does mechanism Y work": `UseState hook` and `key down event handler`
@@ -54,6 +78,17 @@ Conventions for contributors:
### Changed
+- **`.Validate(fieldName, value, validators…)` now runs its validators during the
+ render that calls it**, instead of only when a `FormField` mounts the element —
+ the only consumer that ever ran them. Results are therefore readable by the
+ same `Render()` that produced them, which is what the documented
+ `When(ctx.HasError(…), …)` pattern requires. `UseValidationContext()` also
+ publishes a component-local context to the rendered subtree automatically, so
+ `FormField`, the visualizers and nested components no longer need an explicit
+ `.Provide(ValidationContexts.Current, ctx)`; an explicit provide still takes
+ precedence. The validator-only `.Validate(fieldName, validators…)` overload is
+ unchanged and still attach-only. (spec 011 §1A.5, issue #1262)
+
- **The search index emits every clean `SampleCard` on a page, not just the first
(spec 064 §3.2, issue #1275).** Regenerate after changing *any* card on a
gallery page, not only the opening one.
@@ -114,6 +149,85 @@ Conventions for contributors:
### Fixed
+- **The Forms guide's "Validation Context" example now works as written**
+ (issue #1262). Clicking **Register** on an empty form submitted successfully
+ with no errors, because five defects compounded: `.Validate()` was inert
+ outside `FormField`, the context was never provided to the subtree, reading it
+ in `Render()` was always a pass stale, mutating it scheduled no re-render, and
+ `ShowWhen.WhenTouched` was unreachable because nothing ever called
+ `MarkTouched`. Making that path work surfaced a long tail of validation bugs,
+ all fixed here:
+ - **Verdicts outliving their control.** A validated control behind a condition,
+ a whole `FormField`, or a child removed from a parent left its message in the
+ context, so a form stayed invalid over a field with no control — permanently,
+ with no way to clear it short of `ClearAll()`. The same happened when a field
+ name changed, when a rule moved to another context, and when a render threw
+ after validating.
+ - **Repaint loops.** Chaining two value overloads on one field rewrote the same
+ state every pass and announced a change each time; a failing `ValidationRule`
+ cleared and re-added its message on every reconcile, driving the reconciler
+ into its re-entrancy limit. A pass that ends where it started is now silent —
+ including for `Version`, so a `UseMemo` or `UseEffect` keyed on it no longer
+ re-runs forever.
+ - **Async rules that silently passed.** A rule built with `ValidationRuleAsync`
+ never ran its predicate when placed in the element tree, and recorded a
+ passing verdict when evaluated synchronously — reporting an invalid field as
+ valid. Mounted rules now dispatch asynchronously; the synchronous paths throw
+ instead.
+ - **Async ordering and lifetime.** Overlapping checks applied in completion
+ order, so a slow failing check could reinstate an error a newer run had
+ cleared; a verdict about a replaced value survived the change; and a
+ predicate that never completed held its rule and its `ValidationContext`
+ alive forever, with another added on every re-render. Passes are now ordered
+ per producer and cancellable.
+ - **Rule identity.** Rules were identified by their message text, so an
+ interpolated message such as `$"Must be after {start}"` orphaned the previous
+ verdict on every change and errors accumulated; two rules sharing a predicate
+ retracted each other. Identity is now the field plus the predicate's call
+ site and position.
+ - **Fields that were never registered.** A field validated only through
+ `ValidateFieldAsync`, the rule batches, or `.ValidateAsync(…)` on an element
+ built outside a render pass was skipped by `MarkAllTouched()` and the
+ validity summary.
+ - **Wrong verdicts.** A validator-only attachment inside a `FormField` was
+ validated against `null`, reporting a required-field error for a control that
+ plainly had text; `NotifyValueChanged` discarded `AddExternal` messages on
+ every call rather than only when the value actually moved; a rule batch
+ containing an async rule installed part of itself before throwing; and
+ retiring one producer removed another's message whenever both held the same
+ immutable `ValidationMessage` instance, which a caching validator or a direct
+ `Add(...)` makes reachable — leaving the field spuriously valid.
+ - **Missed repaints.** A change raised while a render was in flight was
+ announced inline, in the middle of the pass that caused it; deferring it
+ then dropped it when no subscriber existed yet, because a host flushes root
+ effects only after reconciliation. A parent rendering `ctx.IsValid()` could
+ therefore never learn that a child had invalidated the shared context.
+ Notifications are now held for the pass and delivered afterwards, to
+ whichever subscribers exist by then.
+ - **Leaks.** A `FormField` that swapped its content control left the displaced
+ editor's blur binding live, so a pooled control kept marking the old field
+ and kept its `ValidationContext` alive; and a long-lived context grew one
+ bookkeeping entry per mounted rule without bound, or per cleared field when
+ fields are named dynamically.
+ - **Registration was invisible.** `RegisteredFields` is public and
+ `MarkAllTouched()` iterates it, but registering a field raised no
+ notification, so a subscriber rendering the field set could stay stale.
+ Registration now counts as a change in every path that performs one.
+ - **Display policies that could never fire.** `FormField`'s default
+ `ShowWhen.WhenTouched` showed nothing because nothing called `MarkTouched`,
+ and `ShowWhen.AfterFirstSubmit` showed nothing because no caller supplied the
+ submit flag — both behaved exactly like `ShowWhen.Never` while silently
+ accepting the setting. Blur now marks touched, and `MarkAllTouched()` records
+ the submit attempt that `AfterFirstSubmit` waits for.
+ - **Documentation.** The guide documented `ValidationContext.IsValidating`,
+ which does not exist, and said `Validate.MustAsync` runs automatically. The
+ async section now states the real contract — attach-only, driven from an
+ effect through `ValidationReconciler.ValidateFieldAsync`. The `ShowWhen`
+ reference now also names the signal each policy waits for, since two of them
+ need one the app has to send: `WhenDirty` measures against the baseline only
+ `SetInitialValue` records, and `AfterFirstSubmit` waits for
+ `MarkAllTouched()`.
+
- **The Visual Studio preview failed to start every session.** The extension was built
against a newer `System.Text.Json` than Visual Studio binds extensions to, so it failed
to load at runtime. It now tracks the `Microsoft.VisualStudio.SDK` baseline, with a test
diff --git a/docs/_pipeline/apps/forms/App.cs b/docs/_pipeline/apps/forms/App.cs
index c293c2a15..83d84bedb 100644
--- a/docs/_pipeline/apps/forms/App.cs
+++ b/docs/_pipeline/apps/forms/App.cs
@@ -1,6 +1,7 @@
using Microsoft.UI.Reactor;
using Microsoft.UI.Reactor.Core;
using Microsoft.UI.Reactor.Controls.Validation;
+using System.Threading;
using static Microsoft.UI.Reactor.Controls.Validation.FormFieldDsl;
using Microsoft.UI.Reactor.Controls;
using static Microsoft.UI.Reactor.Factories;
@@ -170,6 +171,91 @@ public override Element Render()
}
//
+//
+class AsyncValidationDemo : Component
+{
+ static async Task IsEmailFree(string value)
+ {
+ await Task.Delay(300);
+ return value != "taken@example.com";
+ }
+
+ public override Element Render()
+ {
+ var ctx = this.UseValidationContext();
+ var (email, setEmail) = UseState("");
+
+ // Async validators are never run for you: a render pass is synchronous, so
+ // there is nowhere for it to await them. Drive them from an effect, through
+ // ValidateFieldAsync, whose generation guard discards a result the user has
+ // already typed past.
+ UseEffect(() =>
+ {
+ var cts = new CancellationTokenSource();
+ if (email.Length > 0)
+ {
+ // Observed, not discarded. `_ = SomeTask()` drops the returned task on
+ // the floor, so a uniqueness check that fails for a real reason — the
+ // network is down, the service 500s — vanishes silently and the field
+ // just never gets a verdict. Await it inside a local async helper and
+ // handle the two outcomes separately.
+ _ = RunCheckAsync(cts.Token);
+ }
+ // Cancel on cleanup so the superseded check cannot install its verdict, and
+ // dispose the source with it — the effect allocates a fresh one per run.
+ // Note this does not interrupt work already in flight: `Validate.MustAsync`
+ // awaits your predicate without a token and only observes cancellation once
+ // it returns. Take a `CancellationToken` in the predicate itself if you need
+ // the request abandoned rather than its result discarded.
+ return () =>
+ {
+ try { cts.Cancel(); }
+ finally { cts.Dispose(); }
+ };
+
+ async Task RunCheckAsync(CancellationToken token)
+ {
+ try
+ {
+ await ValidationReconciler.ValidateFieldAsync(
+ ctx, "email", email,
+ [Validate.MustAsync(IsEmailFree, "Email is taken")],
+ token);
+ }
+ catch (OperationCanceledException)
+ {
+ // Expected: the user typed again and this check was superseded.
+ }
+ catch (Exception ex)
+ when (ex is not OutOfMemoryException and not StackOverflowException)
+ {
+ // Anything else is a real failure — the network is down, the service
+ // erroring. Surface it however your app reports background faults;
+ // here, as a message on the field so it cannot pass silently.
+ //
+ // Deliberately broad rather than a list of expected exception types:
+ // the predicate is yours, so the framework cannot know what it can
+ // throw, and enumerating types means the one you forgot disappears.
+ // The filter excludes only the two that must never be caught. This
+ // is the same shape the framework itself uses for app callbacks
+ // (see CompositeLifecycle.RunAsyncRuleAsync).
+ ctx.AddExternal("email", $"Could not check availability: {ex.Message}");
+ }
+ }
+ }, email);
+
+ return VStack(12,
+ SubHeading("Async Validation"),
+ TextBox(email, v => { setEmail(v); ctx.NotifyValueChanged("email", v); },
+ placeholderText: "user@example.com", header: "Email"),
+ When(ctx.HasError("email"), () =>
+ TextBlock(ctx.GetMessages("email").First().Text)
+ .Foreground(Theme.SystemCritical).FontSize(12))
+ ).Padding(24);
+ }
+}
+//
+
//
class FormFieldDemo : Component
{
@@ -422,6 +508,7 @@ public override Element Render()
Component(),
Component(),
Component(),
+ Component(),
Component(),
Component(),
Component(),
diff --git a/docs/_pipeline/templates/forms.md.dt b/docs/_pipeline/templates/forms.md.dt
index a5e9c093a..0151dc919 100644
--- a/docs/_pipeline/templates/forms.md.dt
+++ b/docs/_pipeline/templates/forms.md.dt
@@ -184,6 +184,52 @@ Key pieces:
- **`ctx.IsValid()`** returns `true` when no error-severity messages exist.
- **`ctx.MarkAllTouched()`** reveals all errors on submit attempt.
+### How validation runs
+
+Four behaviours make the example above work without any extra wiring. They are
+worth knowing because they explain *when* a result becomes visible.
+
+**Validators run during render, not after it.** The value-carrying
+`.Validate(fieldName, value, …)` overload evaluates its validators immediately
+and writes the result into the enclosing context. C# evaluates arguments left to
+right, so the `.Validate(…)` call above produces the verdict that the later
+`When(ctx.HasError("email"), …)` sibling reads — in the *same* pass. Put your
+error display after the field it describes and it will never lag a render behind.
+
+**The context is provided to the subtree for you.** When
+`UseValidationContext()` creates a component-local context (nothing up the tree
+provided one), that context is published to whatever the component returns, so
+`FormField`, `ValidationVisualizer`, and nested components find it.
+
+Writing `.Provide(ValidationContexts.Current, ctx)` yourself still works, and the
+value you provide is what descendants resolve. It does **not** redirect the
+providing component's own `.Validate()` calls: those already ran while the
+element tree was being built, against the context `UseValidationContext()`
+returned — the same reason `UseContext` can't observe a value the same component
+provides. To collect several components' fields into one context, provide it from
+a parent and let each child call `UseValidationContext()`.
+
+**Mutating the context repaints the form.** `ctx.MarkAllTouched()` changes no
+component state, so nothing else would schedule a render; the component that
+called `UseValidationContext()` re-renders when the context changes. Re-running
+the same validators over an unchanged value is silent, so this does not loop.
+
+**`FormField` marks its field touched on blur.** That is what makes the default
+`ShowWhen.WhenTouched` work: focus the editor, move away, and the error replaces
+the description. Outside `FormField` — the hand-rolled example above — decide for
+yourself when a field counts as touched, typically `ctx.MarkAllTouched()` on a
+failed submit.
+
+> **Attach-only cases.** `.Validate(fieldName, validators…)` without a value has
+> nothing to check: it records the validators and nothing else. Inside a
+> `FormField` the field is still registered on mount, so `MarkAllTouched()`
+> covers it, but the validators are not run — they would be checking `null`
+> against a control that has a value. On a bare control the attachment is inert:
+> no verdict and no registration. A *value-carrying* attachment built outside a
+> render pass — cached in a field, assembled inside an event handler — has no
+> context to reach at attach time, and that one `FormField` does run when it
+> mounts. Pass the value if you want validators to run.
+
## FormField Helper
`FormField()` wraps a control with a label, required indicator, description
@@ -199,6 +245,13 @@ its content. Errors appear below the field after the field is touched (focus
then blur). The `ShowWhen` parameter controls when errors become visible:
`WhenTouched` (default), `WhenDirty`, `AfterFirstSubmit`, `Always`, or `Never`.
+Two of those need a signal you have to send yourself. `WhenDirty` compares
+against a baseline, so it stays silent until `SetInitialValue(field, value)`
+records one. `AfterFirstSubmit` waits for `MarkAllTouched()` — the call the
+submit handler above already makes — and `ResetAll()` puts it back. `ShowWhen`
+gates display only: `IsValid()` and `GetMessages()` are current from the first
+render whichever policy you pick.
+
## Built-in Validators
| Validator | Purpose |
@@ -392,11 +445,27 @@ surface.
### Validating async (uniqueness checks)
`Validate.MustAsync(...)` runs a predicate that returns
-`Task`. The `ValidationContext` tracks the in-flight async work
-and reports `IsValidating` per field, so the Submit button can disable
-while async validation runs. Pair with `.IsDisabledFocusable()` so the
-button stays in tab order while validating — same accessibility
-concern as [Keeping Submit Reachable](#keeping-submit-reachable).
+`Task`. Unlike the synchronous validators, an async attachment is
+**never run for you**: `.ValidateAsync(field, value, …)` attaches the
+validators and registers the field, and nothing else. A render pass is
+synchronous, so there is nowhere for it to await them.
+
+Run them yourself from an effect, through
+`ValidationReconciler.ValidateFieldAsync`, which carries the generation
+guard that discards a result the user has already typed past:
+
+```csharp snippet="forms/async-validation"
+```
+
+Track the in-flight state with your own `UseState` flag if the Submit
+button should disable while the check runs — the context does not expose
+one. Pair that with `.IsDisabledFocusable()` so the button stays in tab
+order while validating — same accessibility concern as
+[Keeping Submit Reachable](#keeping-submit-reachable).
+
+For a cross-field async check, `ValidationRuleAsync` *is* run for you
+when it is placed in the element tree, with cancellation on update and
+unmount.
## Common Mistakes
diff --git a/docs/guide/architecture-overview.md b/docs/guide/architecture-overview.md
index a0332aea5..d0b710779 100644
--- a/docs/guide/architecture-overview.md
+++ b/docs/guide/architecture-overview.md
@@ -256,6 +256,9 @@ public UIElement? Reconcile(
UIElement? existingControl,
Action requestRerender)
{
+ // Declared first so it is disposed last: validation changes raised by mount,
+ // update, or unmount are announced only once the whole pass has finished.
+ using var validationScope = Controls.Validation.ValidationRenderScope.BeginReconcile();
ReferenceDirtySet.BeginCommit();
try
{
diff --git a/docs/guide/forms.md b/docs/guide/forms.md
index dd7171661..666dda6e6 100644
--- a/docs/guide/forms.md
+++ b/docs/guide/forms.md
@@ -347,6 +347,52 @@ Key pieces:
- **`ctx.IsValid()`** returns `true` when no error-severity messages exist.
- **`ctx.MarkAllTouched()`** reveals all errors on submit attempt.
+### How validation runs
+
+Four behaviours make the example above work without any extra wiring. They are
+worth knowing because they explain *when* a result becomes visible.
+
+**Validators run during render, not after it.** The value-carrying
+`.Validate(fieldName, value, …)` overload evaluates its validators immediately
+and writes the result into the enclosing context. C# evaluates arguments left to
+right, so the `.Validate(…)` call above produces the verdict that the later
+`When(ctx.HasError("email"), …)` sibling reads — in the *same* pass. Put your
+error display after the field it describes and it will never lag a render behind.
+
+**The context is provided to the subtree for you.** When
+`UseValidationContext()` creates a component-local context (nothing up the tree
+provided one), that context is published to whatever the component returns, so
+`FormField`, `ValidationVisualizer`, and nested components find it.
+
+Writing `.Provide(ValidationContexts.Current, ctx)` yourself still works, and the
+value you provide is what descendants resolve. It does **not** redirect the
+providing component's own `.Validate()` calls: those already ran while the
+element tree was being built, against the context `UseValidationContext()`
+returned — the same reason `UseContext` can't observe a value the same component
+provides. To collect several components' fields into one context, provide it from
+a parent and let each child call `UseValidationContext()`.
+
+**Mutating the context repaints the form.** `ctx.MarkAllTouched()` changes no
+component state, so nothing else would schedule a render; the component that
+called `UseValidationContext()` re-renders when the context changes. Re-running
+the same validators over an unchanged value is silent, so this does not loop.
+
+**`FormField` marks its field touched on blur.** That is what makes the default
+`ShowWhen.WhenTouched` work: focus the editor, move away, and the error replaces
+the description. Outside `FormField` — the hand-rolled example above — decide for
+yourself when a field counts as touched, typically `ctx.MarkAllTouched()` on a
+failed submit.
+
+> **Attach-only cases.** `.Validate(fieldName, validators…)` without a value has
+> nothing to check: it records the validators and nothing else. Inside a
+> `FormField` the field is still registered on mount, so `MarkAllTouched()`
+> covers it, but the validators are not run — they would be checking `null`
+> against a control that has a value. On a bare control the attachment is inert:
+> no verdict and no registration. A *value-carrying* attachment built outside a
+> render pass — cached in a field, assembled inside an event handler — has no
+> context to reach at attach time, and that one `FormField` does run when it
+> mounts. Pass the value if you want validators to run.
+
## FormField Helper
`FormField()` wraps a control with a label, required indicator, description
@@ -389,6 +435,13 @@ its content. Errors appear below the field after the field is touched (focus
then blur). The `ShowWhen` parameter controls when errors become visible:
`WhenTouched` (default), `WhenDirty`, `AfterFirstSubmit`, `Always`, or `Never`.
+Two of those need a signal you have to send yourself. `WhenDirty` compares
+against a baseline, so it stays silent until `SetInitialValue(field, value)`
+records one. `AfterFirstSubmit` waits for `MarkAllTouched()` — the call the
+submit handler above already makes — and `ResetAll()` puts it back. `ShowWhen`
+gates display only: `IsValid()` and `GetMessages()` are current from the first
+render whichever policy you pick.
+
## Built-in Validators
| Validator | Purpose |
@@ -733,11 +786,109 @@ surface.
### Validating async (uniqueness checks)
`Validate.MustAsync(...)` runs a predicate that returns
-`Task`. The `ValidationContext` tracks the in-flight async work
-and reports `IsValidating` per field, so the Submit button can disable
-while async validation runs. Pair with `.IsDisabledFocusable()` so the
-button stays in tab order while validating — same accessibility
-concern as [Keeping Submit Reachable](#keeping-submit-reachable).
+`Task`. Unlike the synchronous validators, an async attachment is
+**never run for you**: `.ValidateAsync(field, value, …)` attaches the
+validators and registers the field, and nothing else. A render pass is
+synchronous, so there is nowhere for it to await them.
+
+Run them yourself from an effect, through
+`ValidationReconciler.ValidateFieldAsync`, which carries the generation
+guard that discards a result the user has already typed past:
+
+```csharp
+class AsyncValidationDemo : Component
+{
+ static async Task IsEmailFree(string value)
+ {
+ await Task.Delay(300);
+ return value != "taken@example.com";
+ }
+
+ public override Element Render()
+ {
+ var ctx = this.UseValidationContext();
+ var (email, setEmail) = UseState("");
+
+ // Async validators are never run for you: a render pass is synchronous, so
+ // there is nowhere for it to await them. Drive them from an effect, through
+ // ValidateFieldAsync, whose generation guard discards a result the user has
+ // already typed past.
+ UseEffect(() =>
+ {
+ var cts = new CancellationTokenSource();
+ if (email.Length > 0)
+ {
+ // Observed, not discarded. `_ = SomeTask()` drops the returned task on
+ // the floor, so a uniqueness check that fails for a real reason — the
+ // network is down, the service 500s — vanishes silently and the field
+ // just never gets a verdict. Await it inside a local async helper and
+ // handle the two outcomes separately.
+ _ = RunCheckAsync(cts.Token);
+ }
+ // Cancel on cleanup so the superseded check cannot install its verdict, and
+ // dispose the source with it — the effect allocates a fresh one per run.
+ // Note this does not interrupt work already in flight: `Validate.MustAsync`
+ // awaits your predicate without a token and only observes cancellation once
+ // it returns. Take a `CancellationToken` in the predicate itself if you need
+ // the request abandoned rather than its result discarded.
+ return () =>
+ {
+ try { cts.Cancel(); }
+ finally { cts.Dispose(); }
+ };
+
+ async Task RunCheckAsync(CancellationToken token)
+ {
+ try
+ {
+ await ValidationReconciler.ValidateFieldAsync(
+ ctx, "email", email,
+ [Validate.MustAsync(IsEmailFree, "Email is taken")],
+ token);
+ }
+ catch (OperationCanceledException)
+ {
+ // Expected: the user typed again and this check was superseded.
+ }
+ catch (Exception ex)
+ when (ex is not OutOfMemoryException and not StackOverflowException)
+ {
+ // Anything else is a real failure — the network is down, the service
+ // erroring. Surface it however your app reports background faults;
+ // here, as a message on the field so it cannot pass silently.
+ //
+ // Deliberately broad rather than a list of expected exception types:
+ // the predicate is yours, so the framework cannot know what it can
+ // throw, and enumerating types means the one you forgot disappears.
+ // The filter excludes only the two that must never be caught. This
+ // is the same shape the framework itself uses for app callbacks
+ // (see CompositeLifecycle.RunAsyncRuleAsync).
+ ctx.AddExternal("email", $"Could not check availability: {ex.Message}");
+ }
+ }
+ }, email);
+
+ return VStack(12,
+ SubHeading("Async Validation"),
+ TextBox(email, v => { setEmail(v); ctx.NotifyValueChanged("email", v); },
+ placeholderText: "user@example.com", header: "Email"),
+ When(ctx.HasError("email"), () =>
+ TextBlock(ctx.GetMessages("email").First().Text)
+ .Foreground(Theme.SystemCritical).FontSize(12))
+ ).Padding(24);
+ }
+}
+```
+
+Track the in-flight state with your own `UseState` flag if the Submit
+button should disable while the check runs — the context does not expose
+one. Pair that with `.IsDisabledFocusable()` so the button stays in tab
+order while validating — same accessibility concern as
+[Keeping Submit Reachable](#keeping-submit-reachable).
+
+For a cross-field async check, `ValidationRuleAsync` *is* run for you
+when it is placed in the element tree, with cancellation on update and
+unmount.
## Common Mistakes
diff --git a/docs/guide/reconciliation.md b/docs/guide/reconciliation.md
index b0fd865d2..588765e3d 100644
--- a/docs/guide/reconciliation.md
+++ b/docs/guide/reconciliation.md
@@ -54,6 +54,9 @@ public UIElement? Reconcile(
UIElement? existingControl,
Action requestRerender)
{
+ // Declared first so it is disposed last: validation changes raised by mount,
+ // update, or unmount are announced only once the whole pass has finished.
+ using var validationScope = Controls.Validation.ValidationRenderScope.BeginReconcile();
ReferenceDirtySet.BeginCommit();
try
{
diff --git a/plugins/reactor/skills/reactor-dsl/references/reactor.api.txt b/plugins/reactor/skills/reactor-dsl/references/reactor.api.txt
index 2f956a2f1..89ce347e9 100644
--- a/plugins/reactor/skills/reactor-dsl/references/reactor.api.txt
+++ b/plugins/reactor/skills/reactor-dsl/references/reactor.api.txt
@@ -6087,6 +6087,7 @@ ValidationAttached.RunValidators(object value) → IReadOnlyList InvalidFields { get; }
IReadOnlySet RegisteredFields { get; }
+bool SubmitAttempted { get; }
int Version { get; }
Add(ValidationMessage message) → void
Add(string field, string text, Severity severity = Severity.Error) → void
@@ -6123,6 +6125,7 @@ RegisterField(string field) → void
Reset(string field) → object
ResetAll() → IReadOnlyDictionary
SetInitialValue(string field, object value) → void
+event Action Changed
### static class ValidationContextComponentExtensions
Component.UseChildValidationContext() → ValueTuple
@@ -6142,6 +6145,11 @@ ToString() → string
### static class ValidationReconciler
EvaluateRules(ValidationContext ctx, ValidationRuleElement[] rules) → void
+EvaluateRules(ValidationContext ctx, string setId, ValidationRuleElement[] rules) → void
+EvaluateRulesAsync(ValidationContext ctx, CancellationToken cancellationToken, ValidationRuleElement[] rules) → Task
+EvaluateRulesAsync(ValidationContext ctx, ValidationRuleElement[] rules) → Task
+EvaluateRulesAsync(ValidationContext ctx, string setId, CancellationToken cancellationToken, ValidationRuleElement[] rules) → Task
+EvaluateRulesAsync(ValidationContext ctx, string setId, ValidationRuleElement[] rules) → Task
ValidateAttached(ValidationContext ctx, ValidationAttached attached, object value) → void
ValidateField(ValidationContext ctx, string fieldName, object value, IValidator[] validators) → void
ValidateFieldAsync(ValidationContext ctx, string fieldName, object value, IAsyncValidator[] asyncValidators, CancellationToken cancellationToken = default) → Task
diff --git a/plugins/reactor/skills/reactor-forms/SKILL.md b/plugins/reactor/skills/reactor-forms/SKILL.md
index 867f6abb6..7a4111c3d 100644
--- a/plugins/reactor/skills/reactor-forms/SKILL.md
+++ b/plugins/reactor/skills/reactor-forms/SKILL.md
@@ -126,9 +126,25 @@ return VStack(12,
`.Validate(fieldName, value, ...)` resolves the surrounding `ValidationContext`
through React-style ambient context — you do not pass `validation` explicitly.
-Passing the current value (the second arg) opts in to auto-validation as the
-component re-renders; the validator-only overload `.Validate(fieldName, ...)`
-is for cases where you trigger validation manually.
+Passing the current value (the second arg) runs the validators right there,
+during the render, so a `When(validation.HasError("email"), ...)` placed *after*
+the field reads the result in the same pass rather than one render late. The
+validator-only overload `.Validate(fieldName, ...)` has no value to check, so it
+only records the validators: nothing runs them, and on a bare control nothing
+registers the field either. Inside a `FormField` the field is registered on
+mount, so `MarkAllTouched()` covers it.
+
+You do not need `.Provide(ValidationContexts.Current, validation)`: a
+component-local context is published to the rendered subtree automatically, so
+`FormField`, `ValidationVisualizer`, and nested components find it. Providing one
+explicitly is what *descendants* resolve, but it does not redirect the providing
+component's own `.Validate()` calls — those already ran while the tree was being
+built. To pool several components' fields into one context, provide it from a
+parent and let each child call `UseValidationContext()`.
+
+Mutating the context re-renders the component that created it. That is why
+`MarkAllTouched()` alone reveals the errors on a failed submit, even though no
+component state changed.
### ValidationContext API
@@ -144,6 +160,7 @@ is for cases where you trigger validation manually.
| `.ClearAll()` | Clear all messages (preserve touched/initial state) |
| `.GetMessages("field")` | Get error messages for a specific field |
| `.IsTouched("field")` | Whether the user has interacted with a field |
+| `.Changed` | Event raised when the context's observable state changes |
## 4. Built-in validators
@@ -182,10 +199,18 @@ return FormField(
```
`ShowWhen` controls when error messages appear:
-- `WhenTouched` — after the user has interacted with the field (recommended default)
+- `WhenTouched` — after the user has interacted with the field (recommended default).
+ `FormField` marks its own field touched on blur; elsewhere call `MarkTouched`.
- `Always` — immediately, even before user interaction
-- `WhenDirty` — only after the value has changed
-- `AfterFirstSubmit` — only after the first submit attempt
+- `WhenDirty` — only after the value has changed from its baseline. Requires
+ `SetInitialValue(field, value)`: with no baseline recorded a field is never
+ dirty, so this policy stays silent forever.
+- `AfterFirstSubmit` — only after the first submit attempt, which is
+ `MarkAllTouched()`. `ResetAll()` clears it, so the next reveal waits for a
+ fresh submit.
+
+The verdict itself is unaffected by any of these — `IsValid()` and
+`GetMessages()` are current from the first render. `ShowWhen` only gates display.
## 6. Masked input
diff --git a/skills/design.md b/skills/design.md
index cd3d81525..e57212f35 100644
--- a/skills/design.md
+++ b/skills/design.md
@@ -988,13 +988,13 @@ Combine validation with accessibility:
var validation = this.UseValidationContext();
var (email, setEmail) = UseState("");
-return FormField("Email",
+return FormField(
TextBox(email, setEmail)
- .Validate(validation, "email", Validators.Required(), Validators.Email())
- .Required(true)
- .HelpText("We'll send a confirmation to this address"),
+ .Validate("email", email, Validate.Required(), Validate.Email()),
+ label: "Email",
+ description: "We'll send a confirmation to this address",
required: true,
- showErrorWhen: ShowWhen.Touched)
+ showWhen: ShowWhen.WhenTouched)
.Landmark(AutomationLandmarkType.Form);
```
diff --git a/skills/forms.md b/skills/forms.md
index 813a306b9..e18ed2dfd 100644
--- a/skills/forms.md
+++ b/skills/forms.md
@@ -87,34 +87,60 @@ var (email, setEmail) = UseState("");
return VStack(12,
TextBox(name, setName, placeholderText: "Name")
- .Validate(validation, "name",
+ .Validate("name", name,
Validate.Required("Name is required"),
Validate.MinLength(2, "Name too short")),
TextBox(email, setEmail, placeholderText: "Email")
- .Validate(validation, "email",
+ .Validate("email", email,
Validate.Required("Email is required"),
Validate.Email("Invalid email")),
Button("Submit", () =>
{
- validation.ValidateAll();
- if (validation.IsValid)
+ validation.MarkAllTouched();
+ if (validation.IsValid())
Submit(name, email);
})
);
```
+`.Validate(fieldName, value, ...)` finds the surrounding `ValidationContext`
+itself — you never pass `validation` as an argument. Passing the current value
+runs the validators right away, during the render, so a
+`When(validation.HasError("email"), ...)` placed *after* the field observes the
+result in the same pass. The validator-only overload
+`.Validate(fieldName, validators...)` has no value to check, so it only records
+the validators: nothing runs them, and on a bare control nothing registers the
+field either. Inside a `FormField` the field is registered on mount, so
+`MarkAllTouched()` covers it.
+
+You do not need `.Provide(ValidationContexts.Current, validation)`: a
+component-local context is published to the subtree automatically, so
+`FormField` and the visualizers find it. Providing one explicitly is what
+*descendants* resolve, but it does not redirect the providing component's own
+`.Validate()` calls — those already ran while the tree was being built. To pool
+several components' fields into one context, provide it from a parent and let
+each child call `UseValidationContext()`.
+
+Mutating the context re-renders the component that created it, which is why
+`MarkAllTouched()` on an invalid submit is enough to reveal the errors even
+though no component state changed.
+
### ValidationContext API
| Member | Purpose |
|--------|---------|
-| `.IsValid` | `true` when no field has errors |
-| `.IsDirty` | `true` when any field differs from initial value |
-| `.ValidateAll()` | Force validation on all registered fields |
-| `.Reset()` | Clear all messages and touched/dirty flags |
-| `.GetMessages("field")` | Get error messages for a specific field |
+| `.IsValid()` | `true` when no field has Error-severity messages |
+| `.IsDirty()` | `true` when any registered field differs from initial value |
+| `.MarkAllTouched()` | Mark every registered field touched (typical on submit) |
+| `.MarkTouched("field")` | Mark a single field touched |
+| `.Reset("field")` | Reset one field to its initial value, returns that value |
+| `.ResetAll()` | Reset every field, returns field → initial value |
+| `.ClearAll()` | Drop all messages |
+| `.GetMessages("field")` | Get messages for a specific field |
| `.IsTouched("field")` | Whether the user has interacted with a field |
+| `.Changed` | Event raised when the context's observable state changes |
## 4. Built-in validators
@@ -142,20 +168,29 @@ text, and error display:
var validation = this.UseValidationContext();
var (name, setName) = UseState("");
-return FormField("Full Name",
+return FormField(
TextBox(name, setName, placeholderText: "Enter your name")
- .Validate(validation, "name", Validate.Required("Required")),
+ .Validate("name", name, Validate.Required("Required")),
+ label: "Full Name",
required: true,
description: "As it appears on your ID",
- showWhen: ShowWhen.WhenTouched // or Always, WhenDirty, AfterFirstSubmit
+ showWhen: ShowWhen.WhenTouched // or Always, WhenDirty, AfterFirstSubmit, Never
);
```
`ShowWhen` controls when error messages appear:
-- `WhenTouched` — after the user has interacted with the field (recommended default)
+- `WhenTouched` — after the user has interacted with the field (recommended default).
+ `FormField` marks its own field touched on blur; elsewhere call `MarkTouched`.
- `Always` — immediately, even before user interaction
-- `WhenDirty` — only after the value has changed
-- `AfterFirstSubmit` — only after the first submit attempt
+- `WhenDirty` — only after the value has changed from its baseline. Requires
+ `SetInitialValue(field, value)`: with no baseline recorded a field is never
+ dirty, so this policy stays silent forever.
+- `AfterFirstSubmit` — only after the first submit attempt, which is
+ `MarkAllTouched()`. `ResetAll()` clears it, so the next reveal waits for a
+ fresh submit.
+
+The verdict itself is unaffected by any of these — `IsValid()` and
+`GetMessages()` are current from the first render. `ShowWhen` only gates display.
## 6. Masked input
@@ -209,9 +244,10 @@ return TextBox(amount,
1. **Always use controlled inputs** — `(value, setter)` pair. There is no
uncontrolled / two-way binding in Reactor.
-2. **Call `validation.ValidateAll()` before submit** — individual fields
- validate on blur/change, but you must trigger all-field validation
- before acting on the form.
+2. **Call `validation.MarkAllTouched()` before submit** — validators run on
+ every render, so the verdict is always current, but errors stay hidden
+ until their field is touched. Marking all fields touched on a failed
+ submit is what reveals them.
3. **Use `ShowWhen.WhenTouched`** (default) — showing errors immediately on
page load is hostile UX.
4. **MaskEngine and InputFormatter are different** — masks restrict what
diff --git a/skills/reactor.api.txt b/skills/reactor.api.txt
index 2f956a2f1..89ce347e9 100644
--- a/skills/reactor.api.txt
+++ b/skills/reactor.api.txt
@@ -6087,6 +6087,7 @@ ValidationAttached.RunValidators(object value) → IReadOnlyList InvalidFields { get; }
IReadOnlySet RegisteredFields { get; }
+bool SubmitAttempted { get; }
int Version { get; }
Add(ValidationMessage message) → void
Add(string field, string text, Severity severity = Severity.Error) → void
@@ -6123,6 +6125,7 @@ RegisterField(string field) → void
Reset(string field) → object
ResetAll() → IReadOnlyDictionary
SetInitialValue(string field, object value) → void
+event Action Changed
### static class ValidationContextComponentExtensions
Component.UseChildValidationContext() → ValueTuple
@@ -6142,6 +6145,11 @@ ToString() → string
### static class ValidationReconciler
EvaluateRules(ValidationContext ctx, ValidationRuleElement[] rules) → void
+EvaluateRules(ValidationContext ctx, string setId, ValidationRuleElement[] rules) → void
+EvaluateRulesAsync(ValidationContext ctx, CancellationToken cancellationToken, ValidationRuleElement[] rules) → Task
+EvaluateRulesAsync(ValidationContext ctx, ValidationRuleElement[] rules) → Task
+EvaluateRulesAsync(ValidationContext ctx, string setId, CancellationToken cancellationToken, ValidationRuleElement[] rules) → Task
+EvaluateRulesAsync(ValidationContext ctx, string setId, ValidationRuleElement[] rules) → Task
ValidateAttached(ValidationContext ctx, ValidationAttached attached, object value) → void
ValidateField(ValidationContext ctx, string fieldName, object value, IValidator[] validators) → void
ValidateFieldAsync(ValidationContext ctx, string fieldName, object value, IAsyncValidator[] asyncValidators, CancellationToken cancellationToken = default) → Task
diff --git a/src/Reactor/Controls/Validation/UseValidationContext.cs b/src/Reactor/Controls/Validation/UseValidationContext.cs
index 38025671e..380689965 100644
--- a/src/Reactor/Controls/Validation/UseValidationContext.cs
+++ b/src/Reactor/Controls/Validation/UseValidationContext.cs
@@ -22,7 +22,18 @@ public static class ValidationContextHookExtensions
///
/// Returns the nearest ancestor's ValidationContext, or creates a new one
/// scoped to this component. The created context persists across re-renders.
- /// To provide it to a subtree, use .Provide(ValidationContexts.Current, ctx).
+ ///
+ /// When the context is component-local (no ancestor provided one) it is published to
+ /// the rendered subtree automatically, so FormField, the visualizers, and
+ /// nested components find it without an explicit
+ /// .Provide(ValidationContexts.Current, ctx). Writing that call yourself still
+ /// works and takes precedence.
+ ///
+ ///
+ /// The component also re-renders when the context changes, which is what makes
+ /// ctx.MarkAllTouched() in a submit handler visible: it mutates the context
+ /// without touching component state, so nothing else would schedule a repaint.
+ ///
///
public static ValidationContext UseValidationContext(this RenderContext ctx)
{
@@ -30,7 +41,10 @@ public static ValidationContext UseValidationContext(this RenderContext ctx)
// UseState captures initial value only on first render; subsequent renders reuse stored value.
var (local, _) = ctx.UseState(new ValidationContext());
- return parent ?? local;
+ var resolved = parent ?? local;
+ ValidationRenderScope.Publish(resolved, autoProvide: parent is null);
+ SubscribeForRerender(ctx, resolved);
+ return resolved;
}
///
@@ -43,8 +57,47 @@ public static (ValidationContext Child, ValidationContext? Parent) UseChildValid
{
var parent = ctx.UseContext(ValidationContexts.Current);
var (child, _) = ctx.UseState(new ValidationContext());
+
+ // The child is deliberately independent of the parent, so it is the one that
+ // both collects this subtree's results and drives this component's repaints.
+ ValidationRenderScope.Publish(child, autoProvide: true);
+ SubscribeForRerender(ctx, child);
return (child, parent);
}
+
+ ///
+ /// Keeps one live subscription to 's change event for the
+ /// lifetime of the component, re-subscribing only if the component is handed a
+ /// different context instance.
+ ///
+ /// The re-render is requested by moving a counter through UseState rather than
+ /// by calling the re-render callback directly, so it inherits the hook's existing
+ /// UI-thread marshalling — a background async validator that adds a message is
+ /// marshalled rather than racing the reconciler.
+ ///
+ ///
+ private static void SubscribeForRerender(RenderContext ctx, ValidationContext context)
+ {
+ // A monotonic ticket source that survives re-renders. The state setter only
+ // schedules a repaint when the new value differs from the old, so the ticket has
+ // to keep climbing — feeding it a counter captured from render scope would go
+ // stale and silently drop notifications.
+ //
+ // The setter is deliberately the default (marshaling) one rather than
+ // threadSafe: Interlocked already makes the counter safe, while threadSafe would
+ // invoke the re-render callback on whatever thread raised Changed — which an
+ // async validator can do from a worker.
+ var (ticket, _) = ctx.UseState(new global::System.Runtime.CompilerServices.StrongBox(0));
+ var (revision, setRevision) = ctx.UseState(0);
+ _ = revision;
+
+ ctx.UseEffect(() =>
+ {
+ void OnChanged() => setRevision(global::System.Threading.Interlocked.Increment(ref ticket.Value));
+ context.Changed += OnChanged;
+ return () => context.Changed -= OnChanged;
+ }, context);
+ }
}
///
diff --git a/src/Reactor/Controls/Validation/ValidateExtensions.cs b/src/Reactor/Controls/Validation/ValidateExtensions.cs
index d495f245c..47770fd21 100644
--- a/src/Reactor/Controls/Validation/ValidateExtensions.cs
+++ b/src/Reactor/Controls/Validation/ValidateExtensions.cs
@@ -13,10 +13,23 @@ public sealed record ValidationAttached(
{
///
/// The current field value, used for automatic validation when the element is
- /// mounted inside a FormFieldElement or ValidationVisualizerElement.
+ /// mounted inside a FormFieldElement. The visualizers are display-only: they
+ /// render what a context already holds and never run attached validators.
///
public object? Value { get; init; }
+ ///
+ /// True when a value overload of .Validate()/.ValidateAsync() supplied
+ /// .
+ ///
+ /// null is a legitimate value, so the property alone cannot distinguish "the
+ /// field is empty" from "no value was ever attached". Without that distinction
+ /// FormField validated null for the validator-only overload and
+ /// reported a required-field error for a control that plainly had text in it.
+ ///
+ ///
+ public bool HasValue { get; init; }
+
public static readonly ValidationAttached Empty = new("", [], []);
}
@@ -42,13 +55,35 @@ public static T Validate(this T el, string fieldName, params IValidator[] val
Validators = [.. existing.Validators, .. validators]
}
: new ValidationAttached(fieldName, validators, []);
+
+ // Attach-only on its own — there is no value to check. But appended to a chain
+ // that already carries one, the merged set has to be re-run: eager validation
+ // happens at each link, so `.Validate(f, v, Required()).Validate(f, MinLength(3))`
+ // would otherwise install only the Required verdict and silently drop the second
+ // validator for a bare control (issue #1262 review).
+ SupersedeEarlierLink(existing, fieldName);
+ if (merged.HasValue) RunDuringRender(merged, merged.Value);
+
return (T)el.SetAttached(merged);
}
///
- /// Attaches validators to this element along with the current field value.
- /// When placed inside a FormFieldElement, validators run automatically — no manual
- /// ValidationReconciler.ValidateField() call needed.
+ /// Attaches validators to this element along with the current field value, and —
+ /// when called from inside a component's Render() — runs them immediately
+ /// against the enclosing .
+ ///
+ /// Running during render rather than during reconcile is what makes the results
+ /// readable by the same Render() that produced them, so
+ /// When(ctx.IsTouched(f) && ctx.HasError(f), …) placed after this call
+ /// sees the current verdict instead of the previous pass's.
+ ///
+ ///
+ /// The validators are still attached, so FormField keeps working for
+ /// elements built outside a render pass. Re-running them is harmless: results are
+ /// applied with a structural diff. The visualizers only *display* what a context
+ /// already holds — they never run attached validators — so an element that reaches
+ /// neither a render pass nor a FormField contributes no verdict.
+ ///
///
public static T Validate(this T el, string fieldName, object? value, params IValidator[] validators) where T : Element
{
@@ -58,9 +93,13 @@ public static T Validate(this T el, string fieldName, object? value, params I
{
FieldName = fieldName,
Value = value,
+ HasValue = true,
Validators = [.. existing.Validators, .. validators]
}
- : new ValidationAttached(fieldName, validators, []) { Value = value };
+ : new ValidationAttached(fieldName, validators, []) { Value = value, HasValue = true };
+
+ SupersedeEarlierLink(existing, fieldName);
+ RunDuringRender(merged, value);
return (T)el.SetAttached(merged);
}
@@ -77,11 +116,37 @@ public static T ValidateAsync(this T el, string fieldName, params IAsyncValid
AsyncValidators = [.. existing.AsyncValidators, .. asyncValidators]
}
: new ValidationAttached(fieldName, [], asyncValidators);
+ SupersedeEarlierLink(existing, fieldName);
+
+ // Attach-only for the async validators themselves — nothing runs those during a
+ // synchronous render. But a chain that already carries a value has a *sync*
+ // verdict in flight whose claim this link just superseded, so the merged
+ // attachment has to re-run those validators and take the claim over. Without it
+ // `.Validate(f, v, …).ValidateAsync(f, …)` published a verdict that no mounted
+ // control owned, and a bare control left it behind on unmount
+ // (issue #1262 review). RunDuringRender is a no-op when there are no sync
+ // validators, so a purely async attachment is unaffected.
+ if (merged.HasValue) RunDuringRender(merged, merged.Value);
+
return (T)el.SetAttached(merged);
}
///
- /// Attaches async validators with the current field value for automatic validation.
+ /// Attaches async validators to this element along with the current field value, and
+ /// registers the field so MarkAllTouched() covers it.
+ ///
+ /// This overload is attach-only: it does not run the validators. Nothing
+ /// consumes automatically — not the
+ /// render scope, which must stay synchronous, and not FormField. Run them
+ /// yourself from an effect via
+ /// , which carries the
+ /// generation guard that discards a result superseded by a newer value.
+ ///
+ ///
+ /// Registration happens here for an element built during a render, and again when
+ /// FormField mounts or updates it, which is the only chance an element
+ /// assembled outside a render pass gets.
+ ///
///
public static T ValidateAsync(this T el, string fieldName, object? value, params IAsyncValidator[] asyncValidators) where T : Element
{
@@ -91,12 +156,62 @@ public static T ValidateAsync(this T el, string fieldName, object? value, par
{
FieldName = fieldName,
Value = value,
+ HasValue = true,
AsyncValidators = [.. existing.AsyncValidators, .. asyncValidators]
}
- : new ValidationAttached(fieldName, [], asyncValidators) { Value = value };
+ : new ValidationAttached(fieldName, [], asyncValidators) { Value = value, HasValue = true };
+
+ // Async validators cannot resolve inside a synchronous render, but the field
+ // still has to be registered or MarkAllTouched() would skip it.
+ SupersedeEarlierLink(existing, fieldName);
+ ValidationRenderScope.Current?.RegisterField(fieldName);
+
+ // Re-runs only the sync validators a preceding `.Validate(f, v, …)` link
+ // contributed, so the surviving attachment owns their verdict — see the
+ // validator-only overload above. A no-op when the chain carries none.
+ RunDuringRender(merged, value);
+
return (T)el.SetAttached(merged);
}
+ ///
+ /// Pushes a freshly-attached field's verdict into the context that is rendering, if
+ /// any. Outside a render pass — an element assembled in an event handler, a cached
+ /// element, a headless unit test — there is no context to reach and this is a no-op,
+ /// leaving .Validate() purely declarative as it has always been.
+ ///
+ private static void RunDuringRender(ValidationAttached attached, object? value)
+ {
+ if (attached.Validators.Length == 0) return;
+ var ctx = ValidationRenderScope.Current;
+ if (ctx is null) return;
+ ValidationReconciler.ValidateAttached(ctx, attached, value);
+ ValidationRenderScope.RecordOwnership(
+ attached, ctx, ctx.GetProducerStamp(attached.FieldName, ValidationContext.SyncProducer));
+ }
+
+ ///
+ /// Withdraws the verdict an earlier link of the same chain already wrote, when this
+ /// link moves the attachment to a different field.
+ ///
+ /// Every link evaluates eagerly, because a chain that only ran at its end would drop
+ /// the earlier links' validators for a bare control. The attachment that survives
+ /// carries only the final FieldName though, so
+ /// .Validate("a", x, …).Validate("b", y, …) left field a holding a
+ /// verdict nothing would ever revisit — permanently invalid, and invisible, since no
+ /// control is associated with it. The earlier link's own claim is what identifies
+ /// that write, so only what this chain actually installed is withdrawn.
+ ///
+ ///
+ private static void SupersedeEarlierLink(ValidationAttached? existing, string nextField)
+ {
+ if (existing is null) return;
+ if (!ValidationRenderScope.TryTakeOwnership(existing, out var owned)) return;
+ if (string.Equals(existing.FieldName, nextField, StringComparison.Ordinal)) return;
+
+ owned.Context.RetireProducer(existing.FieldName, ValidationContext.SyncProducer, owned.Stamp);
+ }
+
///
/// Runs all synchronous validators attached to an element against a value.
/// Returns the list of validation messages (empty if all pass).
diff --git a/src/Reactor/Controls/Validation/ValidationContext.cs b/src/Reactor/Controls/Validation/ValidationContext.cs
index a6052e8d3..cabf9adeb 100644
--- a/src/Reactor/Controls/Validation/ValidationContext.cs
+++ b/src/Reactor/Controls/Validation/ValidationContext.cs
@@ -9,6 +9,28 @@ public sealed class ValidationContext
private readonly object _lock = new();
private readonly Dictionary> _messages = new();
private readonly Dictionary> _externalMessages = new();
+ // field -> producer -> the exact instances that producer last contributed, so each
+ // producer can retract its own messages without disturbing the others on that field.
+ // field -> the owner of each entry in _messages[field], same length and order.
+ // A null entry was added directly via Add(...) and belongs to no producer.
+ //
+ // Positional rather than by instance: ValidationMessage is immutable, so a validator
+ // may legitimately cache and return one instance, and Add(...) is public — the same
+ // instance can therefore appear twice under two different owners. Matching by
+ // reference could not tell those apart, so retiring one producer removed the other's
+ // message too and the field went spuriously valid (issue #1262 review).
+ private readonly Dictionary> _messageOwners = new();
+ // field -> newest async pass token; older passes that resolve late are discarded.
+ private readonly Dictionary> _asyncGeneration = new();
+ // field -> producer -> the token issued the last time anything wrote that slot.
+ // A mounted control records the token its own contribution got, and retracts on
+ // unmount only while it still matches — so a control leaving the tree withdraws its
+ // own verdict but never one a later writer installed in the same slot.
+ private readonly Dictionary> _producerStamp = new(StringComparer.Ordinal);
+ private long _producerTicket;
+ // Context-wide token source. Never reset, so a token retired by a clear can never be
+ // handed out again while the pass holding it is still in flight.
+ private int _asyncTicket;
private readonly HashSet _registeredFields = new();
private readonly HashSet _touchedFields = new();
private readonly Dictionary _initialValues = new();
@@ -16,6 +38,58 @@ public sealed class ValidationContext
private int _version;
+ ///
+ /// Raised after any mutation that actually changed observable state — a message
+ /// appearing or disappearing, a field becoming touched, a reset.
+ ///
+ /// UseValidationContext() subscribes to this so that mutating the context
+ /// from an event handler (the canonical case being on a
+ /// submit attempt) repaints the form. Without it, an invalid submit changes nothing
+ /// the user can see.
+ ///
+ ///
+ /// Two rules keep this from driving a render loop. First, re-running the same
+ /// validators over an unchanged value is silent: results are applied with a
+ /// structural diff, so an idempotent re-validation raises nothing. Second,
+ /// mutations made while a render is in flight are not announced inline —
+ /// the rendering component reads the new state later in the same pass, and
+ /// notifying there would re-enter the reconciler's re-render path from inside
+ /// Render(). They are held and delivered once the pass finishes, so other
+ /// subscribers — a parent rendering IsValid(), say — still hear about them;
+ /// a pass that ends with the state it started with is dropped rather than
+ /// delivered, since there is nothing to announce.
+ ///
+ ///
+ public event Action? Changed
+ {
+ add
+ {
+ if (value is null) return;
+ bool deliverNow;
+ lock (_lock)
+ {
+ _changed += value;
+ deliverNow = _notificationPending;
+ _notificationPending = false;
+ }
+
+ // A deferred notification that found no subscriber is held rather than
+ // dropped: during an initial root render the reconciler flushes the
+ // deferral before root effects run, so a parent that is about to
+ // subscribe would otherwise never hear that a child invalidated the
+ // shared context, and its rendered IsValid()/summary would stay stale.
+ if (deliverNow) value();
+ }
+ remove
+ {
+ if (value is null) return;
+ lock (_lock) _changed -= value;
+ }
+ }
+
+ private Action? _changed;
+ private bool _notificationPending;
+
///
/// Monotonically increasing version number, bumped on every mutation.
/// Useful for change detection in hooks/memos.
@@ -25,6 +99,258 @@ public int Version
get { lock (_lock) return _version; }
}
+ ///
+ /// Announces a real change. Must be called *outside* — a
+ /// subscriber re-entering the context (for example a re-render that immediately
+ /// re-reads messages) would otherwise take the lock recursively from the handler.
+ ///
+ /// A change made while a render is in flight is deferred rather than dropped. The
+ /// component doing the rendering needs no notification — it observes the new state
+ /// later in the same pass — but other subscribers do: a parent that renders
+ /// ctx.IsValid() and provides the context would otherwise never learn that a
+ /// child's eager .Validate() invalidated it, leaving its summary or submit
+ /// state stale.
+ ///
+ ///
+ private void RaiseChanged(bool messagesOnly = false)
+ {
+ if (ValidationRenderScope.InRender)
+ {
+ if (!messagesOnly)
+ {
+ lock (_lock) _frameTouchedNonMessageState = true;
+ }
+ ValidationRenderScope.DeferNotification(this);
+ return;
+ }
+
+ // Outside a render the snapshot can no longer be trusted as "what subscribers
+ // were last told", so stop suppressing against it.
+ lock (_lock)
+ {
+ _lastNotifiedMessages = null;
+ _lastNotifiedValues = null;
+ _lastNotifiedInitials = null;
+ _lastNotifiedTouched = null;
+ }
+ _changed?.Invoke();
+ }
+
+ ///
+ /// Delivers a notification that was deferred because it happened mid-render.
+ /// Posted through the UI dispatcher when one is available so it lands after the
+ /// in-flight reconcile rather than re-entering it; falls back to an inline raise in
+ /// headless hosts, which keeps unit tests deterministic.
+ ///
+ /// The subscriber list is read when the callback runs, not when it is queued. A
+ /// host flushes root effects after reconciliation, so a parent's
+ /// UseValidationContext() subscription may not exist yet at queue time;
+ /// snapshotting there dropped the notification the parent was waiting for. If
+ /// there is still no subscriber at delivery time the notification is held for the
+ /// first one to arrive.
+ ///
+ ///
+ internal void NotifyDeferred()
+ {
+ var dispatcher = global::Microsoft.UI.Reactor.ReactorApp.UIDispatcher;
+ if (dispatcher is not null && dispatcher.TryEnqueue(DeliverDeferred))
+ return;
+
+ DeliverDeferred();
+ }
+
+ private void DeliverDeferred()
+ {
+ Action? handler;
+ lock (_lock)
+ {
+ // A render can churn a field's messages and land exactly where it started:
+ // chaining two value overloads applies the first call's partial verdict and
+ // then the second call's full one, both under the sync producer. Each write
+ // is a real change, so the frame defers a notification, which repaints, which
+ // churns again — an endless loop from a net-zero pass. Announce only when the
+ // messages actually ended up different from what subscribers were last told.
+ var snapshot = MessageSnapshotLocked();
+ if ((!_frameTouchedNonMessageState || NonMessageStateUnchangedLocked())
+ && _lastNotifiedMessages is not null
+ && string.Equals(_lastNotifiedMessages, snapshot, StringComparison.Ordinal))
+ {
+ _frameTouchedNonMessageState = false;
+ _frameVersionPending = false;
+ return;
+ }
+
+ if (_frameVersionPending)
+ {
+ _version++;
+ _frameVersionPending = false;
+ }
+
+ _lastNotifiedMessages = snapshot;
+ CaptureNonMessageStateLocked();
+ _frameTouchedNonMessageState = false;
+
+ handler = _changed;
+ if (handler is null)
+ {
+ _notificationPending = true;
+ return;
+ }
+ _notificationPending = false;
+ }
+ handler.Invoke();
+ }
+
+ ///
+ /// A deterministic rendering of every message the context currently holds, used to
+ /// tell a net-zero render pass from a real one. Field names are sorted so dictionary
+ /// iteration order cannot matter, but each field's list keeps its own order:
+ /// exposes that order and callers read the first message,
+ /// so a reordering is a real change subscribers have to hear about.
+ ///
+ /// Every value is length-prefixed rather than delimiter-separated. Field names, codes
+ /// and message text are arbitrary strings, so a separator-only encoding lets one
+ /// message whose text happens to contain the separators serialize identically to two
+ /// — and a real change would then be mistaken for net-zero and suppressed.
+ ///
+ ///
+ private string MessageSnapshotLocked()
+ {
+ var fields = new List(_messages.Keys);
+ fields.AddRange(_externalMessages.Keys.Where(field => !_messages.ContainsKey(field)));
+ fields.Sort(StringComparer.Ordinal);
+
+ var sb = new global::System.Text.StringBuilder();
+ foreach (var field in fields)
+ {
+ AppendCounted(sb, field);
+ _messages.TryGetValue(field, out var owned);
+ _externalMessages.TryGetValue(field, out var external);
+ sb.Append(owned?.Count ?? 0).Append('/').Append(external?.Count ?? 0).Append('|');
+
+ if (owned is not null)
+ {
+ foreach (var m in owned) AppendMessage(sb, 'i', m);
+ }
+ if (external is not null)
+ {
+ foreach (var m in external) AppendMessage(sb, 'e', m);
+ }
+ }
+ return sb.ToString();
+ }
+
+ private static void AppendMessage(global::System.Text.StringBuilder sb, char kind, ValidationMessage message)
+ {
+ sb.Append(kind).Append((int)message.Severity).Append(':');
+ AppendCounted(sb, message.Code);
+ AppendCounted(sb, message.Text);
+ }
+
+ private static void AppendCounted(global::System.Text.StringBuilder sb, string? value)
+ {
+ if (value is null)
+ {
+ sb.Append("n|");
+ return;
+ }
+ sb.Append(value.Length).Append('|').Append(value);
+ }
+
+ private string? _lastNotifiedMessages;
+ private Dictionary? _lastNotifiedValues;
+ private Dictionary? _lastNotifiedInitials;
+ private HashSet? _lastNotifiedTouched;
+ private bool _lastNotifiedSubmitAttempted;
+ private int _lastNotifiedRegistered;
+ private bool _frameTouchedNonMessageState;
+ private bool _frameVersionPending;
+
+ ///
+ /// Whether the state a message snapshot cannot see — field values, touched flags,
+ /// the registered set — ended the frame where it started.
+ ///
+ /// A flag alone is not enough, because a render can churn that state and land back
+ /// where it began. Chaining two value overloads on one field is the case that bites:
+ /// each link records its own value, so _currentValues flips to the first
+ /// link's value and back to the second's on every pass. Both writes are real value
+ /// changes, so the frame announced one every time, which repainted, which churned
+ /// again. Only the net result is a change subscribers need to hear about.
+ ///
+ ///
+ private bool NonMessageStateUnchangedLocked()
+ {
+ if (_lastNotifiedValues is null || _lastNotifiedInitials is null || _lastNotifiedTouched is null)
+ return false;
+ if (_lastNotifiedRegistered != _registeredFields.Count) return false;
+ if (_lastNotifiedValues.Count != _currentValues.Count) return false;
+ if (_lastNotifiedInitials.Count != _initialValues.Count) return false;
+ if (!_lastNotifiedTouched.SetEquals(_touchedFields)) return false;
+ // The submit flag is the whole of what MarkAllTouched() changes once every field
+ // is already touched. Omitting it let a MarkAllTouched() raised from an effect
+ // during reconciliation look net-zero: the notification was dropped and the held
+ // Version bump cancelled, so a ShowWhen.AfterFirstSubmit field stayed hidden
+ // after the submit it was told about (issue #1262 review).
+ if (_lastNotifiedSubmitAttempted != _submitAttempted) return false;
+
+ foreach (var (field, value) in _currentValues)
+ {
+ if (!_lastNotifiedValues.TryGetValue(field, out var previous)) return false;
+ if (!Equals(previous, value)) return false;
+ }
+
+ // Baselines, not just current values: re-baselining an edited field with
+ // SetInitialValue flips IsDirty without touching _currentValues, and a
+ // subscriber rendering dirty state has to hear about that (issue #1262 review).
+ foreach (var (field, initial) in _initialValues)
+ {
+ if (!_lastNotifiedInitials.TryGetValue(field, out var previous)) return false;
+ if (!Equals(previous, initial)) return false;
+ }
+ return true;
+ }
+
+ private void CaptureNonMessageStateLocked()
+ {
+ _lastNotifiedValues = new Dictionary(_currentValues);
+ _lastNotifiedInitials = new Dictionary(_initialValues);
+ _lastNotifiedTouched = new HashSet(_touchedFields, StringComparer.Ordinal);
+ _lastNotifiedRegistered = _registeredFields.Count;
+ _lastNotifiedSubmitAttempted = _submitAttempted;
+ }
+
+ ///
+ /// Bumps , except during a render: those are held until the
+ /// frame closes and bumped once, and only if the frame ended with different state
+ /// than it started with.
+ ///
+ /// Bumping eagerly made a net-zero pass — the chained value overloads above —
+ /// increment Version on every render forever, so a UseMemo or
+ /// UseEffect keyed on it re-ran for a context that had not actually moved.
+ ///
+ ///
+ /// Value changes are held too, not just message-only ones. A chain such as
+ /// .Validate("f", "", …).Validate("f", "bb", …) rewrites the current value
+ /// twice per render and lands where it started, so was
+ /// correctly silent while Version grew without bound — the same defect the
+ /// suppression exists to prevent, surviving in the one signal a memo is most likely
+ /// to be keyed on (issue #1262 review).
+ ///
+ ///
+ private void BumpVersionLocked()
+ {
+ if (ValidationRenderScope.InRender)
+ {
+ _frameVersionPending = true;
+ // Guarantee the held bump is settled. Not every bump is paired with a
+ // notification — BeginAsyncValidation bumps and stays quiet — and a pending
+ // bump that no deliver ever reaches would leave Version silently lagging.
+ ValidationRenderScope.DeferNotification(this);
+ return;
+ }
+ _version++;
+ }
+
// ════════════════════════════════════════════════════════════════
// Field registration
// ════════════════════════════════════════════════════════════════
@@ -35,12 +361,28 @@ public int Version
///
public void RegisterField(string field)
{
+ bool changed;
lock (_lock)
{
- _registeredFields.Add(field);
+ changed = RegisterFieldLocked(field);
+ if (changed) BumpVersionLocked();
}
+ if (changed) RaiseChanged();
}
+ ///
+ /// Adds a field to the registered set, reporting whether it was actually new.
+ ///
+ /// Registration is observable: is public, and
+ /// and the validity summary both iterate it. Every
+ /// registration path therefore has to fold this into its change decision, or a
+ /// subscriber rendering the field set goes stale — which is what happened while five
+ /// separate call sites each added to the set directly and none of them counted it
+ /// (issue #1262 review).
+ ///
+ ///
+ private bool RegisterFieldLocked(string field) => _registeredFields.Add(field);
+
///
/// Returns all registered field names.
///
@@ -74,8 +416,14 @@ public void Add(ValidationMessage message)
_messages[message.Field] = list;
}
list.Add(message);
- _version++;
+ // Owned by no producer, so it is never substituted or retracted by one.
+ if (!_messageOwners.TryGetValue(message.Field, out var owners))
+ _messageOwners[message.Field] = owners = new List(list.Count);
+ while (owners.Count < list.Count - 1) owners.Add(null);
+ owners.Add(null);
+ BumpVersionLocked();
}
+ RaiseChanged(messagesOnly: true);
}
///
@@ -100,26 +448,51 @@ public void AddExternal(ValidationMessage message)
_externalMessages[message.Field] = list;
}
list.Add(message);
- _version++;
+ BumpVersionLocked();
}
+ RaiseChanged(messagesOnly: true);
}
// ════════════════════════════════════════════════════════════════
// Message clearing
// ════════════════════════════════════════════════════════════════
+ ///
+ /// Drops every piece of per-field producer bookkeeping in one place: owned messages,
+ /// ownership stamps, in-flight async tokens, and rule-set membership.
+ ///
+ /// A helper rather than four lines repeated at each call site, because they have to
+ /// move together and did not: _producerStamp was added later and the clearing
+ /// paths kept dropping only the other three, which both broke the documented
+ /// invariant that a stamp exists exactly while its producer owns messages and grew
+ /// the map without bound for dynamically named fields (issue #1262 review).
+ ///
+ ///
+ private void DropFieldProducerStateLocked(string field)
+ {
+ _messageOwners.Remove(field);
+ _producerStamp.Remove(field);
+ // An async pass still in flight would otherwise repopulate what the caller just
+ // cleared: dropping the token makes its result stale on arrival.
+ _asyncGeneration.Remove(field);
+ InvalidateRuleSetsLocked(field);
+ }
+
///
/// Clears all validation messages (both internal and external) for the specified field.
///
public void Clear(string field)
{
+ bool changed;
lock (_lock)
{
- var changed = false;
+ changed = false;
if (_messages.Remove(field)) changed = true;
if (_externalMessages.Remove(field)) changed = true;
- if (changed) _version++;
+ DropFieldProducerStateLocked(field);
+ if (changed) BumpVersionLocked();
}
+ if (changed) RaiseChanged(messagesOnly: true);
}
///
@@ -128,11 +501,338 @@ public void Clear(string field)
///
internal void ClearInternal(string field)
{
+ bool changed;
lock (_lock)
{
- if (_messages.Remove(field))
- _version++;
+ changed = _messages.Remove(field);
+ DropFieldProducerStateLocked(field);
+ if (changed) BumpVersionLocked();
+ }
+ if (changed) RaiseChanged(messagesOnly: true);
+ }
+
+
+ ///
+ /// Installs one producer's contribution to a field's internal messages, leaving
+ /// every other producer's messages on that field untouched.
+ ///
+ /// A field is written by several independent producers: the synchronous
+ /// .Validate() chain, each cross-field ValidationRule, and the async
+ /// validator pass. Replacing the whole field — which the original clear-then-add
+ /// did — means the last writer wins, so a passing rule could erase a required-field
+ /// error and make
+ /// true. Each producer now retracts only the exact instances
+ /// it contributed last time.
+ ///
+ ///
+ /// Replacements happen in place rather than by removing and appending, which keeps
+ /// message order stable across passes. Appending would let two producers on the same
+ /// field swap positions every render — a structural difference on every pass, and so
+ /// a notification on every pass, which is precisely the loop this design exists to
+ /// avoid.
+ ///
+ ///
+ internal void ApplyOwned(string field, string producer, List messages)
+ {
+ bool changed;
+ lock (_lock)
+ {
+ changed = ApplyOwnedLocked(field, producer, messages);
+ if (changed) BumpVersionLocked();
+ }
+ if (changed) RaiseChanged(messagesOnly: true);
+ }
+
+ private bool ApplyOwnedLocked(string field, string producer, List messages)
+ {
+ _messages.TryGetValue(field, out var current);
+ _messageOwners.TryGetValue(field, out var owners);
+
+ var capacity = (current?.Count ?? 0) + messages.Count;
+ var next = new List(capacity);
+ var nextOwners = new List(capacity);
+ var taken = 0;
+
+ if (current is not null)
+ {
+ for (var i = 0; i < current.Count; i++)
+ {
+ // Positional, not by instance: two entries can be the same immutable
+ // ValidationMessage under different owners, and only the index tells
+ // them apart.
+ var owner = owners is not null && i < owners.Count ? owners[i] : null;
+ if (string.Equals(owner, producer, StringComparison.Ordinal))
+ {
+ // Substitute this producer's next message at the same position.
+ if (taken < messages.Count)
+ {
+ next.Add(messages[taken++]);
+ nextOwners.Add(producer);
+ }
+ continue;
+ }
+ next.Add(current[i]);
+ nextOwners.Add(owner);
+ }
+ }
+
+ for (; taken < messages.Count; taken++)
+ {
+ next.Add(messages[taken]);
+ nextOwners.Add(producer);
+ }
+
+ var changed = !SameMessages(current, next);
+ if (changed)
+ {
+ if (next.Count == 0)
+ _messages.Remove(field);
+ else
+ _messages[field] = next;
+ }
+
+ // Ownership is rewritten whether or not the values changed. It is positional and
+ // was built alongside `next`, which is same-length and same-order as whatever is
+ // installed, so it describes the live list either way. (The old instance-keyed
+ // map had to skip this case to avoid naming freshly allocated equal-but-distinct
+ // instances that were never installed; positions have no such hazard.)
+ if (next.Count == 0)
+ _messageOwners.Remove(field);
+ else
+ _messageOwners[field] = nextOwners;
+
+ // A stamp exists exactly while the producer owns something. Writing one while
+ // dropping the ownership would leave an entry behind on every retraction, and
+ // every mounted rule gets a fresh `rule#N` identity — so a long-lived context
+ // that sees rules mount and unmount would accumulate them without bound.
+ if (messages.Count == 0) RemoveProducerStampLocked(field, producer);
+ else StampProducerLocked(field, producer);
+
+ return changed;
+ }
+
+ private void StampProducerLocked(string field, string producer)
+ {
+ var token = unchecked(++_producerTicket);
+ if (!_producerStamp.TryGetValue(field, out var byProducer))
+ _producerStamp[field] = byProducer = new Dictionary(StringComparer.Ordinal);
+ byProducer[producer] = token;
+ }
+
+ private void RemoveProducerStampLocked(string field, string producer)
+ {
+ if (!_producerStamp.TryGetValue(field, out var byProducer)) return;
+ byProducer.Remove(producer);
+ if (byProducer.Count == 0) _producerStamp.Remove(field);
+ }
+
+ ///
+ /// The number of producer slots currently carrying an ownership stamp. Exposed for
+ /// tests: a stamp must exist exactly while its producer owns messages, so a context
+ /// that has seen producers come and go must not accumulate them.
+ ///
+ internal int ProducerStampEntryCount
+ {
+ get
+ {
+ lock (_lock)
+ {
+ var total = 0;
+ foreach (var byProducer in _producerStamp.Values) total += byProducer.Count;
+ return total;
+ }
+ }
+ }
+
+ ///
+ /// The token issued by the most recent write to a producer slot. Callers hold it to
+ /// make a later retraction conditional on still owning the slot.
+ ///
+ internal long GetProducerStamp(string field, string producer)
+ {
+ lock (_lock)
+ {
+ return _producerStamp.TryGetValue(field, out var byProducer)
+ && byProducer.TryGetValue(producer, out var stamp) ? stamp : 0;
+ }
+ }
+
+ private static bool SameMessages(List? a, List b)
+ {
+ var countA = a?.Count ?? 0;
+ if (countA != b.Count) return false;
+ for (var i = 0; i < b.Count; i++)
+ {
+ if (a![i] != b[i]) return false;
+ }
+ return true;
+ }
+
+ ///
+ /// Installs the complete result of an async validation pass for a field in one step.
+ ///
+ /// is the token handed out by
+ /// when the pass started. Passes
+ /// for successive values race — an older value's checks can resolve after a newer
+ /// value's — so a result that is no longer the newest is discarded rather than
+ /// overwriting the current verdict with a stale one.
+ ///
+ ///
+ internal void ApplyAsyncValidation(string field, int generation, List messages)
+ => ApplyAsyncOwned(field, AsyncProducer, generation, messages);
+
+ ///
+ /// Installs an async producer's result for a field, but only if it is still the
+ /// newest pass that producer opened. Generations are tracked per producer because
+ /// several can write the same field — an async ValidationRule alongside the
+ /// field's own .ValidateAsync(...) — and a shared token would let whichever
+ /// finished last cancel the other.
+ ///
+ internal void ApplyAsyncOwned(string field, string producer, int generation, List messages)
+ {
+ bool changed;
+ lock (_lock)
+ {
+ if (!_asyncGeneration.TryGetValue(field, out var byProducer)
+ || !byProducer.TryGetValue(producer, out var newest)
+ || newest != generation)
+ return;
+
+ changed = ApplyOwnedLocked(field, producer, messages);
+ if (changed) BumpVersionLocked();
+ }
+ if (changed) RaiseChanged(messagesOnly: true);
+ }
+
+ ///
+ /// Opens an async validation pass for a field and returns the token that identifies
+ /// it. Only the most recently opened pass is allowed to install a result.
+ ///
+ /// Tokens come from a context-wide counter rather than a per-field one. The clearing
+ /// operations drop a field's entry so a pending pass can't repopulate what they just
+ /// removed — with a per-field counter that also reset the sequence, so a pass opened
+ /// after a clear could be handed the same number an older in-flight pass was still
+ /// holding, and the stale result would pass the equality check.
+ ///
+ ///
+ internal int BeginAsyncValidation(string field) => BeginAsyncProducer(field, AsyncProducer);
+
+ ///
+ /// Records the value a field is about to be validated against and opens the async
+ /// pass for it, under one lock.
+ ///
+ /// Doing the two as separate calls let concurrent callers interleave as
+ /// old.record, new.record + new.open, old.open — leaving the
+ /// *older* value's pass holding the newest token, free to install a verdict about a
+ /// value that had already been replaced.
+ ///
+ ///
+ internal int BeginAsyncValidation(string field, object? value)
+ {
+ bool changed;
+ int token;
+ lock (_lock)
+ {
+ var newField = RegisterFieldLocked(field);
+
+ var known = _currentValues.TryGetValue(field, out var previous);
+ changed = !known || !Equals(previous, value);
+ if (changed)
+ {
+ _currentValues[field] = value;
+ _externalMessages.Remove(field);
+ RetractAsyncProducersLocked(field);
+ InvalidateRuleSetsLocked(field);
+ BumpVersionLocked();
+ }
+ else if (newField)
+ {
+ // The value is unchanged, but the field set is not — and that is
+ // observable on its own.
+ changed = true;
+ BumpVersionLocked();
+ }
+
+ token = unchecked(++_asyncTicket);
+ if (!_asyncGeneration.TryGetValue(field, out var byProducer))
+ _asyncGeneration[field] = byProducer = new Dictionary(StringComparer.Ordinal);
+ byProducer[AsyncProducer] = token;
+ }
+ if (changed) RaiseChanged();
+ return token;
+ }
+
+ ///
+ /// Opens an async pass for one producer on a field. Overlapping evaluations of the
+ /// same producer are ordered by this token: an older one that resolves last is
+ /// discarded instead of reinstating a verdict about a value or predicate input that
+ /// has already been superseded.
+ ///
+ internal int BeginAsyncProducer(string field, string producer)
+ {
+ lock (_lock)
+ {
+ var token = unchecked(++_asyncTicket);
+ if (!_asyncGeneration.TryGetValue(field, out var byProducer))
+ _asyncGeneration[field] = byProducer = new Dictionary(StringComparer.Ordinal);
+ byProducer[producer] = token;
+ return token;
+ }
+ }
+
+ internal const string SyncProducer = "sync";
+ internal const string AsyncProducer = "async";
+
+ ///
+ /// Registers a field, records its value, and installs its validator results as one
+ /// atomic step — a single lock, a single version bump, and at most one
+ /// notification raised only after everything is in place.
+ ///
+ /// Doing this as three calls let a subscriber observe the context mid-update: on a
+ /// first mount, would see an unknown field, raise,
+ /// and synchronously drive a re-render that read the *previous* pass's messages
+ /// because the replacement had not happened yet. The reconcile-time FormField
+ /// path reaches this code after the render scope has closed, so that notification
+ /// was not suppressed.
+ ///
+ ///
+ internal void ApplyValidation(string field, object? value, List messages)
+ {
+ bool changed;
+ bool valueChangedForNotify;
+ lock (_lock)
+ {
+ var newField = RegisterFieldLocked(field);
+
+ var known = _currentValues.TryGetValue(field, out var previous);
+ var valueChanged = !known || !Equals(previous, value);
+ var messagesChanged = false;
+
+ if (valueChanged)
+ {
+ _currentValues[field] = value;
+ // A server verdict about the old value says nothing about the new one.
+ _externalMessages.Remove(field);
+ // Nor does a named rule set still being evaluated: its verdicts were
+ // computed from the value that has just been replaced.
+ InvalidateRuleSetsLocked(field);
+
+ // Neither does an async verdict. Retire the in-flight passes so their
+ // results are discarded on arrival, and withdraw whatever the last ones
+ // installed — otherwise an error computed for a value the user has
+ // already replaced stays on screen indefinitely.
+ if (RetractAsyncProducersLocked(field)) messagesChanged = true;
+ }
+
+ // Owned rather than wholesale: a cross-field ValidationRule may also be
+ // writing this field, and it must survive the sync pass.
+ if (ApplyOwnedLocked(field, SyncProducer, messages)) messagesChanged = true;
+
+ changed = valueChanged || messagesChanged || newField;
+ valueChangedForNotify = valueChanged || newField;
+ if (changed) BumpVersionLocked();
}
+ if (changed) RaiseChanged(messagesOnly: !valueChangedForNotify);
}
///
@@ -140,11 +840,13 @@ internal void ClearInternal(string field)
///
public void ClearExternal(string field)
{
+ bool changed;
lock (_lock)
{
- if (_externalMessages.Remove(field))
- _version++;
+ changed = _externalMessages.Remove(field);
+ if (changed) BumpVersionLocked();
}
+ if (changed) RaiseChanged(messagesOnly: true);
}
///
@@ -152,12 +854,19 @@ public void ClearExternal(string field)
///
public void ClearAll()
{
+ bool changed;
lock (_lock)
{
+ changed = _messages.Count > 0 || _externalMessages.Count > 0;
_messages.Clear();
_externalMessages.Clear();
- _version++;
+ _messageOwners.Clear();
+ _producerStamp.Clear();
+ _asyncGeneration.Clear();
+ InvalidateRuleSetsLocked(null);
+ if (changed) BumpVersionLocked();
}
+ if (changed) RaiseChanged(messagesOnly: true);
}
// ════════════════════════════════════════════════════════════════
@@ -295,24 +1004,56 @@ public bool IsTouched(string field)
///
public void MarkTouched(string field)
{
+ bool changed;
lock (_lock)
{
- if (_touchedFields.Add(field))
- _version++;
+ changed = _touchedFields.Add(field);
+ if (changed) BumpVersionLocked();
}
+ if (changed) RaiseChanged();
}
///
/// Marks all registered fields as touched. Typically called on form submit.
+ ///
+ /// Also records that a submit was attempted, which is what
+ /// waits for. The framework has no other
+ /// notion of "submit": this is the call the guide tells you to make on a submit
+ /// attempt, so tying the two together is what makes that policy reachable on a
+ /// FormField at all — before this it could never show an error, behaving
+ /// identically to (issue #1262).
+ ///
///
public void MarkAllTouched()
{
+ bool changed;
lock (_lock)
{
+ // Add unconditionally and compare the set size rather than branching per
+ // field: HashSet.Add already de-duplicates, so a filtered loop would only
+ // add a second hash lookup per field (and, via LINQ, an allocation) inside
+ // this lock to reach the same answer.
+ var touchedBefore = _touchedFields.Count;
foreach (var field in _registeredFields)
_touchedFields.Add(field);
- _version++;
+
+ changed = _touchedFields.Count != touchedBefore || !_submitAttempted;
+ _submitAttempted = true;
+ if (changed) BumpVersionLocked();
}
+ if (changed) RaiseChanged();
+ }
+
+ private bool _submitAttempted;
+
+ ///
+ /// Whether has been called since the last reset — the
+ /// context's record of a submit attempt, read by
+ /// .
+ ///
+ public bool SubmitAttempted
+ {
+ get { lock (_lock) return _submitAttempted; }
}
// ════════════════════════════════════════════════════════════════
@@ -321,28 +1062,365 @@ public void MarkAllTouched()
///
/// Stores the initial value for a field. Called at field registration time.
+ ///
+ /// The current value is seeded only the first time the field is seen. Re-seeding it
+ /// on every call would rewind whatever the user has since typed — harmless while
+ /// nothing watched the context, but with change notification it becomes a permanent
+ /// repaint loop for the common SetInitialValue(...) +
+ /// NotifyValueChanged(...) pair that components run on each render: the
+ /// rewind and the re-notify would take turns forever. Use
+ /// to deliberately return a field to its baseline.
+ ///
///
public void SetInitialValue(string field, object? value)
{
+ bool changed;
lock (_lock)
{
+ var wasDirty = IsDirtyLocked(field);
+ var hadBaseline = _initialValues.TryGetValue(field, out var previousBaseline);
_initialValues[field] = value;
- _currentValues[field] = value;
+ if (!_currentValues.ContainsKey(field))
+ _currentValues[field] = value;
+
+ // Two ways this is observable, and the dirty flag only catches one of them.
+ // Re-baselining an edited field can flip IsDirty without touching messages or
+ // touched state. It can also leave IsDirty alone — baseline `a`, current `b`,
+ // new baseline `c` stays dirty throughout — while still changing what
+ // Reset(field) will hand back, so the move itself counts (issue #1262 review).
+ //
+ // Scoped to a baseline that already existed and actually moved. Seeding a
+ // field for the first time stays silent, as does the identical re-seed that
+ // components run on every render — the loop this method's remarks warn about.
+ var baselineMoved = hadBaseline && !Equals(previousBaseline, value);
+ changed = baselineMoved || IsDirtyLocked(field) != wasDirty;
+ if (changed) BumpVersionLocked();
}
+ if (changed) RaiseChanged();
+ }
+
+ private bool IsDirtyLocked(string field)
+ {
+ if (!_initialValues.TryGetValue(field, out var initial)) return false;
+ if (!_currentValues.TryGetValue(field, out var current)) return false;
+ return !Equals(initial, current);
}
///
- /// Notifies the context of a field value change. Clears external messages for this field.
+ /// Notifies the context of a field value change. Clears external messages for the
+ /// field, because a server-side verdict about the old value says nothing about the
+ /// new one.
+ ///
+ /// The clear is conditional on the value actually having moved. It used to be
+ /// unconditional, which was survivable only while validation ran rarely: now that
+ /// validators run on every render, an unconditional clear here would destroy any
+ /// message on the very next
+ /// repaint, before the user could read it.
+ ///
+ ///
+ /// An async verdict is retired for the same reason, mirroring
+ /// . A field validated only through
+ /// .ValidateAsync(...) never reaches that method, so without this an error
+ /// computed for a value the user has already replaced would stay on screen, and a
+ /// pass opened against the old value could still install its result afterwards.
+ ///
///
public void NotifyValueChanged(string field, object? value)
{
+ bool changed;
lock (_lock)
{
+ var known = _currentValues.TryGetValue(field, out var previous);
+ changed = !known || !Equals(previous, value);
+ if (!changed) return;
+
_currentValues[field] = value;
- // External messages clear on field value change
- if (_externalMessages.Remove(field))
- _version++;
+ _externalMessages.Remove(field);
+ RetractAsyncProducersLocked(field);
+ InvalidateRuleSetsLocked(field);
+ BumpVersionLocked();
+ }
+ RaiseChanged();
+ }
+
+ ///
+ /// Installs a whole rule set — every producer's verdict plus the retirement of
+ /// producers that have disappeared — as one transaction: one lock, one version bump,
+ /// and at most one notification raised only after everything is
+ /// in place.
+ ///
+ /// Committing producer by producer was observable mid-set: the first verdict's
+ /// notification could drive a subscriber straight back into a new evaluation, and the
+ /// outer call would carry on installing the rest of a set it no longer owned, leaving
+ /// orphaned messages that nothing would ever retract (issue #1262 review).
+ ///
+ ///
+ internal void ApplyRuleSet(
+ List<(string Field, string Producer, List Messages)> verdicts,
+ List<(string Field, string Producer)> retired)
+ {
+ var changed = false;
+ lock (_lock)
+ {
+ foreach (var (field, producer) in retired)
+ {
+ if (_asyncGeneration.TryGetValue(field, out var byProducer))
+ {
+ byProducer.Remove(producer);
+ if (byProducer.Count == 0) _asyncGeneration.Remove(field);
+ }
+ if (ApplyOwnedLocked(field, producer, [])) changed = true;
+ }
+
+ foreach (var (field, producer, messages) in verdicts)
+ {
+ if (RegisterFieldLocked(field)) changed = true;
+ if (ApplyOwnedLocked(field, producer, messages)) changed = true;
+ }
+
+ if (changed) BumpVersionLocked();
+ }
+ if (changed) RaiseChanged(messagesOnly: true);
+ }
+
+ ///
+ /// Opens a named rule set's evaluation and returns the generation identifying it.
+ /// The bookkeeping lives on the context, under the same lock as the messages, so a
+ /// set's generation, its membership and its verdicts are all decided together —
+ /// and so no second lock is held while is raised.
+ ///
+ internal int BeginRuleSet(string setId)
+ {
+ lock (_lock)
+ {
+ if (!_ruleSetTickets.TryGetValue(setId, out var generation)) generation = 0;
+ generation = unchecked(generation + 1);
+ _ruleSetTickets[setId] = generation;
+ return generation;
+ }
+ }
+
+ ///
+ /// Installs a named rule set's verdicts, retires the producers that disappeared from
+ /// it, and records the new membership — as one transaction, and only if this
+ /// evaluation still owns the set.
+ ///
+ /// carries the per-producer async generation each rule
+ /// opened before its predicate ran. They are verified here, at the moment of
+ /// application: a field's value changing in the meantime retires those tokens, so a
+ /// verdict computed from the superseded value is discarded instead of installed.
+ /// The whole set stands down together, because a partially-applied set is exactly
+ /// the state this transaction exists to prevent.
+ ///
+ ///
+ internal void CommitRuleSet(
+ string setId,
+ int generation,
+ List<(string Field, string Producer, List Messages)> verdicts,
+ List<(string Field, string Producer, int Token)>? asyncTokens,
+ List<(string Field, string Producer)>? clearAsync = null)
+ {
+ var changed = false;
+ lock (_lock)
+ {
+ if (!_ruleSetTickets.TryGetValue(setId, out var newest) || newest != generation) return;
+
+ if (asyncTokens is not null)
+ {
+ foreach (var (field, producer, token) in asyncTokens)
+ {
+ if (!_asyncGeneration.TryGetValue(field, out var byProducer)
+ || !byProducer.TryGetValue(producer, out var current)
+ || current != token)
+ return;
+ }
+ }
+
+ // Retiring a producer's async token destroys state a *newer* call may own, so
+ // it happens here, past the ownership check, rather than before the predicates
+ // run (issue #1262 review).
+ if (clearAsync is not null)
+ {
+ foreach (var (field, producer) in clearAsync)
+ ClearAsyncGenerationLocked(field, producer);
+ }
+
+ var applied = new List<(string Field, string Producer)>(verdicts.Count);
+ foreach (var verdict in verdicts)
+ applied.Add((verdict.Field, verdict.Producer));
+
+ if (_ruleSetMembership.TryGetValue(setId, out var previous))
+ {
+ foreach (var entry in previous.Where(entry => !applied.Contains(entry)))
+ {
+ if (_asyncGeneration.TryGetValue(entry.Field, out var byProducer))
+ {
+ byProducer.Remove(entry.Producer);
+ if (byProducer.Count == 0) _asyncGeneration.Remove(entry.Field);
+ }
+ if (ApplyOwnedLocked(entry.Field, entry.Producer, [])) changed = true;
+ }
+ }
+ _ruleSetMembership[setId] = applied;
+
+ foreach (var (field, producer, messages) in verdicts)
+ {
+ if (RegisterFieldLocked(field)) changed = true;
+ if (ApplyOwnedLocked(field, producer, messages)) changed = true;
+ }
+
+ if (changed) BumpVersionLocked();
+ }
+ if (changed) RaiseChanged(messagesOnly: true);
+ }
+
+ private readonly Dictionary _ruleSetTickets = new(StringComparer.Ordinal);
+ private readonly Dictionary> _ruleSetMembership = new(StringComparer.Ordinal);
+
+ ///
+ /// Retires the evaluations of every named rule set that could still write to
+ /// — or of every set when it is null.
+ ///
+ /// Clearing or resetting state has to invalidate them, or an evaluation that was
+ /// already in flight passes its ticket check afterwards and reinstalls verdicts the
+ /// clear had just removed. A set whose membership is not yet recorded is treated as
+ /// affected: its first evaluation is exactly the one most likely to be in flight
+ /// (issue #1262 review).
+ ///
+ ///
+ private void InvalidateRuleSetsLocked(string? field)
+ {
+ if (_ruleSetTickets.Count == 0) return;
+
+ // Materialized because the loop below writes back into the dictionary.
+ var affected = _ruleSetTickets.Keys
+ .Where(setId => field is null
+ || !_ruleSetMembership.TryGetValue(setId, out var members)
+ || members.Any(m => string.Equals(m.Field, field, StringComparison.Ordinal)))
+ .ToList();
+
+ foreach (var setId in affected)
+ _ruleSetTickets[setId] = unchecked(_ruleSetTickets[setId] + 1);
+ }
+
+ ///
+ /// Drops a producer's async generation entry without touching its messages, for a
+ /// producer that has stopped being asynchronous.
+ ///
+ /// A mounted rule keeps its identity across a swap from ValidationRuleAsync to
+ /// ValidationRule. Leaving the stale entry behind would make the next value
+ /// change treat that producer as async and retract a synchronous verdict that is
+ /// still current.
+ ///
+ ///
+ internal void ClearAsyncGeneration(string field, string producer)
+ {
+ lock (_lock) ClearAsyncGenerationLocked(field, producer);
+ }
+
+ private void ClearAsyncGenerationLocked(string field, string producer)
+ {
+ if (!_asyncGeneration.TryGetValue(field, out var byProducer)) return;
+ byProducer.Remove(producer);
+ if (byProducer.Count == 0) _asyncGeneration.Remove(field);
+ }
+
+ ///
+ /// Withdraws a producer's contribution to a field *and* retires any async pass it has
+ /// open, in one step.
+ ///
+ /// Withdrawing the messages alone leaves the producer's generation entry behind.
+ /// Every mounted rule gets a fresh rule#N identity, so a long-lived context
+ /// that sees rules mount and unmount would accumulate stale entries without bound —
+ /// and an already-running pass could still install a verdict for a producer that no
+ /// longer exists.
+ ///
+ ///
+ internal void RetireProducer(string field, string producer)
+ {
+ bool changed;
+ lock (_lock)
+ {
+ changed = RetireProducerLocked(field, producer);
+ if (changed) BumpVersionLocked();
+ }
+ if (changed) RaiseChanged(messagesOnly: true);
+ }
+
+ private bool RetireProducerLocked(string field, string producer)
+ {
+ if (_asyncGeneration.TryGetValue(field, out var byProducer))
+ {
+ byProducer.Remove(producer);
+ if (byProducer.Count == 0) _asyncGeneration.Remove(field);
+ }
+
+ return ApplyOwnedLocked(field, producer, []);
+ }
+
+ ///
+ /// Withdraws a producer's contribution only while is
+ /// still the token of the last write to that slot.
+ ///
+ /// A control that leaves the tree has to take its verdict with it, but it is not
+ /// necessarily the last thing to have written the slot it wrote. Replacing a
+ /// FormField's content installs the incoming control's verdict before the
+ /// outgoing one is unmounted, and both write the same field under the same producer;
+ /// an unconditional retraction on the way out would erase the verdict that had just
+ /// replaced it, leaving the form spuriously valid. Comparing stamps makes "withdraw
+ /// what I installed" exact without comparing message instances, which
+ /// deliberately keeps stable across an unchanged pass.
+ ///
+ ///
+ /// The comparison and the withdrawal share one lock acquisition. Split across two, a
+ /// writer that installed a newer verdict in the window between them would have it
+ /// deleted by the very check meant to protect it (issue #1262 review).
+ ///
+ ///
+ internal void RetireProducer(string field, string producer, long expectedStamp)
+ {
+ bool changed;
+ lock (_lock)
+ {
+ var current = _producerStamp.TryGetValue(field, out var stamps)
+ && stamps.TryGetValue(producer, out var stamp) ? stamp : 0;
+ if (current != expectedStamp) return;
+
+ changed = RetireProducerLocked(field, producer);
+ if (changed) BumpVersionLocked();
+ }
+ if (changed) RaiseChanged(messagesOnly: true);
+ }
+
+ ///
+ /// Withdraws every async contribution to a field and retires its in-flight passes.
+ ///
+ /// Producer-aware rather than just : an async
+ /// ValidationRule installs under its own key — rule#N when mounted, or
+ /// the message-derived fallback when evaluated by hand — so clearing only the field's
+ /// own async slot left a rule's verdict about the previous value on screen, keeping
+ /// false for a value it never examined. The generation map's
+ /// keys are exactly the producers that have run asynchronously on this field.
+ ///
+ ///
+ private bool RetractAsyncProducersLocked(string field)
+ {
+ var changed = false;
+
+ if (_asyncGeneration.TryGetValue(field, out var byProducer))
+ {
+ foreach (var producer in byProducer.Keys)
+ {
+ if (ApplyOwnedLocked(field, producer, [])) changed = true;
+ }
+ _asyncGeneration.Remove(field);
}
+
+ // A verdict can outlive its generation entry — a previous retraction drops the
+ // entry but a later pass may have installed under the plain async slot.
+ if (ApplyOwnedLocked(field, AsyncProducer, [])) changed = true;
+
+ return changed;
}
///
@@ -388,16 +1466,30 @@ public bool IsDirty()
///
public object? Reset(string field)
{
+ object? initial;
+ bool changed;
lock (_lock)
{
- _touchedFields.Remove(field);
- _messages.Remove(field);
- _externalMessages.Remove(field);
- _initialValues.TryGetValue(field, out var initial);
- _currentValues[field] = initial;
- _version++;
- return initial;
+ changed = _touchedFields.Remove(field);
+ if (_messages.Remove(field)) changed = true;
+ if (_externalMessages.Remove(field)) changed = true;
+ DropFieldProducerStateLocked(field);
+
+ _initialValues.TryGetValue(field, out initial);
+ // Only rewind a value the context is actually tracking. Creating an entry
+ // for a field it has never seen is not observable (IsDirty needs both an
+ // initial and a current value) but would make Reset("unknown") look like a
+ // change and notify.
+ if (_currentValues.TryGetValue(field, out var current) && !Equals(current, initial))
+ {
+ _currentValues[field] = initial;
+ changed = true;
+ }
+
+ if (changed) BumpVersionLocked();
}
+ if (changed) RaiseChanged();
+ return initial;
}
///
@@ -406,20 +1498,38 @@ public bool IsDirty()
///
public IReadOnlyDictionary ResetAll()
{
+ Dictionary result;
+ bool changed;
lock (_lock)
{
+ changed = _touchedFields.Count > 0 || _messages.Count > 0
+ || _externalMessages.Count > 0 || _submitAttempted;
_touchedFields.Clear();
+ // A reset returns the form to its pre-submit state, so the next
+ // AfterFirstSubmit reveal waits for a fresh submit attempt.
+ _submitAttempted = false;
_messages.Clear();
_externalMessages.Clear();
- var result = new Dictionary();
+ _messageOwners.Clear();
+ _producerStamp.Clear();
+ _asyncGeneration.Clear();
+ InvalidateRuleSetsLocked(null);
+
+ result = new Dictionary();
foreach (var (field, initial) in _initialValues)
{
- _currentValues[field] = initial;
+ if (_currentValues.TryGetValue(field, out var current) && !Equals(current, initial))
+ {
+ _currentValues[field] = initial;
+ changed = true;
+ }
result[field] = initial;
}
- _version++;
- return result;
+
+ if (changed) BumpVersionLocked();
}
+ if (changed) RaiseChanged();
+ return result;
}
// ════════════════════════════════════════════════════════════════
diff --git a/src/Reactor/Controls/Validation/ValidationReconciler.cs b/src/Reactor/Controls/Validation/ValidationReconciler.cs
index 5303d7aa6..96744b0f5 100644
--- a/src/Reactor/Controls/Validation/ValidationReconciler.cs
+++ b/src/Reactor/Controls/Validation/ValidationReconciler.cs
@@ -1,3 +1,4 @@
+using System.Linq;
using Microsoft.UI.Reactor.Core;
using Microsoft.UI.Reactor.Controls.Validation;
@@ -13,6 +14,11 @@ public static class ValidationReconciler
///
/// Runs all synchronous validators for a field and pushes results to the context.
/// Call this from a component's Render() method after state is finalized.
+ ///
+ /// Results are applied as a single diffed replacement, so calling this repeatedly
+ /// with an unchanged value is a no-op: no version bump, no change notification, no
+ /// re-render. That is what lets .Validate() run on every render pass.
+ ///
///
public static void ValidateField(
ValidationContext ctx,
@@ -20,16 +26,7 @@ public static void ValidateField(
object? value,
params IValidator[] validators)
{
- ctx.RegisterField(fieldName);
- ctx.ClearInternal(fieldName);
- ctx.NotifyValueChanged(fieldName, value);
-
- foreach (var validator in validators)
- {
- var result = validator.Validate(value, fieldName);
- if (result is not null)
- ctx.Add(result);
- }
+ ctx.ApplyValidation(fieldName, value, Run(validators, value, fieldName));
}
///
@@ -40,16 +37,19 @@ public static void ValidateAttached(
ValidationAttached attached,
object? value)
{
- ctx.RegisterField(attached.FieldName);
- ctx.ClearInternal(attached.FieldName);
- ctx.NotifyValueChanged(attached.FieldName, value);
+ ctx.ApplyValidation(attached.FieldName, value, Run(attached.Validators, value, attached.FieldName));
+ }
- foreach (var validator in attached.Validators)
+ private static List Run(IValidator[] validators, object? value, string fieldName)
+ {
+ var messages = new List(validators.Length);
+ foreach (var validator in validators)
{
- var result = validator.Validate(value, attached.FieldName);
+ var result = validator.Validate(value, fieldName);
if (result is not null)
- ctx.Add(result);
+ messages.Add(result);
}
+ return messages;
}
///
@@ -62,24 +62,202 @@ public static async Task ValidateFieldAsync(
IAsyncValidator[] asyncValidators,
CancellationToken cancellationToken = default)
{
+ // Recording the value and opening the pass happen under one lock: as two calls,
+ // concurrent callers could interleave and leave the older value's pass holding
+ // the newest token (issue #1262 review). Recording also clears an external
+ // verdict about the old value and retires any async pass still out for it, so
+ // the helper is self-contained.
+ var generation = ctx.BeginAsyncValidation(fieldName, value);
+
+ var messages = new List(asyncValidators.Length);
foreach (var validator in asyncValidators)
{
var result = await validator.ValidateAsync(value, fieldName, cancellationToken);
if (result is not null)
- ctx.Add(result);
+ messages.Add(result);
+ }
+
+ // One atomic install once every validator has resolved: no partial verdict, one
+ // notification, and re-running replaces this producer's previous result instead
+ // of appending a duplicate. The generation token drops a result that a newer
+ // pass has already superseded.
+ ctx.ApplyAsyncValidation(fieldName, generation, messages);
+ }
+
+ ///
+ /// Evaluates ValidationRuleElements and pushes their results to the context.
+ ///
+ /// Each rule owns its own slot, identified by its field and the call site of its
+ /// predicate, so re-running the same rules replaces each verdict rather than
+ /// accumulating. Rules evaluated here are independent of each other and of any other
+ /// caller using the same context.
+ ///
+ ///
+ /// Callers are independent of one another as long as their predicates differ, which
+ /// they do for the ordinary lambda case — each call site compiles to its own method.
+ /// Two callers that pass the same *named method* as the predicate, for the same field
+ /// and position, share a slot; give those a setId instead.
+ ///
+ ///
+ /// This overload does not withdraw a rule that disappears from the list — nothing
+ /// re-evaluates a rule that no longer exists. Use the
+ ///
+ /// overload when a set shrinks or empties, or mount the rules in the element tree,
+ /// where unmount retracts them.
+ ///
+ ///
+ public static void EvaluateRules(
+ ValidationContext ctx,
+ params ValidationRuleElement[] rules)
+ {
+ // Reject the whole call before installing any of it. Evaluating rule by rule
+ // meant a sync-then-async batch threw only once it reached the async rule, with
+ // the earlier verdicts already in the context — a partial batch from a call that
+ // reported failure, which is exactly the state a caller cannot reason about
+ // (issue #1262 review). Each rule's own Evaluate still rejects an async
+ // predicate; this makes the batch atomic rather than the individual rule safe.
+ foreach (var asyncRule in rules.Where(rule => rule.AsyncPredicate is not null))
+ {
+ // Throws, naming the offending field.
+ asyncRule.ComputeSync();
}
+
+ for (var i = 0; i < rules.Length; i++)
+ rules[i].Evaluate(ctx, ValidationRuleDsl.DirectProducerKey(rules[i], i));
}
///
- /// Evaluates all ValidationRuleElements in a list and pushes results to the context.
+ /// Evaluates a named rule set: the call owns that set for the context, so a producer
+ /// from the previous call under the same that is absent this
+ /// time has its contribution withdrawn.
+ ///
+ /// The identity is explicit because ownership is destructive. Two unrelated callers
+ /// sharing one context must not silently retract each other's rules, which is what an
+ /// implicit per-context set would do.
+ ///
+ ///
+ /// Each rule is keyed by its position within the set, so two rules stay independent
+ /// even if they carry the same message, and re-running the set replaces each verdict
+ /// rather than accumulating.
+ ///
///
public static void EvaluateRules(
ValidationContext ctx,
+ string setId,
+ params ValidationRuleElement[] rules)
+ {
+ var generation = ctx.BeginRuleSet(setId);
+
+ // Compute every verdict before installing any: a mid-set notification could
+ // otherwise re-enter and take ownership of the set out from under this call.
+ // The set may also have been asynchronous last time, and a leftover generation
+ // would classify these synchronous verdicts as async — but retiring those tokens
+ // happens inside the commit, past the ownership check, so a call that has been
+ // overtaken cannot destroy state the newer one owns.
+ var verdicts = new List<(string Field, string Producer, List Messages)>(rules.Length);
+ var clearAsync = new List<(string Field, string Producer)>(rules.Length);
+ for (var i = 0; i < rules.Length; i++)
+ {
+ var producer = RuleProducer(setId, i);
+ clearAsync.Add((rules[i].Field, producer));
+ verdicts.Add((rules[i].Field, producer, rules[i].ComputeSync()));
+ }
+
+ ctx.CommitRuleSet(setId, generation, verdicts, asyncTokens: null, clearAsync);
+ }
+
+ ///
+ /// The asynchronous counterpart to
+ /// , for rules
+ /// built by ValidationRuleAsync. Synchronous rules are evaluated normally.
+ ///
+ /// Rules run in order rather than concurrently, so the resulting message order for a
+ /// field is the order the rules were given in — GetMessages exposes that order
+ /// and callers read the first message.
+ ///
+ ///
+ /// Each rule is ordered against itself by its own async generation, so two overlapping
+ /// evaluations of the same rule cannot install out of order. Rules remain independent
+ /// of each other. No lock is held across a caller's predicate.
+ ///
+ ///
+ public static Task EvaluateRulesAsync(
+ ValidationContext ctx,
+ params ValidationRuleElement[] rules)
+ => EvaluateRulesAsync(ctx, CancellationToken.None, rules);
+
+ ///
+ /// As ,
+ /// with cancellation. A rule predicate takes no token of its own, so this is the only
+ /// way to stop waiting on one that never completes — and with it, to release the
+ /// evaluation's hold on the context.
+ ///
+ public static async Task EvaluateRulesAsync(
+ ValidationContext ctx,
+ CancellationToken cancellationToken,
+ params ValidationRuleElement[] rules)
+ {
+ for (var i = 0; i < rules.Length; i++)
+ await rules[i].EvaluateAsync(ctx, ValidationRuleDsl.DirectProducerKey(rules[i], i), cancellationToken);
+ }
+
+ ///
+ /// The asynchronous counterpart to
+ /// .
+ ///
+ /// Every verdict is computed before any is installed, and nothing is installed if the
+ /// set has been re-evaluated in the meantime, or if a field's value changed while a
+ /// predicate was running — each rule opens a per-producer async generation before its
+ /// predicate runs, and those are verified at the moment of application.
+ ///
+ ///
+ public static Task EvaluateRulesAsync(
+ ValidationContext ctx,
+ string setId,
+ params ValidationRuleElement[] rules)
+ => EvaluateRulesAsync(ctx, setId, CancellationToken.None, rules);
+
+ ///
+ /// As ,
+ /// with cancellation. A cancelled call installs nothing: the verdicts are computed
+ /// before any of them is committed, so there is no partial set to unwind.
+ ///
+ public static async Task EvaluateRulesAsync(
+ ValidationContext ctx,
+ string setId,
+ CancellationToken cancellationToken,
params ValidationRuleElement[] rules)
{
- foreach (var rule in rules)
+ var generation = ctx.BeginRuleSet(setId);
+
+ // Open an async generation for each rule that actually runs asynchronously. A
+ // value change retires these, which is how a verdict computed from a superseded
+ // value is recognised as stale when the set tries to commit. A synchronous rule
+ // in the set is computed at call time and cannot go stale that way, so it gets
+ // no token — and any token it carries from a previous async incarnation is
+ // retired inside the commit, past the ownership check, so an overtaken call
+ // cannot destroy state the newer one owns.
+ var tokens = new List<(string Field, string Producer, int Token)>(rules.Length);
+ var clearAsync = new List<(string Field, string Producer)>(rules.Length);
+ var producers = new string[rules.Length];
+ for (var i = 0; i < rules.Length; i++)
{
- rule.Evaluate(ctx);
+ var producer = producers[i] = RuleProducer(setId, i);
+ ctx.RegisterField(rules[i].Field);
+
+ if (rules[i].AsyncPredicate is null)
+ clearAsync.Add((rules[i].Field, producer));
+ else
+ tokens.Add((rules[i].Field, producer, ctx.BeginAsyncProducer(rules[i].Field, producer)));
}
+
+ var verdicts = new List<(string Field, string Producer, List Messages)>(rules.Length);
+ for (var i = 0; i < rules.Length; i++)
+ verdicts.Add((rules[i].Field, producers[i], await rules[i].ComputeAsync(cancellationToken)));
+
+ ctx.CommitRuleSet(setId, generation, verdicts, tokens, clearAsync);
}
-}
+
+ private static string RuleProducer(string setId, int index) =>
+ "ruleset:" + setId + "[" + index.ToString(global::System.Globalization.CultureInfo.InvariantCulture) + "]";
+}
\ No newline at end of file
diff --git a/src/Reactor/Controls/Validation/ValidationRenderScope.cs b/src/Reactor/Controls/Validation/ValidationRenderScope.cs
new file mode 100644
index 000000000..0f5e55082
--- /dev/null
+++ b/src/Reactor/Controls/Validation/ValidationRenderScope.cs
@@ -0,0 +1,359 @@
+using Microsoft.UI.Reactor.Core;
+
+namespace Microsoft.UI.Reactor.Controls.Validation;
+
+///
+/// The ambient, render-pass-scoped link between a component's
+/// and the .Validate(field, value, …) modifier.
+///
+/// Why this exists..Validate() is an extension method on
+/// : it is handed an element and a value, and has no path back to
+/// the component that is rendering. Before this scope existed it could only stash a
+/// record and hope something downstream ran it — which
+/// only FormField and the visualizers ever did, so validators attached to a plain
+/// control silently never ran (issue #1262).
+///
+/// Why a render-pass scope specifically. Validation results have to be
+/// observable by the same Render() that produced them. The documented pattern
+/// reads the context inline —
+/// When(ctx.IsTouched("email") && ctx.HasError("email"), …) — and C#
+/// evaluates arguments left to right, so a .Validate(…) argument runs before a
+/// later When(…) sibling in the same call. Running validators during reconcile
+/// instead (as FormField does) is always one pass too late for that read.
+///
+/// Lifetime is owned by the reconciler, never by the hook. The frame is
+/// opened immediately before Render() and closed immediately after, so the
+/// ambient cannot outlive a render or leak into an event handler, an effect, or — the
+/// case that actually bites — an unrelated unit test running later on the same thread.
+/// A headless test that calls the hook directly has no open
+/// frame, so .Validate() stays attach-only there.
+///
+internal static class ValidationRenderScope
+{
+ [ThreadStatic] private static ValidationContext? t_context;
+ [ThreadStatic] private static ValidationContext? t_pendingProvide;
+ [ThreadStatic] private static int t_depth;
+ [ThreadStatic] private static List? t_deferred;
+ [ThreadStatic] private static Dictionary? t_owned;
+ [ThreadStatic] private static HashSet<(ValidationContext Context, string Field)>? t_adopted;
+
+ ///
+ /// What an eager .Validate() write claimed: the context it actually reached
+ /// and the stamp its own write was issued.
+ ///
+ internal readonly record struct Ownership(ValidationContext Context, long Stamp);
+
+ private sealed class ByReference : IEqualityComparer
+ {
+ internal static readonly ByReference Instance = new();
+ public bool Equals(ValidationAttached? a, ValidationAttached? b) => ReferenceEquals(a, b);
+ public int GetHashCode(ValidationAttached obj)
+ => global::System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(obj);
+ }
+
+ ///
+ /// Records which context an attachment's eager verdict went to, and the stamp it was
+ /// issued, so the mounted control can inherit that exact claim.
+ ///
+ /// Resolving it again at mount time instead would be guesswork: the reconciler's
+ /// context at that point is not necessarily the one the render scope reached — an
+ /// explicit .Provide(...) inside a component that owns a local context
+ /// separates them — and re-reading the field's current stamp would hand one control
+ /// a sibling's claim, letting either retract the other's verdict.
+ ///
+ ///
+ /// Keyed by reference. is a record, so two links of
+ /// a chain that happen to carry equal values are the same key by value and different
+ /// keys by identity — and identity is what "the write I made" means here.
+ ///
+ ///
+ internal static void RecordOwnership(ValidationAttached attached, ValidationContext context, long stamp)
+ {
+ if (t_depth == 0) return;
+ (t_owned ??= new Dictionary(ByReference.Instance))[attached] =
+ new Ownership(context, stamp);
+ }
+
+ ///
+ /// Claims an attachment's recorded ownership, removing it. Returns false when the
+ /// attachment never wrote anything in this pass — an element assembled outside a
+ /// render, or one whose validators were only ever attached.
+ ///
+ internal static bool TryTakeOwnership(ValidationAttached attached, out Ownership ownership)
+ {
+ var owned = t_owned;
+ if (owned is not null && owned.Remove(attached, out ownership)) return true;
+ ownership = default;
+ return false;
+ }
+
+ ///
+ /// Whether an attachment already has a claim from this render pass against
+ /// — a verdict .Validate() installed while the
+ /// tree was being built, which a control is about to take ownership of. Does not
+ /// consume the claim.
+ ///
+ /// The context has to match. An explicit
+ /// .Provide(ValidationContexts.Current, other) written inside a component that
+ /// also owns a hook-local context separates the two: the eager write went to the
+ /// hook's context, while FormField resolves the provided one. A claim against
+ /// the wrong context says nothing about whether this context has been validated.
+ ///
+ ///
+ internal static bool HasOwnership(ValidationAttached attached, ValidationContext context)
+ => t_owned is not null
+ && t_owned.TryGetValue(attached, out var owned)
+ && ReferenceEquals(owned.Context, context);
+
+ ///
+ /// Records that a mounted control has taken over a field's synchronous slot, so an
+ /// unconsumed claim on that same slot is not withdrawn at the end of the pass.
+ ///
+ /// Every value-carrying .Validate() on a field shares one producer key, so a
+ /// render that returns one validated element and also builds and drops another
+ /// naming the same field leaves the dropped element holding the newer stamp. Retiring
+ /// it would clear the slot the mounted control is relying on and report an invalid
+ /// field as valid — the exact failure this ownership model exists to prevent
+ /// (issue #1262 review). 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 prevented here.
+ ///
+ ///
+ internal static void MarkAdopted(ValidationContext context, string field)
+ {
+ if (t_depth == 0) return;
+ (t_adopted ??= new HashSet<(ValidationContext, string)>()).Add((context, field));
+ }
+
+ ///
+ /// The context .Validate() should push results into, or null when no
+ /// render is in flight on this thread or no context is reachable.
+ ///
+ internal static ValidationContext? Current => t_context;
+
+ ///
+ /// True while a component render is in flight on this thread.
+ ///
+ /// uses this to defer its change notification: the
+ /// component doing the rendering observes the new value later in the same pass, and
+ /// notifying inline would re-enter requestRerender from inside
+ /// Render(), which the reconciler treats as a render loop. The notification
+ /// is delivered once the outermost render frame closes.
+ ///
+ ///
+ internal static bool InRender => t_depth > 0;
+
+ ///
+ /// Records a context whose change was raised mid-render, to be announced once the
+ /// outermost frame closes.
+ ///
+ internal static void DeferNotification(ValidationContext context)
+ {
+ var pending = t_deferred ??= new List(2);
+ foreach (var existing in pending)
+ {
+ if (ReferenceEquals(existing, context)) return;
+ }
+ pending.Add(context);
+ }
+
+ private static void FlushDeferredNotifications()
+ {
+ var pending = t_deferred;
+ if (pending is null || pending.Count == 0) return;
+
+ t_deferred = null;
+ foreach (var context in pending)
+ context.NotifyDeferred();
+ }
+
+ ///
+ /// Opens a frame for one component render. is the
+ /// already visible through the reconciler's context
+ /// scope, so a child component whose ancestor provided a context gets eager
+ /// validation without having to call UseValidationContext() itself.
+ ///
+ internal static Frame Begin(ValidationContext? inherited) => BeginCore(inherited, isReconcile: false);
+
+ private static Frame BeginCore(ValidationContext? inherited, bool isReconcile)
+ {
+ // A render frame opening at depth 0 starts a new pass, so the previous pass's
+ // claims are settled here rather than when the last frame closed: the mount that
+ // consumes a claim does not always run inside the frame that made it.
+ //
+ // A reconcile frame must NOT settle them. It is the *consumer* — a host's root
+ // render closes its own frame before Reconcile opens this one, so clearing here
+ // would discard every root-level `.Validate()` claim before the controls that
+ // inherit them exist (issue #1262 review).
+ Dictionary? abandoned = null;
+ HashSet<(ValidationContext Context, string Field)>? abandonedAdopted = null;
+ if (t_depth == 0 && !isReconcile)
+ {
+ abandoned = t_owned;
+ abandonedAdopted = t_adopted;
+ t_owned = null;
+ t_adopted = null;
+ }
+
+ var frame = new Frame(t_context, t_pendingProvide, isReconcile);
+ t_context = inherited;
+ t_pendingProvide = null;
+ t_depth++;
+
+ // Anything still held when a new pass opens belongs to a pass that never
+ // reached reconciliation — a root render that threw, or a host that returned
+ // without one. Its verdict is in the context with nothing to own it, so the
+ // claims are withdrawn rather than dropped. Done after the frame is open so the
+ // retractions defer into this pass instead of announcing inline at depth 0.
+ if (abandoned is not null) RetireClaims(abandoned, abandonedAdopted);
+
+ return frame;
+ }
+
+ ///
+ /// Opens a deferral-only frame around a whole reconcile pass.
+ ///
+ /// Mounting, updating, and unmounting ValidationRule and FormField
+ /// happen *between* component renders, so they used to fall outside every frame and
+ /// announce their changes synchronously. A rule leaving the tree retracts its
+ /// contribution during unmount; if the owning UseValidationContext() lives in
+ /// a child component, that notification re-entered the reconciler's inline re-render
+ /// path while the subtree was still being torn down. Holding a frame for the
+ /// duration of the pass defers every such notification to the end of it.
+ ///
+ ///
+ /// The frame carries no context: no component is rendering, so .Validate()
+ /// reached from reconcile code stays attach-only exactly as before.
+ ///
+ ///
+ internal static Frame BeginReconcile() => BeginCore(null, isReconcile: true);
+
+ ///
+ /// Withdraws the claims of a render that will never reach reconciliation.
+ ///
+ /// A root render that throws installs an error fallback and returns without calling
+ /// Reconcile, so no reconcile frame ever opens to consume or retire what it
+ /// claimed. Leaving that to the *next* render is not good enough: if none follows —
+ /// the fallback is terminal for that host — the verdict sits in the context owned by
+ /// nothing and the thread-static map keeps the abandoned attachment alive
+ /// (issue #1262 review).
+ ///
+ ///
+ /// Safe on any abort path, including one taken after reconciliation started: a claim
+ /// a control already adopted is no longer in the map, and every withdrawal is stamped
+ /// so it cannot disturb a slot another writer owns.
+ ///
+ ///
+ internal static void AbandonPendingClaims() => RetireUnconsumedClaims();
+
+ ///
+ /// Withdraws every claim the pass made that no control took over.
+ ///
+ /// A claim is created by the eager write and taken by the control that gets mounted
+ /// or updated with it. One left behind belongs to an element that was validated and
+ /// then never mounted — built inside a render, then dropped — whose verdict would
+ /// otherwise sit in the context owned by nothing. The withdrawal is stamped, so it
+ /// cannot disturb a slot something else has written since.
+ ///
+ ///
+ /// A slot a mounted control has already adopted is left alone: the two share one
+ /// producer key, so withdrawing the dropped element's claim would take the mounted
+ /// control's verdict with it. See .
+ ///
+ ///
+ private static void RetireUnconsumedClaims()
+ {
+ var owned = t_owned;
+ var adopted = t_adopted;
+ t_owned = null;
+ t_adopted = null;
+ RetireClaims(owned, adopted);
+ }
+
+ private static void RetireClaims(
+ Dictionary? claims,
+ HashSet<(ValidationContext Context, string Field)>? adopted)
+ {
+ if (claims is null || claims.Count == 0) return;
+
+ foreach (var (attached, claim) in claims)
+ {
+ if (adopted is not null && adopted.Contains((claim.Context, attached.FieldName)))
+ continue;
+
+ claim.Context.RetireProducer(
+ attached.FieldName, ValidationContext.SyncProducer, claim.Stamp);
+ }
+ }
+
+ ///
+ /// Called by UseValidationContext() with the context it resolved.
+ /// is true when the hook fell back to a
+ /// component-local context, meaning nothing up the tree has provided one and the
+ /// reconciler should publish it to the rendered subtree on the component's behalf.
+ ///
+ internal static void Publish(ValidationContext context, bool autoProvide)
+ {
+ if (t_depth == 0) return;
+ t_context = context;
+ if (autoProvide) t_pendingProvide = context;
+ }
+
+ ///
+ /// Wraps the element a component just returned so descendants — FormField,
+ /// the visualizers, ValidationRule, and nested components — can read the
+ /// context through the normal provider mechanism.
+ ///
+ /// An explicit .Provide(ValidationContexts.Current, …) written by the caller
+ /// always wins; this only fills an empty slot.
+ ///
+ ///
+ internal static Element ApplyProvide(Element rendered)
+ {
+ var pending = t_pendingProvide;
+ if (pending is null) return rendered;
+
+ var existing = rendered.ContextValues;
+ if (existing is not null && existing.ContainsKey(ValidationContexts.Current))
+ return rendered;
+
+ return rendered.Provide(ValidationContexts.Current, pending);
+ }
+
+ ///
+ /// Restores the enclosing frame. A struct so the per-render cost is a few stack
+ /// slots rather than an allocation on a path that runs for every component.
+ ///
+ internal readonly struct Frame : IDisposable
+ {
+ private readonly ValidationContext? _previousContext;
+ private readonly ValidationContext? _previousPendingProvide;
+ private readonly bool _isReconcile;
+
+ internal Frame(
+ ValidationContext? previousContext,
+ ValidationContext? previousPendingProvide,
+ bool isReconcile)
+ {
+ _previousContext = previousContext;
+ _previousPendingProvide = previousPendingProvide;
+ _isReconcile = isReconcile;
+ }
+
+ public void Dispose()
+ {
+ t_context = _previousContext;
+ t_pendingProvide = _previousPendingProvide;
+
+ // Before the depth drops, so the retractions defer into this pass's batch
+ // rather than announcing one at a time on the way out.
+ if (_isReconcile && t_depth == 1) RetireUnconsumedClaims();
+
+ if (t_depth > 0) t_depth--;
+ if (t_depth == 0)
+ {
+ FlushDeferredNotifications();
+ }
+ }
+ }
+}
diff --git a/src/Reactor/Controls/Validation/ValidationRule.cs b/src/Reactor/Controls/Validation/ValidationRule.cs
index 9b3b01199..80aba125a 100644
--- a/src/Reactor/Controls/Validation/ValidationRule.cs
+++ b/src/Reactor/Controls/Validation/ValidationRule.cs
@@ -62,37 +62,160 @@ public static ValidationRuleElement ValidationRuleAsync(
///
/// Evaluates the validation rule against a ValidationContext.
/// Adds or clears messages based on the predicate result.
+ ///
+ /// The result is applied as one diffed replacement rather than clear-then-add.
+ /// Mount/UpdateValidationRule calls this during reconcile — after the
+ /// component's render scope has closed — so with clear-then-add a failing rule
+ /// raised on every pass, each notification
+ /// drove another render, and the reconciler tripped its re-render re-entrancy limit.
+ /// Re-evaluating to the same verdict is now silent.
+ ///
///
public static void Evaluate(this ValidationRuleElement rule, ValidationContext ctx)
{
- // Clear any previous message from this rule (identified by field + message combo)
- ctx.ClearInternal(rule.Field);
+ rule.Evaluate(ctx, FallbackProducerKey(rule));
+ }
- if (!rule.Predicate())
- {
- ctx.Add(new ValidationMessage(rule.Field, rule.Message, rule.Severity));
- }
+ ///
+ /// Evaluates the rule as a named producer on its field. The reconciler passes an
+ /// identity tied to the rule's mounted placeholder, which survives re-renders and
+ /// distinguishes two rules that happen to share a message.
+ ///
+ /// Throws for a rule built by ValidationRuleAsync. Its synchronous predicate
+ /// is a constant true placeholder, so evaluating it here would record a
+ /// passing verdict without ever running the real check — an invalid field reported
+ /// as valid, silently (issue #1262 review).
+ ///
+ ///
+ /// The producer's async generation is retired first. Without that, an
+ /// EvaluateAsync still in flight for this same producer would still match its
+ /// token when it resolved, and would overwrite the newer synchronous verdict
+ /// (issue #1262 review).
+ ///
+ ///
+ internal static void Evaluate(this ValidationRuleElement rule, ValidationContext ctx, string producer)
+ {
+ // Compute before touching the context. ComputeSync throws for a rule that
+ // carries an async predicate, and mutating first left the previous verdict
+ // installed with its generation already gone — so nothing could retract it
+ // and the stale error survived forever (issue #1262 review).
+ var messages = rule.ComputeSync();
+
+ ctx.RegisterField(rule.Field);
+ ctx.ClearAsyncGeneration(rule.Field, producer);
+ ctx.ApplyOwned(rule.Field, producer, messages);
}
///
/// Evaluates the async validation rule against a ValidationContext.
+ ///
+ /// The previous verdict is kept while the check is in flight and replaced once,
+ /// at the end. Clearing first would raise
+ /// twice per evaluation for an already-failing rule, and would briefly report the
+ /// field as valid in between.
+ ///
+ ///
+ /// Overlapping evaluations are ordered by a generation token taken before the
+ /// predicate is awaited. Without it, a slow failing check started first could
+ /// resolve after a fast passing one and reinstate an error the newer run had
+ /// already cleared.
+ ///
///
- public static async Task EvaluateAsync(this ValidationRuleElement rule, ValidationContext ctx,
+ public static Task EvaluateAsync(this ValidationRuleElement rule, ValidationContext ctx,
CancellationToken cancellationToken = default)
+ => rule.EvaluateAsync(ctx, FallbackProducerKey(rule), cancellationToken);
+
+ internal static async Task EvaluateAsync(this ValidationRuleElement rule, ValidationContext ctx,
+ string producer, CancellationToken cancellationToken = default)
{
if (rule.AsyncPredicate is null)
{
- rule.Evaluate(ctx);
+ rule.Evaluate(ctx, producer);
return;
}
- ctx.ClearInternal(rule.Field);
- var result = await rule.AsyncPredicate();
- cancellationToken.ThrowIfCancellationRequested();
+ ctx.RegisterField(rule.Field);
+ var generation = ctx.BeginAsyncProducer(rule.Field, producer);
+
+ // WaitAsync, not a plain await: the predicate takes no token, so awaiting it
+ // directly means a hung check keeps this state machine — and through it the rule
+ // binding and the ValidationContext — alive forever, with a fresh one added on
+ // every re-render. Cancelling now releases us immediately (issue #1262 review).
+ var result = await rule.AsyncPredicate().WaitAsync(cancellationToken);
+
+ ctx.ApplyAsyncOwned(rule.Field, producer, generation, BuildMessages(rule, result));
+ }
- if (!result)
+ internal static List ComputeSync(this ValidationRuleElement rule)
+ {
+ if (rule.AsyncPredicate is not null)
{
- ctx.Add(new ValidationMessage(rule.Field, rule.Message, rule.Severity));
+ throw new InvalidOperationException(
+ $"The validation rule for field '{rule.Field}' has an async predicate and cannot be " +
+ "evaluated synchronously. Use EvaluateAsync or ValidationReconciler.EvaluateRulesAsync, " +
+ "or mount the rule in the element tree, which dispatches it asynchronously.");
}
+
+ return BuildMessages(rule, rule.Predicate());
}
+
+ internal static async Task> ComputeAsync(
+ this ValidationRuleElement rule, CancellationToken cancellationToken = default)
+ {
+ if (rule.AsyncPredicate is null) return BuildMessages(rule, rule.Predicate());
+
+ // WaitAsync, not a plain await: the predicate takes no token, so awaiting it
+ // directly means a hung check keeps this state machine — and through it the rule
+ // binding and the ValidationContext — alive forever, with a fresh one added on
+ // every re-render. Cancelling now releases us immediately (issue #1262 review).
+ var result = await rule.AsyncPredicate().WaitAsync(cancellationToken);
+ return BuildMessages(rule, result);
+ }
+
+ ///
+ /// The identity a single directly-evaluated rule gets, equivalent to position 0 of a
+ /// one-rule call. See for the derivation and its
+ /// limits.
+ ///
+ internal static string FallbackProducerKey(ValidationRuleElement rule) => DirectProducerKey(rule, 0);
+
+ ///
+ /// Identity for a rule evaluated outside the reconciler, where there is no mounted
+ /// instance to key on: the field, the predicate's method — for a lambda, the
+ /// compiler-generated method for that call site — and the rule's position in the
+ /// call.
+ ///
+ /// Keying on the message instead (as this once did) orphaned the previous verdict
+ /// whenever the text moved, which an interpolated message such as
+ /// $"Must be after {start}" does on every change: errors accumulated and a
+ /// now-passing rule could not retract the one it replaced.
+ ///
+ ///
+ /// The position disambiguates rules that share a predicate — two
+ /// ValidationRule(IsRangeValid, …) on one field would otherwise collapse into
+ /// one slot and retract each other.
+ ///
+ ///
+ /// Limit. The predicate's method is the only caller-derived component
+ /// available here: C# cannot supply [CallerFilePath] after a params
+ /// array, and walking the stack is neither cheap nor trimming-safe. For a lambda
+ /// that is enough — each call site compiles to its own method — but two *different*
+ /// callers that pass the same **named method** as the predicate, for the same field
+ /// and position, share a slot and will retract each other. Give those callers a
+ /// setId (EvaluateRules(ctx, "range-rules", …)), which is scoped per
+ /// set, or mount the rules in the element tree, where each gets a real per-instance
+ /// identity.
+ ///
+ ///
+ internal static string DirectProducerKey(ValidationRuleElement rule, int position)
+ {
+ // An async rule's synchronous Predicate is the shared `() => true` created inside
+ // ValidationRuleAsync, identical for every such rule — so key off the predicate
+ // that actually belongs to this call site.
+ var method = rule.AsyncPredicate?.Method ?? rule.Predicate.Method;
+ return $"rule:{rule.Field}:{method.DeclaringType?.FullName}.{method.Name}#{position.ToString(global::System.Globalization.CultureInfo.InvariantCulture)}";
+ }
+
+ private static List BuildMessages(ValidationRuleElement rule, bool passed) =>
+ passed ? [] : [new ValidationMessage(rule.Field, rule.Message, rule.Severity)];
}
diff --git a/src/Reactor/Core/Reconciler.Mount.cs b/src/Reactor/Core/Reconciler.Mount.cs
index 84fc82e8c..37cadb96e 100644
--- a/src/Reactor/Core/Reconciler.Mount.cs
+++ b/src/Reactor/Core/Reconciler.Mount.cs
@@ -1,4 +1,5 @@
using Microsoft.UI.Reactor.Animation;
+using Microsoft.UI.Reactor.Controls.Validation;
using Microsoft.UI.Reactor.Core.Internal;
using Microsoft.UI.Reactor.Core.V1Protocol;
using Microsoft.UI.Reactor.Hooks;
@@ -151,6 +152,14 @@ public sealed partial class Reconciler
ApplyModifiers(fe, modifiers, requestRerender);
if (control is FrameworkElement dragFe)
ApplyDragAttached(dragFe, element.GetAttached());
+ // Issue #1262 — bind a `.Validate(field, value, …)` verdict to the control's
+ // lifetime so it is withdrawn when the control leaves the tree. Gated on the
+ // validation attachment specifically, not on `Attached is not null`: every
+ // Grid/Canvas/Flex-positioned element carries attached metadata, and routing
+ // those through here cost an attached-state DP read per mount for nothing.
+ if (element.GetAttached() is { } mountValidation
+ && control is FrameworkElement valFe)
+ V1Protocol.CompositeLifecycle.TrackElementValidation(valFe, mountValidation);
// Re-apply the TitleBar's caption-derived height after modifiers so a
// .Tall() without an explicit .Height(...) still sizes the control.
@@ -800,7 +809,10 @@ private UIElement MountComponent(ComponentElement compElement, Action requestRer
try
{
component.Context.BeginRender(componentRerender, _contextScope);
- childElement = component.Render();
+ using (ValidationRenderScope.Begin(ReadContext(ValidationContexts.Current)))
+ {
+ childElement = ValidationRenderScope.ApplyProvide(component.Render());
+ }
component.Context.FlushEffects();
}
catch (Exception ex) when (_errorBoundaryDepth == 0 && ex is not OutOfMemoryException and not StackOverflowException)
@@ -836,7 +848,10 @@ private UIElement MountFuncComponent(FuncElement funcElement, Action requestRere
try
{
ctx.BeginRender(componentRerender, _contextScope);
- childElement = funcElement.RenderFunc(ctx);
+ using (ValidationRenderScope.Begin(ReadContext(ValidationContexts.Current)))
+ {
+ childElement = ValidationRenderScope.ApplyProvide(funcElement.RenderFunc(ctx));
+ }
ctx.FlushEffects();
}
catch (Exception ex) when (_errorBoundaryDepth == 0 && ex is not OutOfMemoryException and not StackOverflowException)
@@ -873,7 +888,10 @@ private UIElement MountMemoComponent(MemoElement memoElement, Action requestRere
try
{
ctx.BeginRender(componentRerender, _contextScope);
- childElement = memoElement.RenderFunc(ctx);
+ using (ValidationRenderScope.Begin(ReadContext(ValidationContexts.Current)))
+ {
+ childElement = ValidationRenderScope.ApplyProvide(memoElement.RenderFunc(ctx));
+ }
ctx.FlushEffects();
}
catch (Exception ex) when (_errorBoundaryDepth == 0 && ex is not OutOfMemoryException and not StackOverflowException)
diff --git a/src/Reactor/Core/Reconciler.Update.cs b/src/Reactor/Core/Reconciler.Update.cs
index 210c10694..affe356f6 100644
--- a/src/Reactor/Core/Reconciler.Update.cs
+++ b/src/Reactor/Core/Reconciler.Update.cs
@@ -1,4 +1,5 @@
using Microsoft.UI.Reactor.Animation;
+using Microsoft.UI.Reactor.Controls.Validation;
using Microsoft.UI.Reactor.Core.Internal;
using Microsoft.UI.Reactor.Hosting;
using Microsoft.Extensions.Logging;
@@ -127,6 +128,11 @@ public sealed partial class Reconciler
if ((HasGestureOrDragSlots(modifiers) || HasGestureOrDragSlots(oldModifiers))
&& control is FrameworkElement gestFeSE)
RefreshGestureDragStateOnSkip(gestFeSE, oldModifiers, modifiers);
+ // No validation refresh here, deliberately (issue #1262). A `.Validate()`
+ // verdict is republished only by a component that re-rendered, and that
+ // component's output is on the dirty ancestor path, which this arm declines
+ // — so an element whose claim moved never reaches the skip. Instrumenting
+ // this arm across the whole selftest corpus produced zero validated hits.
// No _ambientRequestedTheme restore here: the field is written only on the
// non-skip path inside the try below, never on this early-return arm (which
// reads the LOCAL effectiveTheme at line ~109), so it cannot leak.
@@ -257,6 +263,16 @@ public sealed partial class Reconciler
ApplyModifiers(fe, oldModifiers, modifiers ?? new ElementModifiers(), requestRerender);
if (target is FrameworkElement dragFe)
ApplyDragAttached(dragFe, newEl.GetAttached());
+ // Issue #1262 — re-bind (or withdraw) the attached verdict. The old element is
+ // consulted too: dropping `.Validate()` from a control that stays mounted has to
+ // retract just as surely as the control going away. Both sides are tested for the
+ // validation attachment specifically rather than for any attached metadata, so a
+ // Grid/Canvas/Flex-positioned element does not pay an attached-state DP read on
+ // every update for a verdict it never had.
+ var newValidation = newEl.GetAttached();
+ if ((newValidation is not null || oldEl.GetAttached() is not null)
+ && target is FrameworkElement valFe)
+ V1Protocol.CompositeLifecycle.TrackElementValidation(valFe, newValidation);
// Re-apply the TitleBar's caption-derived height after modifiers so
// removing an explicit .Height(...) from a still-tall TitleBar falls
diff --git a/src/Reactor/Core/Reconciler.cs b/src/Reactor/Core/Reconciler.cs
index d824c13f9..928af21dd 100644
--- a/src/Reactor/Core/Reconciler.cs
+++ b/src/Reactor/Core/Reconciler.cs
@@ -1,6 +1,7 @@
using System.Diagnostics.CodeAnalysis;
using System.Numerics;
using Microsoft.UI.Reactor.Animation;
+using Microsoft.UI.Reactor.Controls.Validation;
using Microsoft.UI.Reactor.Core.Diagnostics;
using Microsoft.UI.Reactor.Core.V1Protocol;
using Microsoft.UI.Reactor.Hosting;
@@ -412,6 +413,22 @@ internal sealed class ReactorState
// (pool return / ClearCurrentEventHandlers / DetachReactorState) so a
// stale arm can't suppress the first real event of a later lifecycle.
public Func