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? PendingEchoMatch; + // Issue #1262 — per-native-element validation lifecycle bindings: a mounted + // ValidationRule's producer identity, a FormField editor's blur binding, and + // the FormField root's pointer at its current editor's binding. Stored here + // rather than in a ConditionalWeakTable keyed by UIElement for the same reason + // as EchoSuppressCount: WinRT can project two managed RCWs over one native + // DependencyObject, and an update that saw a different wrapper would create a + // fresh producer while the old one could never be retired — leaving a stale + // validation message behind. Typed as object so Core does not depend on the + // V1 composite lifecycle's private binding types. + public object? ValidationRuleBinding; + public object? ValidationTouchBinding; + public object? ValidationRootBinding; + // The (context, field) a FormField's attached validators last produced a + // synchronous verdict for, so the contribution can be withdrawn when it moves + // field, moves context, or stops being produced at all (issue #1262). + public object? ValidationAttachedBinding; // Issue #986 — the AutomationId a deferred LabeledBy resolution is still // waiting to bind. ApplyAccessibilityModifiers can only resolve LabeledBy // once the element is in the visual tree, so an unresolved request parks a @@ -810,6 +827,54 @@ public static void DetachReactorState(FrameworkElement fe) state.EchoSuppressScopeDepth = 0; state.PendingEchoMatch = null; state.PendingLabeledBy = null; + // Issue #1262 — a retired control must not keep a ValidationContext (or a + // pending async rule) alive through a binding the normal FormField / + // ValidationRule unmount path never got to clear. Neutralize before dropping + // the slot: the binding object is captured by a once-per-lifetime LostFocus + // handler that outlives detach, and a pending async rule reads its context + // when it resolves. + (state.ValidationTouchBinding as IValidationBindingReset)?.Reset(); + (state.ValidationRootBinding as IValidationBindingReset)?.Reset(); + (state.ValidationRuleBinding as IValidationBindingReset)?.Reset(); + (state.ValidationAttachedBinding as IValidationBindingReset)?.Reset(); + state.ValidationTouchBinding = null; + state.ValidationRootBinding = null; + state.ValidationRuleBinding = null; + state.ValidationAttachedBinding = null; + } + + /// + /// Lets neutralize a validation binding without + /// Core depending on the V1 composite lifecycle's private binding types. + /// + internal interface IValidationBindingReset + { + void Reset(); + } + + /// + /// Issue #1262 — withdraws the validation verdict a control carried, on its way out + /// of the tree. + /// + /// Teardown runs down two parallel paths: for an + /// ordinary unmount and UnmountAndCollect for the pooling traversal that + /// child removal uses. Only the second one runs for a conditionally removed poolable + /// control, so this lives in one helper both call rather than in either of them. + /// + /// + /// The binding retracts only while it still owns the slot, so an incoming control + /// that already replaced the verdict is not disturbed by the outgoing one; and + /// Reset drops the context reference, so a pooled control does not keep a + /// ValidationContext alive for its next renter. + /// + /// + private static void WithdrawAttachedValidation(UIElement control) + { + if (control is FrameworkElement fe + && fe.GetValue(ReactorAttached.StateProperty) is ReactorState state) + { + (state.ValidationAttachedBinding as IValidationBindingReset)?.Reset(); + } } // ════════════════════════════════════════════════════════════════════ @@ -1478,6 +1543,9 @@ public void Unmount(UIElement control, Reconciler reconciler) 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 { @@ -1850,19 +1918,28 @@ private void ReconcileComponent(Element oldEl, Element newEl, UIElement control, } node.Component.Context.BeginRender(componentRerender, _contextScope); - newChildElement = node.Component.Render(); + using (ValidationRenderScope.Begin(ReadContext(ValidationContexts.Current))) + { + newChildElement = ValidationRenderScope.ApplyProvide(node.Component.Render()); + } FlushEffectsTraced(node.Component.Context, componentName); } else if (node.Context is not null && newEl is FuncElement func) { node.Context.BeginRender(componentRerender, _contextScope); - newChildElement = func.RenderFunc(node.Context); + using (ValidationRenderScope.Begin(ReadContext(ValidationContexts.Current))) + { + newChildElement = ValidationRenderScope.ApplyProvide(func.RenderFunc(node.Context)); + } FlushEffectsTraced(node.Context, componentName); } else if (node.Context is not null && newEl is MemoElement memo) { node.Context.BeginRender(componentRerender, _contextScope); - newChildElement = memo.RenderFunc(node.Context); + using (ValidationRenderScope.Begin(ReadContext(ValidationContexts.Current))) + { + newChildElement = ValidationRenderScope.ApplyProvide(memo.RenderFunc(node.Context)); + } FlushEffectsTraced(node.Context, componentName); } else @@ -2162,6 +2239,14 @@ private void UnmountRecursive(UIElement control) if (control is FrameworkElement refFe) CleanupReferenceStateForUnmount(refFe, GetElementTag(refFe)); + // Issue #1262 — a control that carried an attached verdict takes it with it. + // Nothing else does this: DetachReactorState runs only on a full detach, so a + // conditionally rendered validated control (or a whole FormField) leaving the + // tree otherwise left the context invalid over a field that no longer exists. + // The binding retracts only while it still owns the slot, so an incoming control + // that already replaced the verdict is not disturbed by the outgoing one. + WithdrawAttachedValidation(control); + // OnUnmountAction (.OnUnmount) — imperative teardown half of .OnMount. if (control is FrameworkElement umFe && _onUnmountActions.TryGetValue(umFe, out var onUnmount)) { @@ -2529,6 +2614,8 @@ private void UnmountAndCollect(UIElement control, List toPool) if (control is FrameworkElement refFe) CleanupReferenceStateForUnmount(refFe, GetElementTag(refFe)); + WithdrawAttachedValidation(control); + // OnUnmountAction (.OnUnmount) — imperative teardown half of .OnMount. if (control is FrameworkElement umFe && _onUnmountActions.TryGetValue(umFe, out var onUnmount)) { diff --git a/src/Reactor/Core/V1Protocol/CompositeLifecycle.cs b/src/Reactor/Core/V1Protocol/CompositeLifecycle.cs index 552bd4b23..121496f9a 100644 --- a/src/Reactor/Core/V1Protocol/CompositeLifecycle.cs +++ b/src/Reactor/Core/V1Protocol/CompositeLifecycle.cs @@ -63,10 +63,7 @@ internal static WinUI.StackPanel MountFormField(Reconciler reconciler, FormField // Auto-validate: if Content has attached validators with a Value, run them now var attached = ff.Content.GetAttached(); var valCtx = reconciler.ReadContext(ValidationContexts.Current); - if (valCtx is not null && attached is not null && attached.Validators.Length > 0) - { - ValidationReconciler.ValidateAttached(valCtx, attached, attached.Value); - } + ApplyAttachedValidation(panel, valCtx, attached); // [0] Label — always present, collapsed when empty var displayLabel = FormFieldHelpers.GetDisplayLabel(ff.Label, ff.Required); @@ -85,6 +82,7 @@ internal static WinUI.StackPanel MountFormField(Reconciler reconciler, FormField { ApplyFormFieldAutomation(contentControl, ff.Label); ApplyFormFieldErrorStyling(contentControl, valCtx, fieldName, ff.ShowWhen); + WireTouchedOnBlur(panel, contentControl, valCtx, fieldName); panel.Children.Add(contentControl); } else @@ -102,6 +100,199 @@ internal static WinUI.StackPanel MountFormField(Reconciler reconciler, FormField return panel; } + /// + /// Tracks the (context, field) a FormField's attached validators last produced + /// a synchronous verdict for, so the contribution can be withdrawn when it moves. + /// + private sealed class AttachedValidationBinding : Reconciler.IValidationBindingReset + { + internal ValidationContext? Context; + internal string? Field; + internal long Stamp; + + public void Reset() + { + if (Context is { } ctx && Field is { } field) + ctx.RetireProducer(field, ValidationContext.SyncProducer, Stamp); + Context = null; + Field = null; + Stamp = 0; + } + + /// + /// Withdraws the recorded contribution unless it is still the one + /// under will re-install. + /// + internal void WithdrawIfMoved(ValidationContext? nextCtx, string? nextField) + { + if (Context is not { } ctx || Field is not { } field) return; + if (ReferenceEquals(ctx, nextCtx) && string.Equals(field, nextField, StringComparison.Ordinal)) + return; + + ctx.RetireProducer(field, ValidationContext.SyncProducer, Stamp); + Context = null; + Field = null; + Stamp = 0; + } + + internal void Record(ValidationContext ctx, string field) + { + Context = ctx; + Field = field; + Stamp = ctx.GetProducerStamp(field, ValidationContext.SyncProducer); + } + + /// + /// Takes over a claim made elsewhere — the render-time write, whose context and + /// stamp are the only accurate description of what it installed. + /// + internal void Adopt(ValidationContext ctx, string field, long stamp) + { + Context = ctx; + Field = field; + Stamp = stamp; + } + } + + private static AttachedValidationBinding? TryGetAttachedBinding(UIElement control) + => control is FrameworkElement fe + && fe.GetValue(Reconciler.ReactorAttached.StateProperty) is Reconciler.ReactorState state + ? state.ValidationAttachedBinding as AttachedValidationBinding + : null; + + private static AttachedValidationBinding GetOrCreateAttachedBinding(UIElement formFieldRoot) + { + if (formFieldRoot is not FrameworkElement fe) return new AttachedValidationBinding(); + + var state = Reconciler.GetOrCreateReactorState(fe); + if (state.ValidationAttachedBinding is AttachedValidationBinding existing) return existing; + + var binding = new AttachedValidationBinding(); + state.ValidationAttachedBinding = binding; + return binding; + } + + /// + /// Decides whether an attachment contributes a synchronous verdict at all: it needs a + /// field, a value to judge, and something to judge it with. + /// + private static bool Produces(ValidationAttached? attached) + => attached is not null + && !string.IsNullOrEmpty(attached.FieldName) + && attached.HasValue + && attached.Validators.Length > 0; + + /// + /// Ties a plain (non-FormField) validated element's verdict to the lifetime of + /// the control it produced. + /// + /// .Validate(field, value, …) installs its verdict while the owning component + /// renders, which is what lets the same Render() read it back. Nothing was + /// watching what happened to that verdict afterwards: a control behind a condition + /// installed an error on the pass that showed it and then simply stopped being + /// rendered, leaving the context invalid over a field with no control, forever. The + /// mounted control is the only thing whose lifetime tracks the attachment, so the + /// contribution is recorded against it here and withdrawn when the field moves, when + /// the attachment stops producing, or when the control is unmounted. + /// + /// + /// This records the render-time verdict rather than re-running the validators: + /// reconcile-time evaluation is FormField's job, and doing it here too would + /// start judging elements that were deliberately left declarative — those assembled + /// outside a render pass. The stamp makes the recording safe even then, because a + /// contribution nobody made matches no live write. + /// + /// + internal static void TrackElementValidation(FrameworkElement fe, ValidationAttached? attached) + { + var claimed = false; + ValidationRenderScope.Ownership owned = default; + if (attached is not null) + claimed = ValidationRenderScope.TryTakeOwnership(attached, out owned); + + // Never materialize state for an element that has nothing to withdraw and nothing + // to record — every element in the tree reaches this, not just validated ones. + var binding = claimed ? GetOrCreateAttachedBinding(fe) : TryGetAttachedBinding(fe); + if (binding is null) return; + + binding.WithdrawIfMoved( + claimed ? owned.Context : null, + claimed ? attached!.FieldName : null); + + if (claimed) + { + binding.Adopt(owned.Context, attached!.FieldName, owned.Stamp); + ValidationRenderScope.MarkAdopted(owned.Context, attached.FieldName); + } + } + + /// + /// Runs a content element's attached synchronous validators against the context a + /// FormField resolved, and makes sure the field exists either way. + /// + /// Only a value-carrying attachment is validated. The validator-only overload never + /// supplies one, and null is a legitimate value, so running it here made + /// FormField(TextBox("Alice").Validate("name", Validate.Required())) report a + /// required-field error against null for a control that plainly had text + /// (issue #1262 review). Such an attachment stays declarative, exactly as it is + /// outside a FormField. + /// + /// + /// The verdict is installed under the field's own sync producer, so the previous + /// contribution has to be withdrawn whenever it moves — to a different field, to a + /// different context, or away entirely because the validators or the value are gone. + /// Otherwise its messages stay owned by something nothing validates any more, keeping + /// the form invalid or showing an error against a control that has moved on. + /// + /// + /// Registration cannot be left to the validated path alone. An element assembled + /// outside a render pass — cached in a field, built in an event handler, produced by + /// a memo — never reaches .Validate()'s render scope, so its field would exist + /// nowhere: MarkAllTouched() and the validity summary would silently skip it. + /// Async validators still are not run here; nothing runs them automatically (see + /// ValidateExtensions.ValidateAsync). + /// + /// + private static void ApplyAttachedValidation( + UIElement formFieldRoot, ValidationContext? valCtx, ValidationAttached? attached) + { + var binding = GetOrCreateAttachedBinding(formFieldRoot); + + var produces = valCtx is not null && Produces(attached); + binding.WithdrawIfMoved(valCtx, produces ? attached!.FieldName : null); + + if (valCtx is null || attached is null || string.IsNullOrEmpty(attached.FieldName)) return; + + if (produces) + { + // Skip when the render pass already judged this exact attachment. `.Validate()` + // runs eagerly while the tree is built, and this reconcile-time pass used to + // run every validator a second time for an ordinary + // `FormField(TextBox(…).Validate(f, v, …))`. The structural diff suppressed the + // duplicate *notification* but not the work, so an expensive or custom + // validator paid twice on every render (issue #1262 review). + // + // The claim is left for the content control to take: it is the thing whose + // lifetime the verdict is tied to, and it reaches TrackElementValidation + // moments later when this method's caller reconciles it. Recording here as + // well would have two bindings owning one slot. + // + // The fallback below still runs for an element assembled outside a render + // pass — cached in a field, built in an event handler, produced by a memo — + // which is the only chance those ever get to be validated. It also runs when + // the claim names a *different* context than the one resolved here, which an + // explicit `.Provide(...)` inside a hook-owning component produces. + if (ValidationRenderScope.HasOwnership(attached, valCtx)) return; + + ValidationReconciler.ValidateAttached(valCtx, attached, attached.Value); + binding.Record(valCtx, attached.FieldName); + return; + } + + if (attached.Validators.Length > 0 || attached.AsyncValidators.Length > 0) + valCtx.RegisterField(attached.FieldName); + } + internal static UIElement? UpdateFormField( Reconciler reconciler, FormFieldElement oldFf, FormFieldElement newFf, WinUI.StackPanel panel, Action requestRerender) @@ -115,10 +306,9 @@ internal static WinUI.StackPanel MountFormField(Reconciler reconciler, FormField // Auto-validate var attached = newFf.Content.GetAttached(); var valCtx = reconciler.ReadContext(ValidationContexts.Current); - if (valCtx is not null && attached is not null && attached.Validators.Length > 0) - { - ValidationReconciler.ValidateAttached(valCtx, attached, attached.Value); - } + + ApplyAttachedValidation(panel, valCtx, attached); + // [0] Update label if (panel.Children[0] is TextBlock labelTb) @@ -130,6 +320,7 @@ internal static WinUI.StackPanel MountFormField(Reconciler reconciler, FormField // [1] Patch content in-place (preserves caret position and focus) var existingContent = panel.Children[1]; + var outgoingContent = existingContent; if (reconciler.CanUpdate(oldFf.Content, newFf.Content)) { var replacement = reconciler.Update(oldFf.Content, newFf.Content, existingContent, requestRerender); @@ -154,8 +345,17 @@ internal static WinUI.StackPanel MountFormField(Reconciler reconciler, FormField existingContent = newContent; } + // An editor that has just been displaced is on its way to the element pool, and + // its LostFocus handler is attached once for the control's lifetime and reads a + // mutable binding. Neutralize it here rather than through the root mapping: the + // root can itself be replaced by a remount, in which case the mapping no longer + // names the control that actually left (issue #1262 review). + if (!ReferenceEquals(outgoingContent, existingContent)) + ClearTouchBinding(outgoingContent); + ApplyFormFieldAutomation(existingContent, newFf.Label); ApplyFormFieldErrorStyling(existingContent, valCtx, fieldName, newFf.ShowWhen); + WireTouchedOnBlur(panel, existingContent, valCtx, fieldName); // [2] Update description/error text if (panel.Children[2] is TextBlock descTb) @@ -179,7 +379,11 @@ internal static WinUI.StackPanel MountValidationVisualizer( // Collect messages from the validation context var allMessages = valCtx?.GetAllMessages() ?? (IReadOnlyList)[]; var (caught, _) = ErrorBubbling.FilterMessages(allMessages, vv.SeverityFilter); - var shouldDisplay = ErrorBubbling.ShouldDisplay(caught, vv.ShowWhen, valCtx); + // Same submit-attempt plumbing as FormField: without it a visualizer set to + // ShowWhen.AfterFirstSubmit could never display, since nothing else supplies + // the flag (issue #1262). + var shouldDisplay = ErrorBubbling.ShouldDisplay( + caught, vv.ShowWhen, valCtx, valCtx?.SubmitAttempted ?? false); switch (vv.Style) { @@ -284,25 +488,399 @@ internal static WinUI.StackPanel MountValidationVisualizer( internal static UIElement MountValidationRule(Reconciler reconciler, ValidationRuleElement rule) { - // Evaluate the rule against the nearest ValidationContext + // The collapsed placeholder is this rule's durable identity: the reconciler keeps + // it across re-renders, so it can name the rule as a message producer even though + // the element record itself is rebuilt every pass. Keying on the message instead + // would break for an interpolated one and would conflate two rules that happen to + // share text (issue #1262 review). + var placeholder = new WinUI.StackPanel { Visibility = Visibility.Collapsed }; + var binding = GetOrCreateRuleBinding(placeholder); + var valCtx = reconciler.ReadContext(ValidationContexts.Current); if (valCtx is not null) - rule.Evaluate(valCtx); + { + valCtx.RegisterField(rule.Field); + EvaluateRuleForBinding(rule, valCtx, binding); + binding.Context = valCtx; + binding.Field = rule.Field; + } - // Return a collapsed placeholder — validation rules produce no UI - var placeholder = new WinUI.StackPanel { Visibility = Visibility.Collapsed }; Reconciler.SetElementTag(placeholder, rule); return placeholder; } - internal static UIElement? UpdateValidationRule(Reconciler reconciler, ValidationRuleElement rule) + internal static UIElement? UpdateValidationRule(Reconciler reconciler, ValidationRuleElement rule, UIElement control) { + var binding = GetOrCreateRuleBinding(control); var valCtx = reconciler.ReadContext(ValidationContexts.Current); + + // A rule can move: to a different field, or into a different provider's context. + // Its old contribution has to be withdrawn from where it used to live, or that + // context stays invalid forever with a message nothing owns any more. + if (binding.Context is { } previousCtx && binding.Field is { } previousField + && (!ReferenceEquals(previousCtx, valCtx) + || !string.Equals(previousField, rule.Field, StringComparison.Ordinal))) + { + // Cancel first: a pass still running against the old context would otherwise + // reinstall the error just withdrawn, for a rule that has moved away. + CancelPendingRule(binding); + previousCtx.RetireProducer(previousField, binding.Producer); + binding.Context = null; + binding.Field = null; + } + if (valCtx is not null) - rule.Evaluate(valCtx); + { + valCtx.RegisterField(rule.Field); + EvaluateRuleForBinding(rule, valCtx, binding); + binding.Context = valCtx; + binding.Field = rule.Field; + } + return null; // keep existing collapsed placeholder } + /// + /// Withdraws a mounted rule's contribution when it leaves the tree — a conditionally + /// rendered rule disappearing must not leave the form permanently invalid. + /// + internal static void RetractValidationRule(UIElement placeholder) + { + if (ReadRuleBinding(placeholder) is not { } binding) return; + + // A pass still running would otherwise install a verdict for a rule that has + // already left the tree. + CancelPendingRule(binding); + + if (binding.Context is { } ctx && binding.Field is { } field) + ctx.RetireProducer(field, binding.Producer); + + binding.Context = null; + binding.Field = null; + } + + private sealed class RuleBinding : Reconciler.IValidationBindingReset + { + internal string Producer = ""; + internal ValidationContext? Context; + internal string? Field; + internal global::System.Threading.CancellationTokenSource? Pending; + + // Called by DetachReactorState when a control is retired outside the normal + // unmount path: cancel any pass still out, withdraw whatever this producer + // installed — otherwise the error outlives the control forever — and drop the + // context so a result that resolves anyway cannot write to it. + public void Reset() + { + CancelPendingRule(this); + if (Context is { } ctx && Field is { } field) + ctx.RetireProducer(field, Producer); + Context = null; + Field = null; + } + } + + /// + /// Runs a mounted rule against its context, dispatching an async rule through the + /// generation-guarded async path instead of its synchronous stand-in. + /// + /// ValidationRuleAsync builds an element whose synchronous predicate is a + /// constant true, so evaluating it synchronously recorded a passing verdict + /// for every async rule placed in the tree — the predicate never ran at all + /// (issue #1262 review). + /// + /// + /// Each pass supersedes the previous one: the old token is cancelled, and the + /// context's per-producer generation discards whatever an already-resolved older + /// pass tries to install. Unmount cancels the outstanding pass as well, so a rule + /// that left the tree cannot write to the context afterwards. + /// + /// + private static void EvaluateRuleForBinding(ValidationRuleElement rule, ValidationContext valCtx, RuleBinding binding) + { + if (rule.AsyncPredicate is null) + { + CancelPendingRule(binding); + + // Evaluate retires the producer's async generation, which matters when the + // rule has just stopped being async on this same placeholder. + rule.Evaluate(valCtx, binding.Producer); + return; + } + + CancelPendingRule(binding); + var cts = new global::System.Threading.CancellationTokenSource(); + binding.Pending = cts; + _ = RunAsyncRuleAsync(rule, valCtx, binding, cts); + } + + private static async Task RunAsyncRuleAsync( + ValidationRuleElement rule, ValidationContext valCtx, RuleBinding binding, + global::System.Threading.CancellationTokenSource cts) + { + using var owned = cts; + try + { + await rule.EvaluateAsync(valCtx, binding.Producer, cts.Token); + } + catch (global::System.OperationCanceledException ex) + when (ex.CancellationToken == cts.Token || cts.IsCancellationRequested) + { + // Our own token: the pass was superseded by a newer one, or the rule left + // the tree. Expected, and the caller that cancelled owns what happens next. + // + // Matched narrowly rather than catching every OperationCanceledException, + // because the predicate takes no token of its own — anything it cancels is + // the app's own business and a fault we are hiding if we treat it as + // lifecycle churn (issue #1262 review). Those fall through to the diagnostic + // arm below. `IsCancellationRequested` is checked as well as the token, + // since a predicate that observes our token indirectly can surface a + // cancellation that carries `CancellationToken.None`. + } + catch (global::System.Exception ex) + when (ex is not global::System.OutOfMemoryException + and not global::System.StackOverflowException) + { + // An app predicate threw. Surfacing it as an unobserved task exception would + // tear the process down later and far from the cause, so report it where the + // rest of the framework reports background faults and leave the previous + // verdict in place. + Diagnostics.DiagnosticLog.SwallowedError( + Diagnostics.LogCategory.Reactor, "ValidationRuleAsync.Evaluate", ex); + } + + // Compare-exchange, not check-then-assign: an update can install a newer source + // between the two, and a plain assignment would then clear *its* slot — leaving + // the newer pass uncancellable on the next update or unmount. + global::System.Threading.Interlocked.CompareExchange(ref binding.Pending, null, cts); + } + + private static void CancelPendingRule(RuleBinding binding) + { + var pending = global::System.Threading.Interlocked.Exchange(ref binding.Pending, null); + if (pending is null) return; + + try + { + pending.Cancel(); + } + catch (global::System.ObjectDisposedException ex) + { + // The pass finished and disposed its source between the read above and this + // call. There is nothing left to cancel, but record it rather than swallow. + Diagnostics.DiagnosticLog.SwallowedError( + Diagnostics.LogCategory.Reactor, "ValidationRuleAsync.Cancel", ex); + } + } + + private static long s_ruleProducerSeed; + // Bindings live on the native control's attached state, not in a CWT keyed by the + // managed wrapper: WinRT can project two RCWs over one DependencyObject, and a + // lookup that landed on the other wrapper would mint a fresh producer while the + // old one could never be retired (see ChangeEchoSuppressor.cs, issues #86/#114). + private static RuleBinding? ReadRuleBinding(UIElement placeholder) => + placeholder is FrameworkElement fe + ? Reconciler.GetOrCreateReactorState(fe).ValidationRuleBinding as RuleBinding + : null; + + private static RuleBinding GetOrCreateRuleBinding(UIElement placeholder) + { + if (ReadRuleBinding(placeholder) is { } existing) return existing; + + var binding = new RuleBinding + { + Producer = "rule#" + global::System.Threading.Interlocked + .Increment(ref s_ruleProducerSeed) + .ToString(global::System.Globalization.CultureInfo.InvariantCulture), + }; + if (placeholder is FrameworkElement fe) + Reconciler.GetOrCreateReactorState(fe).ValidationRuleBinding = binding; + return binding; + } + + /// + /// Marks a field touched when its editor loses focus. + /// + /// FormField defaults to and the guide + /// promises "errors appear below the field after the field is touched (focus then + /// blur)" — but nothing in the framework ever called + /// , so that default could only ever + /// reveal an error in apps that marked fields by hand. The documented FormField + /// example does not, which left its error display permanently unreachable + /// (issue #1262). + /// + /// + /// The handler is attached once per control and reads the field name and context + /// from a mutable binding at invocation time, so a control recycled through the + /// element pool — or re-targeted at a different field by an update — reports for + /// whatever field it currently hosts rather than the one it was mounted with. + /// + /// + private static void WireTouchedOnBlur(UIElement formFieldRoot, UIElement contentControl, ValidationContext? valCtx, string? fieldName) + { + if (contentControl is not FrameworkElement fe) + { + // Nothing to bind to, but the root may still point at the *previous* + // content's live binding — and that control is on its way to the pool. + ReleaseRootBinding(formFieldRoot); + return; + } + + // No context or field to report to — neutralize any binding this control still + // carries from a previous FormField rather than leaving it pointed at the old one. + if (valCtx is null || string.IsNullOrEmpty(fieldName)) + { + // An update can drop the context *and* swap the content control in one pass. + // The root still points at the old editor's binding, and that editor is on + // its way to the pool with a live context — so neutralize what the root + // points at, not just the incoming control. + ReleaseRootBinding(formFieldRoot); + ClearTouchBinding(fe); + return; + } + + if (Reconciler.GetOrCreateReactorState(fe).ValidationTouchBinding is TouchBinding existing) + { + existing.Context = valCtx; + existing.FieldName = fieldName; + ReplaceRootBinding(formFieldRoot, existing); + return; + } + + var binding = new TouchBinding { Context = valCtx, FieldName = fieldName }; + Reconciler.GetOrCreateReactorState(fe).ValidationTouchBinding = binding; + ReplaceRootBinding(formFieldRoot, binding); + fe.LosingFocus += (_, args) => + { + // LosingFocus rather than LostFocus, and filtered by where focus is going. + // Both are routed, so both also fire when focus moves *between descendants* + // of a composite editor — a NumberBox's text part to one of its spin + // buttons, a DatePicker between its three selectors. The field has not been + // blurred at all in that case, so marking it touched contradicts the + // documented "focus then blur" and can reveal an error while the user is + // still inside the control (issue #1262 review). LostFocus cannot make this + // distinction: it carries no destination, and the new focus is not yet set + // when it fires. + // + // A null destination — focus leaving the window entirely — is a real blur + // and falls through to mark touched. + if (args.NewFocusedElement is DependencyObject next && IsDescendantOf(next, fe)) + return; + + if (binding.Context is { } ctx && binding.FieldName is { Length: > 0 } field) + ctx.MarkTouched(field); + }; + } + + /// + /// Points a FormField root at its current content control's binding, neutralizing + /// whichever binding it pointed at before. + /// + /// An update that swaps the content control unmounts the old editor into the pool + /// and maps the root to the new one. Without clearing the displaced binding, that + /// pooled editor would keep marking the old field when rented out elsewhere — the + /// same leak as an unmounted FormField, reached by a different route. + /// + /// + private static void ReplaceRootBinding(UIElement formFieldRoot, TouchBinding binding) + { + if (ReadRootBinding(formFieldRoot) is { } previous) + { + if (ReferenceEquals(previous, binding)) return; + + previous.Context = null; + previous.FieldName = null; + WriteRootBinding(formFieldRoot, null); + } + + WriteRootBinding(formFieldRoot, binding); + } + + /// + /// Neutralizes the blur binding on a FormField's content control when the + /// field unmounts. + /// + /// The LostFocus handler is attached once for the control's lifetime, and + /// controls such as TextBox are poolable. Without this, a control rented back + /// out for some non-FormField use would still mark the field it used to host on + /// every blur, and the pool would keep that alive. + /// Clearing the live state leaves the one-time handler harmless and lets a later + /// mount re-point the same binding. + /// + /// + internal static void ClearFormFieldTouchBinding(UIElement formFieldRoot) + { + // Looked up by root rather than by walking Children: unmount runs while the + // subtree is being torn down, and reading a panel's visual children at that + // point is exactly the kind of teardown-state access worth not doing. + if (ReadRootBinding(formFieldRoot) is { } binding) + { + binding.Context = null; + binding.FieldName = null; + } + } + + private static void ClearTouchBinding(UIElement contentControl) + { + if (contentControl is FrameworkElement fe + && Reconciler.GetOrCreateReactorState(fe).ValidationTouchBinding is TouchBinding binding) + { + binding.Context = null; + binding.FieldName = null; + } + } + + /// + /// Neutralizes whatever binding a FormField root currently points at and stops + /// pointing at it, for the paths where no new binding will take its place. + /// + private static void ReleaseRootBinding(UIElement formFieldRoot) + { + ClearFormFieldTouchBinding(formFieldRoot); + WriteRootBinding(formFieldRoot, null); + } + + // Test-only accessor (InternalsVisibleTo Reactor.Tests / Reactor.AppTests.Host): + // reports whether a control's once-per-lifetime LostFocus handler would still + // mark a field. The leak this guards — a displaced editor keeping the old + // context alive — is otherwise observable only through element-pool reuse, + // which is not deterministic enough to assert on. + internal static bool HasLiveTouchBindingForTests(UIElement contentControl) => + contentControl is FrameworkElement fe + && Reconciler.GetOrCreateReactorState(fe).ValidationTouchBinding is TouchBinding binding + && binding.Context is not null + && !string.IsNullOrEmpty(binding.FieldName); + + private sealed class TouchBinding : Reconciler.IValidationBindingReset + { + internal ValidationContext? Context; + internal string? FieldName; + + // The LostFocus handler is attached once for the control's lifetime and reads + // this at invocation time, so neutralizing is what makes a retired control + // stop marking the field it used to host. + public void Reset() + { + Context = null; + FieldName = null; + } + } + + private static TouchBinding? ReadRootBinding(UIElement formFieldRoot) => + formFieldRoot is FrameworkElement fe + ? Reconciler.GetOrCreateReactorState(fe).ValidationRootBinding as TouchBinding + : null; + + private static void WriteRootBinding(UIElement formFieldRoot, TouchBinding? binding) + { + if (formFieldRoot is FrameworkElement fe) + Reconciler.GetOrCreateReactorState(fe).ValidationRootBinding = binding; + } + + // FormField root -> the binding of its current content control, so unmount can + // neutralize it without touching the visual tree mid-teardown. + + private static void ApplyFormFieldAutomation(UIElement contentControl, string? label) { var automationName = FormFieldHelpers.GetAutomationName(label); @@ -319,7 +897,8 @@ private static void ApplyFormFieldErrorStyling( if (valCtx is not null && fieldName is not null) { var severity = valCtx.HighestSeverity(fieldName); - if (severity is not null && ErrorStyling.ShouldShowErrors(valCtx, fieldName, showWhen)) + if (severity is not null + && ErrorStyling.ShouldShowErrors(valCtx, fieldName, showWhen, valCtx.SubmitAttempted)) { var brushKey = ErrorStyling.GetBrushKey(severity.Value); var brush = ThemeRef.Resolve(brushKey, ctrl); @@ -342,7 +921,7 @@ private static void ApplyFormFieldDescription( string? description, ShowWhen showWhen) { var (descText, isError) = FormFieldHelpers.GetDescriptionOrError( - valCtx, fieldName, description, showWhen); + valCtx, fieldName, description, showWhen, valCtx?.SubmitAttempted ?? false); if (descText is null) { diff --git a/src/Reactor/Core/V1Protocol/Handlers/CompositeHandlers.cs b/src/Reactor/Core/V1Protocol/Handlers/CompositeHandlers.cs index 51ac8fa9a..3149db004 100644 --- a/src/Reactor/Core/V1Protocol/Handlers/CompositeHandlers.cs +++ b/src/Reactor/Core/V1Protocol/Handlers/CompositeHandlers.cs @@ -46,7 +46,11 @@ public UIElement Update(UpdateContext ctx, FormFieldElement oldEl, FormFieldElem : ctx.Reconciler.Mount(newEl, ctx.RequestRerender) ?? control; public V1UnmountDisposition Unmount(UnmountContext ctx, FormFieldElement? element, UIElement control) - => V1UnmountDisposition.ContinueDefaultTraversal; + { + // Issue #1262: drop the blur binding before the content control can be pooled. + CompositeLifecycle.ClearFormFieldTouchBinding(control); + return V1UnmountDisposition.ContinueDefaultTraversal; + } } /// §14 — ValidationVisualizer (StackPanel; Update always remounts). @@ -71,8 +75,13 @@ public UIElement Mount(MountContext ctx, ValidationRuleElement el) => CompositeLifecycle.MountValidationRule(ctx.Reconciler, el); public UIElement Update(UpdateContext ctx, ValidationRuleElement oldEl, ValidationRuleElement newEl, UIElement control) - => CompositeLifecycle.UpdateValidationRule(ctx.Reconciler, newEl) ?? control; + => CompositeLifecycle.UpdateValidationRule(ctx.Reconciler, newEl, control) ?? control; public V1UnmountDisposition Unmount(UnmountContext ctx, ValidationRuleElement? element, UIElement control) - => V1UnmountDisposition.ContinueDefaultTraversal; + { + // Issue #1262: a conditionally rendered rule leaving the tree must withdraw its + // message, or the context stays invalid with a verdict nothing owns. + CompositeLifecycle.RetractValidationRule(control); + return V1UnmountDisposition.ContinueDefaultTraversal; + } } diff --git a/src/Reactor/Hosting/ReactorHost.cs b/src/Reactor/Hosting/ReactorHost.cs index 75adfe021..ab7e45fb9 100644 --- a/src/Reactor/Hosting/ReactorHost.cs +++ b/src/Reactor/Hosting/ReactorHost.cs @@ -1,5 +1,6 @@ using System.Diagnostics; using Microsoft.UI.Reactor.Animation; +using Microsoft.UI.Reactor.Controls.Validation; using Microsoft.UI.Reactor.Core; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; @@ -520,6 +521,9 @@ private void Render() void RecoverFromHookOrder(HookOrderException ex, RenderContext ctx, string mode) { + // This path returns without reconciling, so nothing downstream will consume + // or retire what the aborted render claimed (issue #1262). + Controls.Validation.ValidationRenderScope.AbandonPendingClaims(); _logger?.LogWarning(ex, "Hot reload: hook order/type changed — resetting {Mode} state and re-rendering", mode); @@ -555,7 +559,10 @@ void RecoverFromHookOrder(HookOrderException ex, RenderContext ctx, string mode) _rootComponent.Context.BeginRender(rerender); try { - newTree = _rootComponent.Render(); + using (ValidationRenderScope.Begin(null)) + { + newTree = ValidationRenderScope.ApplyProvide(_rootComponent.Render()); + } } catch (HookOrderException ex) when (hotReloadRender) { @@ -575,7 +582,10 @@ void RecoverFromHookOrder(HookOrderException ex, RenderContext ctx, string mode) _funcContext.BeginRender(rerender); try { - newTree = _rootRenderFunc(_funcContext); + using (ValidationRenderScope.Begin(null)) + { + newTree = ValidationRenderScope.ApplyProvide(_rootRenderFunc(_funcContext)); + } } catch (HookOrderException ex) when (hotReloadRender) { @@ -988,6 +998,10 @@ public void Dispose() private void ShowErrorFallback(Exception ex) { + // The render that failed never reaches Reconcile, so its validation claims have + // no consumer. Withdraw them here rather than waiting for a next render that may + // never come (issue #1262). + Controls.Validation.ValidationRenderScope.AbandonPendingClaims(); var errorPanel = Microsoft.UI.Reactor.Core.ErrorFallback.BuildPanel(ex); if (_overlayWiring is not null && _overlayWiring.TryShowErrorInWrapper(errorPanel)) { diff --git a/src/Reactor/Hosting/ReactorHostControl.cs b/src/Reactor/Hosting/ReactorHostControl.cs index 965e7b6af..f7a76a5ea 100644 --- a/src/Reactor/Hosting/ReactorHostControl.cs +++ b/src/Reactor/Hosting/ReactorHostControl.cs @@ -1,5 +1,6 @@ using System.Diagnostics; using Microsoft.UI.Reactor.Animation; +using Microsoft.UI.Reactor.Controls.Validation; using Microsoft.UI.Reactor.Core; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; @@ -144,6 +145,13 @@ public ReactorHostControl(Component? component = null, ILogger? logger = null) _logger = logger ?? ReactorApp.AppLogger; _reconciler = new Reconciler(_logger); _dispatcherQueue = DispatcherQueue.GetForCurrentThread(); + // A standalone ReactorHostControl has no ReactorApp bootstrap, so nothing else + // sets ReactorApp.UIDispatcher. Cross-thread setState — including the re-render + // that UseValidationContext schedules when a background async validator raises + // ValidationContext.Changed — resolves its marshal target from that static and + // throws when it is null. Seed it exactly as ReactorHost does (spec 036 §4.3). + if (ReactorApp.UIDispatcher is null) + ReactorApp.UIDispatcher = _dispatcherQueue; HorizontalContentAlignment = HorizontalAlignment.Stretch; VerticalContentAlignment = VerticalAlignment.Stretch; // ContentControl inherits IsTabStop=true from Control. Set it to false @@ -357,6 +365,9 @@ private void Render() // additional reset steps, throttling) only need editing here. void RecoverFromHookOrder(HookOrderException ex, RenderContext ctx, string mode) { + // This path returns without reconciling, so nothing downstream will consume + // or retire what the aborted render claimed (issue #1262). + Controls.Validation.ValidationRenderScope.AbandonPendingClaims(); _logger?.LogWarning(ex, "Hot reload: hook order/type changed — resetting {Mode} state and re-rendering", mode); @@ -375,7 +386,10 @@ void RecoverFromHookOrder(HookOrderException ex, RenderContext ctx, string mode) _rootComponent.Context.BeginRender(_requestRenderAction ??= RequestRender); try { - newTree = _rootComponent.Render(); + using (ValidationRenderScope.Begin(null)) + { + newTree = ValidationRenderScope.ApplyProvide(_rootComponent.Render()); + } } catch (HookOrderException ex) when (hotReloadRender) { @@ -394,7 +408,10 @@ void RecoverFromHookOrder(HookOrderException ex, RenderContext ctx, string mode) _funcContext.BeginRender(_requestRenderAction ??= RequestRender); try { - newTree = _rootRenderFunc(_funcContext); + using (ValidationRenderScope.Begin(null)) + { + newTree = ValidationRenderScope.ApplyProvide(_rootRenderFunc(_funcContext)); + } } catch (HookOrderException ex) when (hotReloadRender) { @@ -617,6 +634,10 @@ private void OnColorValuesChanged(global::Windows.UI.ViewManagement.UISettings s private void ShowErrorFallback(Exception ex) { + // The render that failed never reaches Reconcile, so its validation claims have + // no consumer. Withdraw them here rather than waiting for a next render that may + // never come (issue #1262). + Controls.Validation.ValidationRenderScope.AbandonPendingClaims(); var errorPanel = Microsoft.UI.Reactor.Core.ErrorFallback.BuildPanel(ex); if (_overlayWiring is not null && _overlayWiring.TryShowErrorInWrapper(errorPanel)) { diff --git a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs index 3e0b44079..3e3bf5f69 100644 --- a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs +++ b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs @@ -354,30 +354,47 @@ public override async Task RunAsync() { var ctx = new ValidationContext(); - // Sync rule — fails - var rule = ValidationRule(() => false, "Must be valid", "field1"); - rule.Evaluate(ctx); + // Sync rule — fails, then the *same rule* (same call site) passes and + // retracts its own message. A rule's identity is its code location, not its + // message text, so re-running one call site is what models "this rule again" + // (issue #1262 review). + var field1Ok = false; + void RunSyncRule() => ValidationRule(() => field1Ok, "Must be valid", "field1").Evaluate(ctx); + + RunSyncRule(); H.Check("ValRule_SyncFail", ctx.HasError("field1")); - // Sync rule — passes (clears previous) - var rule2 = ValidationRule(() => true, "Must be valid", "field1"); - rule2.Evaluate(ctx); + field1Ok = true; + RunSyncRule(); H.Check("ValRule_SyncPass", !ctx.HasError("field1")); - // Async rule — fails - var asyncRule = ValidationRuleAsync( - async () => { await Task.Delay(1); return false; }, - "Async fail", "field2"); - await asyncRule.EvaluateAsync(ctx); + // Async rule — fails, then the same call site passes and retracts. Using a + // *different* call site here would assert the old whole-field-replace + // behaviour, where any passing rule erased every other producer's errors. + var field2Ok = false; + async Task RunAsyncRule() => await ValidationRuleAsync( + async () => { await Task.Delay(1); return field2Ok; }, + "Async fail", "field2").EvaluateAsync(ctx); + + await RunAsyncRule(); H.Check("ValRule_AsyncFail", ctx.HasError("field2")); - // Async rule — passes - var asyncRule2 = ValidationRuleAsync( - async () => { await Task.Delay(1); return true; }, - "Async pass", "field2"); - await asyncRule2.EvaluateAsync(ctx); + field2Ok = true; + await RunAsyncRule(); H.Check("ValRule_AsyncPass", !ctx.HasError("field2")); + // A different passing rule must leave another producer's error alone. + var asyncFail2 = ValidationRuleAsync( + async () => { await Task.Delay(1); return false; }, + "Async fail", "field2"); + await asyncFail2.EvaluateAsync(ctx); + var unrelatedPass = ValidationRuleAsync( + async () => { await Task.Delay(1); return true; }, + "Unrelated rule", "field2"); + await unrelatedPass.EvaluateAsync(ctx); + H.Check("ValRule_AsyncPassKeepsOtherProducers", ctx.HasError("field2")); + ctx.Clear("field2"); + // Sync fallback when AsyncPredicate is null var syncFallback = ValidationRule(() => false, "Sync fallback", "field3"); await syncFallback.EvaluateAsync(ctx); @@ -440,4 +457,2148 @@ public override async Task RunAsync() await Harness.Render(); } } -} + + // ════════════════════════════════════════════════════════════════════════ + // Issue #1262 — the docs' "Validation Context" example, verbatim. + // + // Bare .Validate() controls: no FormField wrapper, no explicit + // .Provide(ValidationContexts.Current, …), and the context queried inline + // during Render(). Every one of those was silently inert before the fix, so + // an empty form reported IsValid() == true and submitted. + // ════════════════════════════════════════════════════════════════════════ + + internal class Issue1262_BareValidateOnControls(Harness h) : SelfTestFixtureBase(h) + { + public override async Task RunAsync() + { + var host = H.CreateHost(); + ValidationContext? captured = null; + var submissions = 0; + + host.Mount(ctx => + { + var valCtx = ctx.UseValidationContext(); + captured = valCtx; + var (email, setEmail) = ctx.UseState(""); + var (password, setPassword) = ctx.UseState(""); + var (submitted, setSubmitted) = ctx.UseState(false); + + return VStack(12, + TextBox(email, v => { setEmail(v); valCtx.NotifyValueChanged("email", v); }, + placeholderText: "user@example.com", header: "Email") + .Validate("email", email, + Validate.Required("Email is required"), + Validate.Email()), + When(valCtx.IsTouched("email") && valCtx.HasError("email"), () => + TextBlock(valCtx.GetMessages("email")[0].Text)), + PasswordBox(password, v => { setPassword(v); valCtx.NotifyValueChanged("password", v); }) + .Validate("password", password, + Validate.Required("Password is required"), + Validate.MinLength(8, "Password is too short")), + When(valCtx.IsTouched("password") && valCtx.HasError("password"), () => + TextBlock(valCtx.GetMessages("password")[0].Text)), + Button("Register", () => + { + valCtx.MarkAllTouched(); + if (valCtx.IsValid()) { setSubmitted(true); submissions++; } + }), + When(submitted, () => TextBlock("Registration successful!"))); + }); + + await Harness.Render(); + + // Validators ran during Render(), so the verdict exists before any click. + H.Check("Issue1262_ValidatorsRanOnMount", captured is not null && !captured.IsValid()); + if (captured is null) return; + + H.Check("Issue1262_FieldsRegistered", + captured.RegisteredFields.Contains("email") && captured.RegisteredFields.Contains("password")); + + // ...but stays hidden until the field is touched. + H.Check("Issue1262_ErrorsHiddenBeforeTouch", H.FindText("Email is required") is null); + + H.ClickButton("Register"); + await Harness.Render(); + + // The reported symptom: this used to submit an empty form. + H.Check("Issue1262_SubmitBlocked", submissions == 0); + H.Check("Issue1262_NoSuccessMessage", H.FindText("Registration successful!") is null); + + // MarkAllTouched() mutates only the context — nothing else schedules a + // repaint, so without the change notification the errors stayed invisible. + H.Check("Issue1262_ErrorTextVisibleAfterSubmit", H.FindText("Email is required") is not null); + H.Check("Issue1262_SecondFieldErrorVisible", H.FindText("Password is required") is not null); + + // An empty password trips Required *and* MinLength — both validators ran. + H.Check("Issue1262_AllValidatorsRan", captured.GetMessages("password").Count == 2); + + // Re-clicking must stay stable rather than accumulating messages. + H.ClickButton("Register"); + await Harness.Render(); + H.Check("Issue1262_NoMessageAccumulation", captured.GetMessages("email").Count == 1); + + var done = H.CreateHost(); + done.Mount(c => TextBlock("Issue1262 bare validate done")); + await Harness.Render(); + } + } + + // ════════════════════════════════════════════════════════════════════════ + // Issue #1262 — the docs' "FormField Helper" example, verbatim. + // + // FormField finds the ValidationContext through the provider the hook now + // installs on the component's own output; the snippet never wrote + // .Provide(...) itself, so nothing validated and no error chrome appeared. + // ════════════════════════════════════════════════════════════════════════ + + internal class Issue1262_FormFieldWithoutExplicitProvide(Harness h) : SelfTestFixtureBase(h) + { + public override async Task RunAsync() + { + var host = H.CreateHost(); + ValidationContext? captured = null; + + host.Mount(ctx => + { + var valCtx = ctx.UseValidationContext(); + captured = valCtx; + var (name, setName) = ctx.UseState(""); + + return VStack(12, + FormField( + TextBox(name, v => { setName(v); valCtx.NotifyValueChanged("name", v); }) + .Validate("name", name, Validate.Required("Name is required")), + label: "Full Name", + required: true, + description: "As it appears on your ID", + showWhen: ShowWhen.Always), + Button("SubmitForm", () => valCtx.MarkAllTouched())); + }); + + await Harness.Render(); + + H.Check("Issue1262_FormField_ContextReached", captured is not null && !captured.IsValid()); + H.Check("Issue1262_FormField_ErrorRendered", H.FindText("Name is required") is not null); + // The error text replaces the description in FormField's third slot. + H.Check("Issue1262_FormField_DescriptionSwapped", H.FindText("As it appears on your ID") is null); + + var done = H.CreateHost(); + done.Mount(c => TextBlock("Issue1262 formfield done")); + await Harness.Render(); + } + } + + // ════════════════════════════════════════════════════════════════════════ + // Issue #1262 — an explicit .Provide() must still win over the automatic one. + // ════════════════════════════════════════════════════════════════════════ + + internal class Issue1262_ExplicitProvideStillWins(Harness h) : SelfTestFixtureBase(h) + { + public override async Task RunAsync() + { + var host = H.CreateHost(); + var mine = new ValidationContext(); + ValidationContext? hookResult = null; + + host.Mount(ctx => + { + var valCtx = ctx.UseValidationContext(); + hookResult = valCtx; + + return VStack( + FormField( + TextBox("").Validate("name", "", Validate.Required("Explicit-ctx error")), + label: "Name", + showWhen: ShowWhen.Always)) + .Provide(ValidationContexts.Current, mine); + }); + + await Harness.Render(); + + // FormField validated against the caller's context, not the hook's local one. + H.Check("Issue1262_ExplicitProvide_Wins", !mine.IsValid()); + H.Check("Issue1262_ExplicitProvide_HookCtxDistinct", + hookResult is not null && !ReferenceEquals(hookResult, mine)); + + var done = H.CreateHost(); + done.Mount(c => TextBlock("Issue1262 explicit provide done")); + await Harness.Render(); + } + } + + // ════════════════════════════════════════════════════════════════════════ + // Issue #1262 — FormField's default ShowWhen.WhenTouched must be reachable. + // + // The guide promises "errors appear below the field after the field is + // touched (focus then blur)". Nothing in the framework ever called + // MarkTouched, so with the default ShowWhen an app that did not mark fields + // by hand — including the documented FormField snippet — could never show an + // error at all. + // ════════════════════════════════════════════════════════════════════════ + + internal class Issue1262_FormFieldTouchedOnBlur(Harness h) : SelfTestFixtureBase(h) + { + public override async Task RunAsync() + { + var host = H.CreateHost(); + ValidationContext? captured = null; + + host.Mount(ctx => + { + var valCtx = ctx.UseValidationContext(); + captured = valCtx; + var (name, setName) = ctx.UseState(""); + + return VStack(12, + FormField( + TextBox(name, v => { setName(v); valCtx.NotifyValueChanged("name", v); }) + .Validate("name", name, Validate.Required("Name is required")), + label: "Full Name", + required: true, + description: "As it appears on your ID"), + Button("Elsewhere", () => { })); + }); + + await Harness.Render(); + + // Invalid from the first pass, but untouched — so the description shows. + H.Check("Issue1262_Blur_InvalidButQuiet", + captured is not null && !captured.IsValid()); + if (captured is null) return; + + H.Check("Issue1262_Blur_NotTouchedInitially", !captured.IsTouched("name")); + H.Check("Issue1262_Blur_DescriptionShown", H.FindText("As it appears on your ID") is not null); + H.Check("Issue1262_Blur_NoErrorBeforeBlur", H.FindText("Name is required") is null); + + var box = H.FindControl(_ => true); + var elsewhere = H.FindButton("Elsewhere"); + H.Check("Issue1262_Blur_ControlsFound", box is not null && elsewhere is not null); + + // Focus the editor, then move focus away — the blur is what marks it. + box!.Focus(Microsoft.UI.Xaml.FocusState.Programmatic); + await Harness.Render(); + H.Check("Issue1262_Blur_StillQuietWhileFocused", !captured.IsTouched("name")); + + elsewhere!.Focus(Microsoft.UI.Xaml.FocusState.Programmatic); + await Harness.Render(); + + H.Check("Issue1262_Blur_TouchedAfterBlur", captured.IsTouched("name")); + H.Check("Issue1262_Blur_ErrorShownAfterBlur", H.FindText("Name is required") is not null); + H.Check("Issue1262_Blur_DescriptionSwapped", H.FindText("As it appears on your ID") is null); + + var done = H.CreateHost(); + done.Mount(c => TextBlock("Issue1262 blur done")); + await Harness.Render(); + } + } + + // ════════════════════════════════════════════════════════════════════════ + // Issue #1262 review — a failing ValidationRule must not drive a render loop. + // + // ValidationRule evaluates during reconcile, after the component's render + // scope has closed, so its notifications are NOT suppressed. Clear-then-add + // made every pass look like a change, each change requested another render, + // and the reconciler's re-entrancy guard would throw "Render loop detected". + // ════════════════════════════════════════════════════════════════════════ + + internal class Issue1262_FailingRuleDoesNotLoop(Harness h) : SelfTestFixtureBase(h) + { + private sealed class RuleProbe + { + internal ValidationContext? Context; + internal int Renders; + } + + private sealed record RuleOwnerProps(RuleProbe Probe); + + // Must be a child Component, not the host's root render func: a child's + // re-render callback runs INLINE (CreateComponentRerender), which is what turns + // a notification raised during reconcile into unbounded re-entrancy. The root's + // callback merely schedules, so mounting this at the root would hide the bug. + private sealed class RuleOwner : Component + { + public override Element Render() + { + var valCtx = this.UseValidationContext(); + Props.Probe.Context = valCtx; + Props.Probe.Renders++; + + return VStack(12, + ValidationRule(() => false, "Passwords must match", "confirm"), + TextBlock($"valid:{valCtx.IsValid()}")); + } + } + + public override async Task RunAsync() + { + var probe = new RuleProbe(); + var host = H.CreateHost(); + host.Mount(_ => Component(new RuleOwnerProps(probe))); + + // If the loop were still present this throws "Render loop detected". + await Harness.Render(); + await Harness.Render(); + + var captured = probe.Context; + H.Check("Issue1262_Rule_NoLoopThrown", captured is not null); + if (captured is null) return; + + H.Check("Issue1262_Rule_MessageRecorded", captured.GetMessages("confirm").Count == 1); + H.Check("Issue1262_Rule_NoAccumulation", captured.GetAllMessages().Count == 1); + H.Check("Issue1262_Rule_RenderCountBounded", probe.Renders < 10, $"renders={probe.Renders}"); + + var settledVersion = captured.Version; + var settledRenders = probe.Renders; + await Harness.Render(); + + H.Check("Issue1262_Rule_VersionStableOnReRender", captured.Version == settledVersion, + $"before={settledVersion} after={captured.Version}"); + H.Check("Issue1262_Rule_RendersSettle", probe.Renders - settledRenders <= 2, + $"delta={probe.Renders - settledRenders}"); + + var done = H.CreateHost(); + done.Mount(c => TextBlock("Issue1262 rule done")); + await Harness.Render(); + } + } + + // ════════════════════════════════════════════════════════════════════════ + // Issue #1262 review — the blur binding must not outlive its FormField. + // + // The LostFocus handler is attached once for the control's lifetime and + // TextBox is poolable, so a control that stops being a FormField's content + // must stop reporting to the old context/field. + // ════════════════════════════════════════════════════════════════════════ + + // ════════════════════════════════════════════════════════════════════════ + // Issue #1262 review — a mounted rule must withdraw its verdict when it + // leaves the tree, or a conditionally rendered rule keeps the form invalid + // forever after it disappears. + // ════════════════════════════════════════════════════════════════════════ + + internal class Issue1262_RuleRetractsOnUnmount(Harness h) : SelfTestFixtureBase(h) + { + public override async Task RunAsync() + { + var ctx = new ValidationContext(); + var host = H.CreateHost(); + Action? setShowRule = null; + + host.Mount(c => + { + var (showRule, setShow) = c.UseState(true); + setShowRule = setShow; + + return VStack(12, + When(showRule, () => ValidationRule(() => false, "Rule failed", "form")), + TextBlock("body")) + .Provide(ValidationContexts.Current, ctx); + }); + + await Harness.Render(); + H.Check("Issue1262_RuleUnmount_AppliedWhileMounted", !ctx.IsValid()); + H.Check("Issue1262_RuleUnmount_MessageRecorded", ctx.GetMessages("form").Count == 1); + + // Re-render with the rule still present: the verdict must not accumulate. + await Harness.Render(); + H.Check("Issue1262_RuleUnmount_NoAccumulation", ctx.GetMessages("form").Count == 1); + + setShowRule!(false); + await Harness.Render(); + + H.Check("Issue1262_RuleUnmount_WithdrawnOnUnmount", ctx.GetMessages("form").Count == 0, + $"remaining={string.Join("|", ctx.GetMessages("form").Select(m => m.Text))}"); + H.Check("Issue1262_RuleUnmount_ValidAfterUnmount", ctx.IsValid()); + + var done = H.CreateHost(); + done.Mount(c => TextBlock("Issue1262 rule unmount done")); + await Harness.Render(); + } + } + + internal class Issue1262_TouchBindingClearedWhenContextGoes(Harness h) : SelfTestFixtureBase(h) + { + public override async Task RunAsync() + { + var ctx = new ValidationContext(); + var host = H.CreateHost(); + var (provide, setProvide) = (true, (Action?)null); + + host.Mount(c => + { + var (provided, setProvided) = c.UseState(true); + setProvide = setProvided; + provide = provided; + + var tree = VStack(12, + FormField( + TextBox("binding-probe").Validate("name", "binding-probe", Validate.MinLength(50)), + label: "Full Name", + showWhen: ShowWhen.Always), + Button("Elsewhere", () => { })); + + return provided ? tree.Provide(ValidationContexts.Current, ctx) : tree; + }); + + await Harness.Render(); + // Matched by text: other fixtures leave TextBoxes in the search root, so + // "the first TextBox" is not reliably this one. + var box = H.FindControl(tb => tb.Text == "binding-probe"); + var elsewhere = H.FindButton("Elsewhere"); + H.Check("Issue1262_Binding_ControlsFound", box is not null && elsewhere is not null); + if (box is null || elsewhere is null) return; + + // While the context is reachable, blur marks the field. + box.Focus(Microsoft.UI.Xaml.FocusState.Programmatic); + await Harness.Render(); + elsewhere.Focus(Microsoft.UI.Xaml.FocusState.Programmatic); + await Harness.Render(); + H.Check("Issue1262_Binding_MarksWhileBound", ctx.IsTouched("name")); + + // Drop the provider. The same control is patched in place, so the binding + // has to be cleared rather than left pointing at the old context. + var stillSameControl = ReferenceEquals(box, H.FindControl(tb => tb.Text == "binding-probe")); + setProvide!(false); + await Harness.Render(); + H.Check("Issue1262_Binding_ControlPreserved", + stillSameControl && ReferenceEquals(box, H.FindControl(tb => tb.Text == "binding-probe"))); + + var touchedBefore = ctx.IsTouched("name"); + + // Re-blur now that nothing provides a context. + ctx.Reset("name"); + H.Check("Issue1262_Binding_ResetClearedTouched", !ctx.IsTouched("name")); + box.Focus(Microsoft.UI.Xaml.FocusState.Programmatic); + await Harness.Render(); + elsewhere.Focus(Microsoft.UI.Xaml.FocusState.Programmatic); + await Harness.Render(); + + H.Check("Issue1262_Binding_SilentAfterContextGone", !ctx.IsTouched("name"), + $"touchedBefore={touchedBefore}"); + + var done = H.CreateHost(); + done.Mount(c => TextBlock("Issue1262 binding done")); + await Harness.Render(); + } + } + + // ════════════════════════════════════════════════════════════════════════ + // Issue #1262 review — a rule removed from a *child* component. + // + // Retraction runs during unmount, which sits between renders and so used + // to fall outside every validation frame. When the context is owned by a + // child via UseValidationContext(), announcing that change inline drove + // CreateComponentRerender back into the reconciler while the subtree was + // still being torn down. The root-host fixture above cannot see this: a + // root re-render only schedules. + // ════════════════════════════════════════════════════════════════════════ + + internal sealed record ChildRuleProps(bool ShowRule, Action OnContext, Action OnRender); + + internal sealed class ChildRuleOwner : Component + { + public override Element Render() + { + var props = Props; + var ctx = this.UseValidationContext(); + props.OnContext(ctx); + props.OnRender(); + + return VStack(8, + When(props.ShowRule, () => ValidationRule(() => false, "Rule failed", "form")), + // Structural: the removal's retraction notification re-renders this + // component, and the re-render changes the very subtree the reconciler + // is still walking to unmount the rule. + When(!ctx.IsValid(), () => TextBlock("child-invalid")), + When(ctx.IsValid(), () => VStack(4, TextBlock("child-valid"), TextBlock("ok"))), + TextBlock("tail")); + } + } + + internal class Issue1262_RuleRetractsFromChildComponent(Harness h) : SelfTestFixtureBase(h) + { + public override async Task RunAsync() + { + var host = H.CreateHost(); + Action? setShowRule = null; + ValidationContext? childCtx = null; + var childRenders = 0; + + host.Mount(c => + { + var (showRule, setShow) = c.UseState(true); + setShowRule = setShow; + + return VStack(12, + Component( + new ChildRuleProps(showRule, ctx => childCtx = ctx, () => childRenders++)), + TextBlock("host")); + }); + + await Harness.Render(); + H.Check("Issue1262_ChildRule_ContextResolved", childCtx is not null); + if (childCtx is null) return; + + H.Check("Issue1262_ChildRule_InvalidWhileMounted", !childCtx.IsValid()); + H.Check("Issue1262_ChildRule_MessageRecorded", childCtx.GetMessages("form").Count == 1); + + var rendersBefore = childRenders; + + // The removal itself: unmount retracts, and that retraction is a real + // change, so it notifies the child that owns the context. + setShowRule!(false); + await Harness.Render(); + await Harness.Render(); + + H.Check("Issue1262_ChildRule_RetractedOnRemoval", childCtx.GetMessages("form").Count == 0, + $"remaining={string.Join("|", childCtx.GetMessages("form").Select(m => m.Text))}"); + H.Check("Issue1262_ChildRule_ValidAfterRemoval", childCtx.IsValid()); + + // A handful of renders is the removal plus its notification settling; an + // inline re-entrant notification shows up as a storm. The reconcile-wide + // deferral frame is what keeps this bounded for direct ctx.Changed + // subscribers, which do not get UseState's marshalling. + var delta = childRenders - rendersBefore; + H.Check("Issue1262_ChildRule_NoRenderStorm", delta <= 6, $"childRenders={delta}"); + + var done = H.CreateHost(); + done.Mount(c => TextBlock("Issue1262 child rule done")); + await Harness.Render(); + } + } + + // ════════════════════════════════════════════════════════════════════════ + // Issue #1262 review — a FormField that loses its context *and* swaps its + // content control in one update. + // + // Only the incoming control's binding used to be neutralized; the root + // still pointed at the outgoing editor's live binding, and that editor was + // already on its way to the pool. Rented back for a non-FormField use — the + // one path that never re-points the binding — it kept marking the old field. + // ════════════════════════════════════════════════════════════════════════ + + internal class Issue1262_DisplacedRootBindingCleared(Harness h) : SelfTestFixtureBase(h) + { + public override async Task RunAsync() + { + var ctx = new ValidationContext(); + var host = H.CreateHost(); + Action? setMode = null; + + host.Mount(c => + { + var (mode, set) = c.UseState(0); + setMode = set; + + if (mode == 0) + { + return VStack(12, + FormField( + TextBox("displaced-probe").Validate("name", "displaced-probe", Validate.MinLength(50)), + label: "Full Name", + showWhen: ShowWhen.Always), + Button("Away", () => { })) + .Provide(ValidationContexts.Current, ctx); + } + + if (mode == 1) + { + // Context dropped and the content control swapped in the same pass. + return VStack(12, + FormField( + TextBlock("swapped"), + label: "Full Name", + showWhen: ShowWhen.Always), + Button("Away", () => { })); + } + + // The displaced editor is rented back for a plain, non-FormField use. + return VStack(12, + TextBox("plain-probe"), + Button("Away", () => { })); + }); + + await Harness.Render(); + // Matched by text, not by type: other fixtures in the same run leave + // TextBoxes in the search root, so "the first TextBox" is not reliably this + // one — which made the fixture order-dependent. + var original = H.FindControl(tb => tb.Text == "displaced-probe"); + var away = H.FindButton("Away"); + H.Check("Issue1262_Displaced_ControlsFound", original is not null && away is not null); + if (original is null || away is null) return; + + original.Focus(Microsoft.UI.Xaml.FocusState.Programmatic); + await Harness.Render(); + away.Focus(Microsoft.UI.Xaml.FocusState.Programmatic); + await Harness.Render(); + H.Check("Issue1262_Displaced_MarksWhileBound", ctx.IsTouched("name")); + + ctx.Reset("name"); + H.Check("Issue1262_Displaced_ResetClearedTouched", !ctx.IsTouched("name")); + + setMode!(1); + // Wait for the swap to be observable rather than assuming one render is + // enough — under load a single pass may not have applied the state change, + // and asserting then reads the *old* tree. + for (var i = 0; i < 8 && H.FindControl(tb => tb.Text == "displaced-probe") is not null; i++) + await Harness.Render(); + + H.Check("Issue1262_Displaced_EditorLeftTree", + H.FindControl(tb => tb.Text == "displaced-probe") is null, + "the content swap never took effect, so the check below would be vacuous"); + + // The editor is out of the tree and on its way to the pool. Its binding + // must be neutralized, or a later non-FormField use of the same control + // keeps marking this field — and keeps this context alive. + H.Check("Issue1262_Displaced_BindingNeutralized", + !global::Microsoft.UI.Reactor.Core.V1Protocol.CompositeLifecycle + .HasLiveTouchBindingForTests(original), + "the displaced editor still carries a live blur binding"); + + setMode!(2); + for (var i = 0; i < 8 && H.FindControl(tb => tb.Text == "plain-probe") is null; i++) + await Harness.Render(); + + var rented = H.FindControl(tb => tb.Text == "plain-probe"); + if (rented is null) { H.Check("Issue1262_Displaced_PlainBoxRendered", false); return; } + + // Whether or not the pool handed back the same instance, nothing in the + // plain, non-FormField slot may report to the old context. + var awayAgain = H.FindButton("Away"); + if (awayAgain is null) { H.Check("Issue1262_Displaced_AwayFound", false); return; } + + rented.Focus(Microsoft.UI.Xaml.FocusState.Programmatic); + await Harness.Render(); + awayAgain.Focus(Microsoft.UI.Xaml.FocusState.Programmatic); + await Harness.Render(); + + H.Check("Issue1262_Displaced_SilentAfterDisplacement", !ctx.IsTouched("name"), + $"reused={ReferenceEquals(original, rented)}"); + + var done = H.CreateHost(); + done.Mount(c => TextBlock("Issue1262 displaced binding done")); + await Harness.Render(); + } + } + + // ════════════════════════════════════════════════════════════════════════ + // Issue #1262 review — an async-only field on a cached element. + // + // .ValidateAsync(field, value, …) registers the field through the render + // scope, which a cached element never passes through. FormField gated its + // registration on sync validators being present, so an async-only field + // existed nowhere and MarkAllTouched()/IsValid() skipped it entirely. + // ════════════════════════════════════════════════════════════════════════ + + internal class Issue1262_AsyncOnlyFieldOnCachedElement(Harness h) : SelfTestFixtureBase(h) + { + public override async Task RunAsync() + { + var ctx = new ValidationContext(); + var host = H.CreateHost(); + + // Built here, outside any render pass — the case the render scope misses. + var cached = FormField( + TextBox("").ValidateAsync("email", "", + Validate.MustAsync(_ => Task.FromResult(true), "Already registered")), + label: "Email", + showWhen: ShowWhen.Always); + + H.Check("Issue1262_AsyncOnly_UnregisteredBeforeMount", !ctx.RegisteredFields.Contains("email")); + + host.Mount(c => VStack(12, cached).Provide(ValidationContexts.Current, ctx)); + await Harness.Render(); + + H.Check("Issue1262_AsyncOnly_RegisteredOnMount", ctx.RegisteredFields.Contains("email"), + $"registered={string.Join("|", ctx.RegisteredFields)}"); + + ctx.MarkAllTouched(); + H.Check("Issue1262_AsyncOnly_CoveredByMarkAllTouched", ctx.IsTouched("email")); + + // And the update path keeps it registered after a reset. + ctx.ResetAll(); + await Harness.Render(); + H.Check("Issue1262_AsyncOnly_StillRegisteredAfterUpdate", ctx.RegisteredFields.Contains("email")); + + var done = H.CreateHost(); + done.Mount(c => TextBlock("Issue1262 async-only field done")); + await Harness.Render(); + } + } + + // ════════════════════════════════════════════════════════════════════════ + // Issue #1262 review — two value overloads chained on one element. + // + // Each call eagerly applies its own intermediate validator set under the + // same producer, so every pass strips the later message and puts it back. + // The net state never moves, but each write was a real change, so the + // frame announced one — repainting a component that churns identically. + // In a child component that re-renders inline, that is an endless loop. + // ════════════════════════════════════════════════════════════════════════ + + internal sealed record ChainedValidateProps(Action OnRender); + + internal sealed class ChainedValidateOwner : Component + { + public override Element Render() + { + Props.OnRender(); + var ctx = this.UseValidationContext(); + + return VStack(8, + TextBox("abc") + .Validate("email", "abc", Validate.Email("Not an email")) + .Validate("email", "abc", Validate.MinLength(10, "Too short")), + TextBlock(ctx.HasError("email") ? "invalid" : "valid")); + } + } + + internal class Issue1262_ChainedValueOverloadsDoNotLoop(Harness h) : SelfTestFixtureBase(h) + { + public override async Task RunAsync() + { + var host = H.CreateHost(); + var renders = 0; + + host.Mount(c => VStack(12, + Component( + new ChainedValidateProps(() => renders++)), + TextBlock("host"))); + + await Harness.Render(); + var afterFirst = renders; + + // Let any scheduled repaints drain. A churning chain never stops asking. + for (var i = 0; i < 6; i++) await Harness.Render(); + + H.Check("Issue1262_Chain_BothValidatorsApplied", afterFirst > 0); + H.Check("Issue1262_Chain_RendersSettle", renders - afterFirst <= 8, + $"extraRenders={renders - afterFirst}"); + + var done = H.CreateHost(); + done.Mount(c => TextBlock("Issue1262 chained validate done")); + await Harness.Render(); + } + } + + // ════════════════════════════════════════════════════════════════════════ + // Issue #1262 review — a mounted async rule must actually run. + // + // ValidationRuleAsync builds an element whose synchronous predicate is a + // constant true, and both lifecycle paths called Evaluate — so placing an + // async rule in the tree recorded a passing verdict and never invoked the + // predicate at all. + // ════════════════════════════════════════════════════════════════════════ + + internal class Issue1262_MountedAsyncRuleRuns(Harness h) : SelfTestFixtureBase(h) + { + public override async Task RunAsync() + { + var ctx = new ValidationContext(); + var host = H.CreateHost(); + var invocations = 0; + Action? setShowRule = null; + + host.Mount(c => + { + var (showRule, setShow) = c.UseState(true); + setShowRule = setShow; + + return VStack(12, + When(showRule, () => ValidationRuleAsync( + () => + { + invocations++; + return Task.FromResult(false); + }, + "Name is already taken", + "name")), + TextBlock("body")) + .Provide(ValidationContexts.Current, ctx); + }); + + await Harness.Render(); + for (var i = 0; i < 4 && ctx.GetMessages("name").Count == 0; i++) + await Harness.Render(); + + H.Check("Issue1262_AsyncRule_PredicateInvoked", invocations > 0, $"invocations={invocations}"); + H.Check("Issue1262_AsyncRule_VerdictInstalled", ctx.GetMessages("name").Count == 1, + $"messages={ctx.GetMessages("name").Count}"); + H.Check("Issue1262_AsyncRule_Invalid", !ctx.IsValid()); + + // Re-rendering must not accumulate: the producer slot is replaced, not appended. + await Harness.Render(); + await Harness.Render(); + H.Check("Issue1262_AsyncRule_NoAccumulation", ctx.GetMessages("name").Count == 1, + $"messages={ctx.GetMessages("name").Count}"); + + // And removal retracts it, cancelling any pass still in flight. + setShowRule!(false); + await Harness.Render(); + await Harness.Render(); + H.Check("Issue1262_AsyncRule_RetractedOnRemoval", ctx.GetMessages("name").Count == 0, + $"remaining={string.Join("|", ctx.GetMessages("name").Select(m => m.Text))}"); + + var done = H.CreateHost(); + done.Mount(c => TextBlock("Issue1262 async rule done")); + await Harness.Render(); + } + } + + // ════════════════════════════════════════════════════════════════════════ + // Issue #1262 review — an async rule whose provider disappears mid-flight. + // + // Update withdraws the rule's contribution from the old context, but a + // pass already running would resolve afterwards and reinstall the error + // into a context the rule no longer belongs to. + // ════════════════════════════════════════════════════════════════════════ + + internal class Issue1262_AsyncRuleProviderRemovedMidFlight(Harness h) : SelfTestFixtureBase(h) + { + public override async Task RunAsync() + { + var ctx = new ValidationContext(); + var gate = new TaskCompletionSource(); + var host = H.CreateHost(); + Action? setProvide = null; + + host.Mount(c => + { + var (provided, set) = c.UseState(true); + setProvide = set; + + var tree = VStack(12, + ValidationRuleAsync(() => gate.Task, "Name is already taken", "name"), + TextBlock("body")); + + return provided ? tree.Provide(ValidationContexts.Current, ctx) : tree; + }); + + await Harness.Render(); + H.Check("Issue1262_AsyncProvider_QuietWhilePending", ctx.GetMessages("name").Count == 0); + + // The provider goes away while the check is still out. + setProvide!(false); + await Harness.Render(); + + gate.SetResult(false); + await Harness.Render(); + await Harness.Render(); + + H.Check("Issue1262_AsyncProvider_NoVerdictAfterRemoval", ctx.GetMessages("name").Count == 0, + $"remaining={string.Join("|", ctx.GetMessages("name").Select(m => m.Text))}"); + H.Check("Issue1262_AsyncProvider_ContextStillValid", ctx.IsValid()); + + var done = H.CreateHost(); + done.Mount(c => TextBlock("Issue1262 async provider done")); + await Harness.Render(); + } + } + + // ════════════════════════════════════════════════════════════════════════ + // Issue #1262 review — a mounted rule that stops being async. + // + // The placeholder, and therefore the producer identity, survives the swap. + // Leaving the old async generation entry behind made the next value change + // treat that producer as async and retract a synchronous verdict that was + // still current. + // ════════════════════════════════════════════════════════════════════════ + + internal class Issue1262_AsyncRuleBecomesSync(Harness h) : SelfTestFixtureBase(h) + { + public override async Task RunAsync() + { + var ctx = new ValidationContext(); + var host = H.CreateHost(); + Action? setUseAsync = null; + + host.Mount(c => + { + var (useAsync, set) = c.UseState(true); + setUseAsync = set; + + return VStack(12, + useAsync + ? ValidationRuleAsync(() => Task.FromResult(false), "Rule failed", "name") + : ValidationRule(() => false, "Rule failed", "name"), + TextBlock("body")) + .Provide(ValidationContexts.Current, ctx); + }); + + await Harness.Render(); + for (var i = 0; i < 4 && ctx.GetMessages("name").Count == 0; i++) + await Harness.Render(); + H.Check("Issue1262_RuleSwap_AsyncVerdictInstalled", ctx.GetMessages("name").Count == 1, + $"messages={ctx.GetMessages("name").Count}"); + + // Same placeholder, now a synchronous rule. + setUseAsync!(false); + await Harness.Render(); + await Harness.Render(); + H.Check("Issue1262_RuleSwap_SyncVerdictInstalled", ctx.GetMessages("name").Count == 1, + $"messages={ctx.GetMessages("name").Count}"); + + // A value change retires async producers. The swapped rule is no longer one, + // so its verdict has to survive — checked before any re-render could + // reinstall it. + ctx.NotifyValueChanged("name", "anything"); + H.Check("Issue1262_RuleSwap_SyncVerdictSurvivesValueChange", + ctx.GetMessages("name").Count == 1, + $"messages={ctx.GetMessages("name").Count}"); + + var done = H.CreateHost(); + done.Mount(c => TextBlock("Issue1262 rule swap done")); + await Harness.Render(); + } + } + + // ════════════════════════════════════════════════════════════════════════ + // Issue #1262 review — a validator-only attachment inside a FormField. + // + // .Validate(field, validators…) supplies no value, and null is a legitimate + // value, so FormField validated null and reported "required" for a control + // that plainly had text in it. + // ════════════════════════════════════════════════════════════════════════ + + internal class Issue1262_ValidatorOnlyAttachmentInFormField(Harness h) : SelfTestFixtureBase(h) + { + public override async Task RunAsync() + { + var ctx = new ValidationContext(); + var host = H.CreateHost(); + + host.Mount(c => VStack(12, + FormField( + TextBox("Alice").Validate("name", Validate.Required("Name is required")), + label: "Full Name", + showWhen: ShowWhen.Always)) + .Provide(ValidationContexts.Current, ctx)); + + await Harness.Render(); + + H.Check("Issue1262_ValidatorOnly_NoSpuriousError", ctx.GetMessages("name").Count == 0, + $"messages={string.Join("|", ctx.GetMessages("name").Select(m => m.Text))}"); + H.Check("Issue1262_ValidatorOnly_NoErrorRendered", H.FindText("Name is required") is null); + + // Still registered, so a submit-time MarkAllTouched() covers the field. + H.Check("Issue1262_ValidatorOnly_FieldRegistered", ctx.RegisteredFields.Contains("name"), + $"registered={string.Join("|", ctx.RegisteredFields)}"); + + var done = H.CreateHost(); + done.Mount(c => TextBlock("Issue1262 validator-only done")); + await Harness.Render(); + } + } + + // ════════════════════════════════════════════════════════════════════════ + // Issue #1262 review — a control retired outside the normal unmount path. + // + // DetachReactorState is reached when a control is discarded without the + // FormField/ValidationRule unmount callback running. The blur binding is + // captured by a once-per-lifetime LostFocus handler that survives detach, so + // a binding left live would keep marking the old field — and keep its + // ValidationContext alive. + // ════════════════════════════════════════════════════════════════════════ + + internal class Issue1262_DetachClearsValidationBindings(Harness h) : SelfTestFixtureBase(h) + { + public override async Task RunAsync() + { + var ctx = new ValidationContext(); + var host = H.CreateHost(); + + host.Mount(c => VStack(12, + FormField( + TextBox("detach-probe").Validate("name", "detach-probe", Validate.MinLength(50)), + label: "Full Name", + showWhen: ShowWhen.Always), + Button("Away", () => { })) + .Provide(ValidationContexts.Current, ctx)); + + await Harness.Render(); + var box = H.FindControl(tb => tb.Text == "detach-probe"); + var away = H.FindButton("Away"); + H.Check("Issue1262_Detach_ControlsFound", box is not null && away is not null); + if (box is null || away is null) return; + + box.Focus(Microsoft.UI.Xaml.FocusState.Programmatic); + await Harness.Render(); + away.Focus(Microsoft.UI.Xaml.FocusState.Programmatic); + await Harness.Render(); + H.Check("Issue1262_Detach_MarksWhileBound", ctx.IsTouched("name")); + + ctx.Reset("name"); + H.Check("Issue1262_Detach_ResetClearedTouched", !ctx.IsTouched("name")); + + // Retire the control directly, bypassing the FormField unmount path. + global::Microsoft.UI.Reactor.Core.Reconciler.DetachReactorState(box); + + H.Check("Issue1262_Detach_BindingNeutralized", + !global::Microsoft.UI.Reactor.Core.V1Protocol.CompositeLifecycle + .HasLiveTouchBindingForTests(box), + "the retired editor still carries a live blur binding"); + + // And the once-per-lifetime handler is now inert. + box.Focus(Microsoft.UI.Xaml.FocusState.Programmatic); + await Harness.Render(); + away.Focus(Microsoft.UI.Xaml.FocusState.Programmatic); + await Harness.Render(); + H.Check("Issue1262_Detach_SilentAfterDetach", !ctx.IsTouched("name")); + + var done = H.CreateHost(); + done.Mount(c => TextBlock("Issue1262 detach done")); + await Harness.Render(); + } + } + + // ════════════════════════════════════════════════════════════════════════ + // Issue #1262 review — a mounted rule's placeholder retired outside unmount. + // + // Detach has to withdraw the rule's verdict as well as cancel its pass, or + // the error outlives the control that produced it and nothing will ever + // re-evaluate it away. + // ════════════════════════════════════════════════════════════════════════ + + internal class Issue1262_DetachRetiresRuleVerdict(Harness h) : SelfTestFixtureBase(h) + { + public override async Task RunAsync() + { + var ctx = new ValidationContext(); + var host = H.CreateHost(); + + host.Mount(c => VStack(12, + ValidationRule(() => false, "Rule failed", "form"), + TextBlock("body")) + .Provide(ValidationContexts.Current, ctx)); + + await Harness.Render(); + H.Check("Issue1262_DetachRule_VerdictInstalled", ctx.GetMessages("form").Count == 1, + $"messages={ctx.GetMessages("form").Count}"); + + // The rule's collapsed placeholder is the first child of the panel. + var placeholder = H.FindControl( + p => p.Visibility == Microsoft.UI.Xaml.Visibility.Collapsed); + H.Check("Issue1262_DetachRule_PlaceholderFound", placeholder is not null); + if (placeholder is null) return; + + global::Microsoft.UI.Reactor.Core.Reconciler.DetachReactorState(placeholder); + + H.Check("Issue1262_DetachRule_VerdictWithdrawn", ctx.GetMessages("form").Count == 0, + $"remaining={string.Join("|", ctx.GetMessages("form").Select(m => m.Text))}"); + H.Check("Issue1262_DetachRule_ValidAfterDetach", ctx.IsValid()); + + var done = H.CreateHost(); + done.Mount(c => TextBlock("Issue1262 detach rule done")); + await Harness.Render(); + } + } + + // ════════════════════════════════════════════════════════════════════════ + // Issue #1262 review — a context mutated from a worker thread. + // + // UseValidationContext re-renders through the *default* (marshalling) + // UseState setter rather than threadSafe: threadSafe would invoke the + // re-render callback on whatever thread raised Changed, and an async + // validator raises it from a worker — entering the reconciler off the UI + // thread. + // + // What this fixture does and does not establish. It does establish that a + // worker-thread mutation neither throws nor renders on the worker, and + // that the repaint it causes arrives on the UI thread. It does NOT make + // the threadSafe choice falsifiable: flipping that setter leaves every + // check here green, so the marshalling that saves us lives somewhere + // below the setter and this fixture cannot attribute it. An earlier + // version of this comment claimed otherwise. + // ════════════════════════════════════════════════════════════════════════ + + internal sealed record OffThreadProps(Action OnContext, Action OnRender); + + internal sealed class OffThreadValidationOwner : Component + { + public override Element Render() + { + var props = Props; + var ctx = this.UseValidationContext(); + props.OnContext(ctx); + props.OnRender(global::System.Environment.CurrentManagedThreadId); + + return VStack(8, + When(ctx.HasError("email"), () => TextBlock("Email is taken")), + TextBlock("body")); + } + } + + internal class Issue1262_OffThreadContextMutationMarshals(Harness h) : SelfTestFixtureBase(h) + { + public override async Task RunAsync() + { + var host = H.CreateHost(); + var uiThreadId = global::System.Environment.CurrentManagedThreadId; + // Appended from the render (UI thread) and read from the worker, so a plain + // List races even when nothing renders off-thread. + var renderThreads = new global::System.Collections.Concurrent.ConcurrentQueue(); + ValidationContext? captured = null; + + host.Mount(c => VStack(12, + Component( + new OffThreadProps(ctx => captured = ctx, renderThreads.Enqueue)), + TextBlock("host"))); + + await Harness.Render(); + H.Check("Issue1262_OffThread_ContextResolved", captured is not null); + if (captured is null) return; + + H.Check("Issue1262_OffThread_NoErrorInitially", H.FindText("Email is taken") is null); + var rendersBefore = renderThreads.Count; + + // Exactly what a background async validator does when it resolves. + global::System.Exception? thrown = null; + var workerThreadId = -1; + await Task.Run(() => + { + workerThreadId = global::System.Environment.CurrentManagedThreadId; + try { captured.Add("email", "Email is taken"); } + catch (global::System.Exception ex) + when (ex is not global::System.OutOfMemoryException + and not global::System.StackOverflowException) + { + // Deliberately broad: the assertion below is "any failure at all", + // since the defect this guards against is an off-thread reconcile + // surfacing as a COM or invalid-operation exception. + thrown = ex; + } + }); + + H.Check("Issue1262_OffThread_MutationDidNotThrow", thrown is null, + thrown is null ? "" : $"{thrown.GetType().Name}: {thrown.Message}"); + + // The load-bearing assertion: the mutation must not have driven a render on + // the worker. A threadSafe state setter invokes the re-render callback on + // whatever thread raised Changed, which for a child component renders inline. + // + // Attributed by thread id, not by counting renders. Comparing a count taken + // on the worker against one taken before it asks "did the counter move", + // which a *marshalled* render landing on the UI thread in that same window + // also satisfies — so the check reddened under AOT while every render was in + // fact on the UI thread, as its sibling below confirmed. The healthy and + // broken branches were separated only by timing, which is no separation. + var workerRenders = renderThreads.Where(id => id == workerThreadId).ToList(); + H.Check("Issue1262_OffThread_NoSynchronousRenderFromWorker", + workerThreadId != -1 && workerRenders.Count == 0, + $"worker={workerThreadId} rendersOnWorker={workerRenders.Count}"); + + // Positive control for the detector above. A zero from a working filter and + // a zero from one that can never match read identically, and this one cannot + // be falsified by mutating the product: flipping UseValidationContext's + // setter to threadSafe: true — the very thing this fixture exists to justify + // — leaves both checks green, so the re-render must be marshalled somewhere + // further down than the setter. Rather than claim coverage the mutation does + // not support, prove the instrument instead: the same filter, over a queue + // that deliberately holds a worker-thread entry, has to find it. + var detectorProbe = new global::System.Collections.Concurrent.ConcurrentQueue(); + detectorProbe.Enqueue(workerThreadId); + H.Check("Issue1262_OffThread_DetectorCanMatchAWorkerRender", + detectorProbe.Any(id => id == workerThreadId), + $"worker={workerThreadId} probe={string.Join("|", detectorProbe)}"); + + for (var i = 0; i < 6 && H.FindText("Email is taken") is null; i++) + await Harness.Render(); + + H.Check("Issue1262_OffThread_Repainted", H.FindText("Email is taken") is not null); + H.Check("Issue1262_OffThread_RenderedAgain", renderThreads.Count > rendersBefore, + $"renders={renderThreads.Count - rendersBefore}"); + + // The point: every render ran on the UI thread, including the one the + // worker-thread mutation caused. + var offThread = renderThreads.Where(id => id != uiThreadId).ToList(); + H.Check("Issue1262_OffThread_AllRendersOnUiThread", offThread.Count == 0, + $"ui={uiThreadId} offThread={string.Join("|", offThread)}"); + + var done = H.CreateHost(); + done.Mount(c => TextBlock("Issue1262 off-thread done")); + await Harness.Render(); + } + } + + // ════════════════════════════════════════════════════════════════════════ + // Issue #1262 review — a FormField whose field name moves between passes. + // + // The verdict is installed under the field's own sync producer, so without + // withdrawing the old one its messages stay owned by a field nothing + // validates any more — keeping the form invalid forever. + // ════════════════════════════════════════════════════════════════════════ + + internal class Issue1262_FormFieldNameMigration(Harness h) : SelfTestFixtureBase(h) + { + public override async Task RunAsync() + { + var ctx = new ValidationContext(); + var host = H.CreateHost(); + Action? setUsePhone = null; + + host.Mount(c => + { + var (usePhone, set) = c.UseState(false); + setUsePhone = set; + var field = usePhone ? "phone" : "email"; + + return VStack(12, + FormField( + TextBox("").Validate(field, "", Validate.Required($"{field} is required")), + label: "Contact", + showWhen: ShowWhen.Always)) + .Provide(ValidationContexts.Current, ctx); + }); + + await Harness.Render(); + H.Check("Issue1262_FieldMove_InitialError", ctx.GetMessages("email").Count == 1, + $"email={ctx.GetMessages("email").Count}"); + + setUsePhone!(true); + await Harness.Render(); + await Harness.Render(); + + H.Check("Issue1262_FieldMove_NewFieldValidated", ctx.GetMessages("phone").Count == 1, + $"phone={ctx.GetMessages("phone").Count}"); + H.Check("Issue1262_FieldMove_OldFieldWithdrawn", ctx.GetMessages("email").Count == 0, + $"remaining={string.Join("|", ctx.GetMessages("email").Select(m => m.Text))}"); + + var done = H.CreateHost(); + done.Mount(c => TextBlock("Issue1262 field move done")); + await Harness.Render(); + } + } + + // ════════════════════════════════════════════════════════════════════════ + // Issue #1262 review — validators or provider disappearing with the field + // name unchanged. + // + // A moving field name is only one of the ways the sync contribution can + // stop applying: the validators can go away, or the provider can change, + // and either leaves the old verdict owned by nothing. + // ════════════════════════════════════════════════════════════════════════ + + internal class Issue1262_AttachedValidationWithdrawnWhenItStops(Harness h) : SelfTestFixtureBase(h) + { + public override async Task RunAsync() + { + var ctx = new ValidationContext(); + var other = new ValidationContext(); + var host = H.CreateHost(); + Action? setMode = null; + + host.Mount(c => + { + var (mode, set) = c.UseState(0); + setMode = set; + + // mode 0: validated. mode 1: same field, validators gone. + // mode 2: validated again, but under a different provider. + var content = mode == 1 + ? TextBox("") + : TextBox("").Validate("email", "", Validate.Required("Email is required")); + + var tree = VStack(12, + FormField(content, label: "Email", showWhen: ShowWhen.Always)); + + return tree.Provide(ValidationContexts.Current, mode == 2 ? other : ctx); + }); + + await Harness.Render(); + H.Check("Issue1262_Stops_InitialError", ctx.GetMessages("email").Count == 1, + $"email={ctx.GetMessages("email").Count}"); + + // The validators disappear while the field name stays the same. + setMode!(1); + await Harness.Render(); + await Harness.Render(); + H.Check("Issue1262_Stops_WithdrawnWhenValidatorsGo", ctx.GetMessages("email").Count == 0, + $"remaining={string.Join("|", ctx.GetMessages("email").Select(m => m.Text))}"); + + // Validated again, but against a different context. + setMode!(2); + await Harness.Render(); + await Harness.Render(); + H.Check("Issue1262_Stops_NewContextValidated", other.GetMessages("email").Count == 1, + $"other={other.GetMessages("email").Count}"); + H.Check("Issue1262_Stops_OldContextStillEmpty", ctx.GetMessages("email").Count == 0, + $"remaining={string.Join("|", ctx.GetMessages("email").Select(m => m.Text))}"); + + var done = H.CreateHost(); + done.Mount(c => TextBlock("Issue1262 attached stop done")); + await Harness.Render(); + } + } + + + // ════════════════════════════════════════════════════════════════════════ + // Issue #1262 review — a validated control leaving the tree, and chained + // links that move. + // + // `.Validate(field, value, …)` installs its verdict while the owning + // component renders, and nothing watched what became of it: a control + // behind a condition installed an error on the pass that showed it and + // then simply stopped being rendered, leaving the context invalid over a + // field with no control. The same held for a whole FormField, and for a + // removed child, which leaves through the pooling traversal rather than + // the ordinary unmount. + // + // Two guards ride along. Replacing a control installs the incoming + // verdict *before* the outgoing control is unmounted, so an unconditional + // retraction on the way out would erase the verdict that replaced it. And + // every link of a chain evaluates eagerly, so a link that moves the + // attachment to another field has to take the earlier link's verdict with + // it — while a link that only changes the value must not turn a net-zero + // pass into a repaint. + // ════════════════════════════════════════════════════════════════════════ + + internal enum BareValidateShape + { + /// Validated control present. + Present, + /// Replaced by an unvalidated element of another type. + Hidden, + /// Removed from the children entirely — the pooling teardown path. + Removed, + /// Same field, different control type: the replacement guard. + Replaced, + /// Validated element built and then dropped without being rendered. + Discarded, + /// + /// Validated control rendered, plus a second validated element naming the *same* + /// field built and dropped afterwards — the shared-slot collision. + /// + DiscardedSameField, + } + + internal sealed record BareValidateProps( + BareValidateShape Shape, int Nudge, Action OnContext); + + internal sealed class BareValidateOwner : Component + { + public override Element Render() + { + var props = Props; + var ctx = this.UseValidationContext(); + props.OnContext(ctx); + + // No FormField anywhere: the verdict exists only because `.Validate()` + // ran inside this component's render scope. Built inside the branch that + // uses it — hoisting it would re-publish the verdict on the very passes + // that are supposed to have stopped producing one. + // + // The validated control is the LAST child so that dropping it shortens the + // collection: the child reconciler then *removes* it, which tears down + // through the pooling traversal rather than the ordinary unmount. + return props.Shape switch + { + BareValidateShape.Present => VStack(8, + TextBlock("bare-head"), + TextBox("").Validate("email", "", Validate.Required("Email is required"))), + BareValidateShape.Hidden => VStack(8, TextBlock("bare-head"), TextBlock("hidden")), + BareValidateShape.Removed => VStack(8, TextBlock("bare-head")), + BareValidateShape.Discarded => DiscardedTree(), + BareValidateShape.DiscardedSameField => DiscardedSameFieldTree(), + _ => VStack(8, + TextBlock("bare-head"), + PasswordBox("").Validate("email", "", Validate.Required("Email is required"))), + }; + } + + /// + /// Builds a validated element and then drops it. `.Validate()` has already + /// written its verdict by the time the element is discarded, so nothing will + /// ever be mounted to own it. + /// + private static Element DiscardedTree() + { + _ = TextBox("").Validate("ghost", "", Validate.Required("ghost is required")); + return VStack(8, TextBlock("bare-head")); + } + + /// + /// Renders a validated control and then builds and drops a second validated + /// element naming the same field. Both write the one shared sync slot, and the + /// dropped element holds the newer stamp — so retiring its unconsumed claim + /// would clear the slot the mounted control depends on. + /// + private static Element DiscardedSameFieldTree() + { + var mounted = TextBox("").Validate("email", "", Validate.Required("Email is required")); + _ = TextBox("").Validate("email", "", Validate.Required("Email is required")); + return VStack(8, TextBlock("bare-head"), mounted); + } + } + + internal sealed record ChainProps(bool Moved, Action OnContext, Action OnRender); + + internal sealed class ChainOwner : Component + { + public override Element Render() + { + var props = Props; + var ctx = this.UseValidationContext(); + props.OnContext(ctx); + props.OnRender(); + + // Both links evaluate eagerly. The second decides which field the + // attachment ends up naming, so the first link's write has to follow it. + var el = props.Moved + ? TextBox("") + .Validate("first", "", Validate.Required("first is required")) + .Validate("second", "", Validate.Required("second is required")) + // Same field, different values on each link: a net-zero churn that + // must not announce a change, or the repaint never settles. The two + // values disagree on the verdict, so the final messages say which + // link's value the field settled on. + : TextBox("") + .Validate("first", "", Validate.Required("first is required")) + .Validate("first", "bb", Validate.MinLength(3, "first is too short")); + + return VStack(8, el, TextBlock("chain-tail")); + } + } + + internal class Issue1262_ValidatedControlUnmountWithdraws(Harness h) : SelfTestFixtureBase(h) + { + public override async Task RunAsync() + { + await BareAsync(BareValidateShape.Hidden, "Hidden", nudge: false); + await BareAsync(BareValidateShape.Removed, "Removed", nudge: false); + await BareAsync(BareValidateShape.Hidden, "Skipped", nudge: true); + await RootHostAsync(); + await DiscardedElementAsync(); + await DiscardedSameFieldAsync(); + await SyncThenAsyncChainAsync(); + await AbortedRootRenderAsync(); + await BareReplacedAsync(); + await WholeFormFieldAsync(); + await ChainMovesFieldAsync(); + await ChainValueChurnSettlesAsync(); + + var done = H.CreateHost(); + done.Mount(c => TextBlock("Issue1262 unmount withdraw done")); + await Harness.Render(); + } + + // The host's own root render, with no child component in between. Its render + // frame closes before the reconcile frame opens, so the claim has to survive + // the gap between them to reach the control that inherits it. + private async Task RootHostAsync() + { + var host = H.CreateHost(); + Action? setShow = null; + ValidationContext? ctx = null; + + host.Mount(c => + { + var (show, set) = c.UseState(true); + setShow = set; + ctx = c.UseValidationContext(); + + return VStack(12, + TextBlock("root-head"), + show + ? TextBox("").Validate("email", "", Validate.Required("Email is required")) + : TextBlock("hidden")); + }); + + await Harness.Render(); + H.Check("Issue1262_Unmount_RootResolved", ctx is not null); + if (ctx is null) return; + + H.Check("Issue1262_Unmount_RootInitialError", ctx.GetMessages("email").Count == 1, + $"email={ctx.GetMessages("email").Count}"); + + setShow!(false); + await Harness.Render(); + await Harness.Render(); + for (var i = 0; i < 20 && ctx.GetMessages("email").Count > 0; i++) + { + await global::System.Threading.Tasks.Task.Delay(25); + await Harness.Render(); + } + + H.Check("Issue1262_Unmount_RootWithdrawn", ctx.GetMessages("email").Count == 0, + $"remaining={string.Join("|", ctx.GetMessages("email").Select(m => m.Text))}"); + H.Check("Issue1262_Unmount_RootValid", ctx.IsValid(), $"valid={ctx.IsValid()}"); + } + + // A bare `.Validate()` — no FormField anywhere — that stops being rendered, + // either replaced by another element or removed from the children outright. + // The two leave through different teardown paths. + // + // `nudge` adds a re-render that changes nothing about the validated element. + // Its verdict is republished under a fresh claim, but the element is + // structurally identical, so the update is shallow-skipped — and a control + // whose binding still names the previous pass's write can no longer withdraw. + private async Task BareAsync(BareValidateShape gone, string label, bool nudge) + { + var host = H.CreateHost(); + Action? setShape = null; + Action? setNudge = null; + ValidationContext? ctx = null; + + host.Mount(c => + { + var (shape, set) = c.UseState(BareValidateShape.Present); + var (nudgeCount, setN) = c.UseState(0); + setShape = set; + setNudge = setN; + + return VStack(12, + Component( + new BareValidateProps(shape, nudgeCount, found => ctx = found)), + TextBlock("host")); + }); + + await Harness.Render(); + H.Check($"Issue1262_Unmount_Bare{label}Resolved", ctx is not null); + if (ctx is null) return; + + H.Check($"Issue1262_Unmount_Bare{label}InitialError", ctx.GetMessages("email").Count == 1, + $"email={ctx.GetMessages("email").Count}"); + + if (nudge) + { + setNudge!(1); + await Harness.Render(); + await Harness.Render(); + H.Check($"Issue1262_Unmount_Bare{label}StillInvalid", ctx.GetMessages("email").Count == 1, + $"email={ctx.GetMessages("email").Count}"); + } + + setShape!(gone); + await Harness.Render(); + await Harness.Render(); + + // A removal can be deferred behind an exit transition, so the teardown that + // withdraws runs on a later turn — and on a timer, not on a render — than + // the pass that requested it. + for (var i = 0; i < 20 && ctx.GetMessages("email").Count > 0; i++) + { + await global::System.Threading.Tasks.Task.Delay(25); + await Harness.Render(); + } + + H.Check($"Issue1262_Unmount_Bare{label}Withdrawn", ctx.GetMessages("email").Count == 0, + $"remaining={string.Join("|", ctx.GetMessages("email").Select(m => m.Text))}"); + H.Check($"Issue1262_Unmount_Bare{label}Valid", ctx.IsValid(), $"valid={ctx.IsValid()}"); + } + + // A validated element that is built and then dropped writes its verdict and + // leaves nothing behind to own it. The claim it made is the only record that + // the write happened, so the pass retires whatever it did not hand to a + // control. + private async Task DiscardedElementAsync() + { + var host = H.CreateHost(); + ValidationContext? ctx = null; + + host.Mount(c => VStack(12, + Component( + new BareValidateProps(BareValidateShape.Discarded, 0, found => ctx = found)), + TextBlock("host"))); + + await Harness.Render(); + await Harness.Render(); + H.Check("Issue1262_Unmount_DiscardedResolved", ctx is not null); + if (ctx is null) return; + + H.Check("Issue1262_Unmount_DiscardedWithdrawn", ctx.GetMessages("ghost").Count == 0, + $"remaining={string.Join("|", ctx.GetMessages("ghost").Select(m => m.Text))}"); + H.Check("Issue1262_Unmount_DiscardedValid", ctx.IsValid(), $"valid={ctx.IsValid()}"); + } + + // A chain that ends on an async link. The async validators are attach-only, + // but the sync verdict the earlier link installed is live — and the surviving + // attachment is the only thing a mounted control can claim ownership through. + private async Task SyncThenAsyncChainAsync() + { + var host = H.CreateHost(); + Action? setShow = null; + ValidationContext? ctx = null; + + host.Mount(c => + { + var (show, set) = c.UseState(true); + setShow = set; + ctx = c.UseValidationContext(); + + return VStack(12, + TextBlock("chain-head"), + show + ? TextBox("") + .Validate("mixed", "", Validate.Required("mixed is required")) + .ValidateAsync("mixed", Validate.MustAsync( + async s => { await Task.Yield(); return true; }, "taken")) + : TextBlock("hidden")); + }); + + await Harness.Render(); + H.Check("Issue1262_SyncAsync_Resolved", ctx is not null); + if (ctx is null) return; + + H.Check("Issue1262_SyncAsync_InitialError", ctx.GetMessages("mixed").Count == 1, + $"mixed={string.Join("|", ctx.GetMessages("mixed").Select(m => m.Text))}"); + + setShow!(false); + await Harness.Render(); + await Harness.Render(); + for (var i = 0; i < 20 && ctx.GetMessages("mixed").Count > 0; i++) + { + await Task.Delay(25); + await Harness.Render(); + } + + H.Check("Issue1262_SyncAsync_Withdrawn", ctx.GetMessages("mixed").Count == 0, + $"remaining={string.Join("|", ctx.GetMessages("mixed").Select(m => m.Text))}"); + } + + // A root render that writes a verdict and then throws never reaches + // reconciliation, so nothing consumes or retires the claim it made. The host's + // error path settles it immediately — waiting for a later render is not enough, + // because a terminal error fallback means there may not be one. + private async Task AbortedRootRenderAsync() + { + var host = H.CreateHost(); + ValidationContext? ctx = null; + var abort = true; + + host.Mount(c => + { + ctx = c.UseValidationContext(); + if (abort) + { + _ = TextBox("").Validate("aborted", "", Validate.Required("aborted is required")); + throw new global::System.InvalidOperationException("render aborted on purpose"); + } + return VStack(8, TextBlock("recovered")); + }); + + var threw = false; + try { await Harness.Render(); } + catch (global::System.InvalidOperationException) { threw = true; } + + H.Check("Issue1262_Aborted_Resolved", ctx is not null, $"threw={threw}"); + if (ctx is null) return; + + // Settled by the abort itself, with no further render. This used to assert + // the verdict was still present here and only cleaned up by the *next* + // render — which left it stranded whenever no next render came + // (issue #1262 review). + H.Check("Issue1262_Aborted_SettledAtAbort", ctx.GetMessages("aborted").Count == 0, + $"remaining={string.Join("|", ctx.GetMessages("aborted").Select(m => m.Text))}"); + H.Check("Issue1262_Aborted_ValidAtAbort", ctx.IsValid(), $"valid={ctx.IsValid()}"); + + // And it stays settled once the host recovers into a tree without the field. + abort = false; + var recovered = H.CreateHost(); + recovered.Mount(c => TextBlock("after abort")); + await Harness.Render(); + await Harness.Render(); + + H.Check("Issue1262_Aborted_ClaimSettled", ctx.GetMessages("aborted").Count == 0, + $"remaining={string.Join("|", ctx.GetMessages("aborted").Select(m => m.Text))}"); + } + + // Two elements naming one field, one mounted and one dropped. The dropped one + // wrote last, so its claim holds the current stamp — retiring it unconditionally + // clears the verdict the mounted control is relying on and reports an invalid + // field as valid. + private async Task DiscardedSameFieldAsync() + { + var host = H.CreateHost(); + ValidationContext? ctx = null; + + host.Mount(c => VStack(12, + Component( + new BareValidateProps(BareValidateShape.DiscardedSameField, 0, found => ctx = found)), + TextBlock("host"))); + + await Harness.Render(); + await Harness.Render(); + H.Check("Issue1262_SameField_Resolved", ctx is not null); + if (ctx is null) return; + + H.Check("Issue1262_SameField_VerdictSurvives", ctx.GetMessages("email").Count == 1, + $"email={ctx.GetMessages("email").Count}"); + H.Check("Issue1262_SameField_ReportsInvalid", !ctx.IsValid(), + $"valid={ctx.IsValid()}"); + } + + // Guard: the outgoing control must not take the incoming one's verdict with + // it. Both write the same field under the same producer, and the incoming + // verdict is installed first. + private async Task BareReplacedAsync() + { + var host = H.CreateHost(); + Action? setShape = null; + ValidationContext? ctx = null; + + host.Mount(c => + { + var (shape, set) = c.UseState(BareValidateShape.Present); + setShape = set; + + return VStack(12, + Component( + new BareValidateProps(shape, 0, found => ctx = found)), + TextBlock("host")); + }); + + await Harness.Render(); + if (ctx is null) { H.Check("Issue1262_Unmount_ReplaceContextResolved", false); return; } + + H.Check("Issue1262_Unmount_ReplaceInitialError", ctx.GetMessages("email").Count == 1, + $"email={ctx.GetMessages("email").Count}"); + + setShape!(BareValidateShape.Replaced); + await Harness.Render(); + await Harness.Render(); + + H.Check("Issue1262_Unmount_ReplaceKeepsVerdict", ctx.GetMessages("email").Count == 1, + $"email={ctx.GetMessages("email").Count}"); + } + + // The whole FormField behind a condition: its own unmount has to withdraw, + // not just its content's. + private async Task WholeFormFieldAsync() + { + var ctx = new ValidationContext(); + var host = H.CreateHost(); + Action? setShow = null; + + host.Mount(c => + { + var (show, set) = c.UseState(true); + setShow = set; + + return VStack(12, + show + ? FormField( + TextBox("").Validate("email", "", Validate.Required("Email is required")), + label: "Email", + showWhen: ShowWhen.Always) + : TextBlock("hidden")) + .Provide(ValidationContexts.Current, ctx); + }); + + await Harness.Render(); + H.Check("Issue1262_Unmount_FieldInitialError", ctx.GetMessages("email").Count == 1, + $"email={ctx.GetMessages("email").Count}"); + + setShow!(false); + await Harness.Render(); + await Harness.Render(); + + H.Check("Issue1262_Unmount_FieldWithdrawn", ctx.GetMessages("email").Count == 0, + $"remaining={string.Join("|", ctx.GetMessages("email").Select(m => m.Text))}"); + } + + // A later link moves the attachment to another field. The attachment keeps + // only the final name, so the earlier link's verdict would otherwise be owned + // by a field nothing revisits. + private async Task ChainMovesFieldAsync() + { + var host = H.CreateHost(); + ValidationContext? ctx = null; + var renders = 0; + + host.Mount(c => VStack(12, + Component( + new ChainProps(true, found => ctx = found, () => renders++)), + TextBlock("host"))); + + await Harness.Render(); + if (ctx is null) { H.Check("Issue1262_Chain_ContextResolved", false); return; } + + // Chaining merges validators, so the final link runs both against its own + // field — the point is that "first" keeps nothing, not that "second" has + // exactly one message. + H.Check("Issue1262_Chain_FinalFieldValidated", ctx.GetMessages("second").Count == 2, + $"second={ctx.GetMessages("second").Count}"); + H.Check("Issue1262_Chain_EarlierLinkWithdrawn", ctx.GetMessages("first").Count == 0, + $"remaining={string.Join("|", ctx.GetMessages("first").Select(m => m.Text))}"); + } + + // Two links on one field carrying different values churn `_currentValues` + // every pass and land exactly where they started. The pass is net-zero, so it + // must not announce a change — announcing one repaints, which churns again. + private async Task ChainValueChurnSettlesAsync() + { + var host = H.CreateHost(); + ValidationContext? ctx = null; + var renders = 0; + + host.Mount(c => VStack(12, + Component( + new ChainProps(false, found => ctx = found, () => renders++)), + TextBlock("host"))); + + await Harness.Render(); + if (ctx is null) { H.Check("Issue1262_Churn_ContextResolved", false); return; } + + var settled = renders; + for (var i = 0; i < 6; i++) await Harness.Render(); + + // A self-sustaining notification loop shows up as renders that keep + // arriving with no input; a settled pass adds none of its own. + H.Check("Issue1262_Churn_Settles", renders - settled <= 2, + $"settled={settled} now={renders}"); + // "bb" is the final link's value: it passes Required and fails MinLength, + // so exactly one message survives. Had the first link's "" won, Required + // would have failed too and there would be two. + var texts = ctx.GetMessages("first").Select(m => m.Text).ToList(); + H.Check("Issue1262_Churn_FinalValueWins", + texts.Count == 1 && texts[0] == "first is too short", + $"messages={string.Join("|", texts)}"); + } + } + // ════════════════════════════════════════════════════════════════════════ + // Issue #1262 review — validators must not run twice per render. + // + // `.Validate()` evaluates eagerly while the tree is built, and FormField's + // reconcile-time pass used to evaluate the same attachment again. The + // structural diff hid the duplicate notification but not the work, so a + // custom or expensive validator paid twice on every render. + // ════════════════════════════════════════════════════════════════════════ + + internal sealed record CountingValidatorProps( + Action OnContext, Action Bump, Action OnRender); + + internal sealed class CountingValidatorOwner : Component + { + public override Element Render() + { + var props = Props; + var ctx = this.UseValidationContext(); + props.OnContext(ctx); + props.OnRender(); + + return VStack(8, + FormField( + TextBox("").Validate("email", "", + Validate.Must(_ => { props.Bump(); return false; }, "Email is required")), + label: "Email", + showWhen: ShowWhen.Always)); + } + } + + internal class Issue1262_ValidatorsRunOncePerRender(Harness h) : SelfTestFixtureBase(h) + { + public override async Task RunAsync() + { + var host = H.CreateHost(); + ValidationContext? ctx = null; + var runs = 0; + var ownerRenders = 0; + + host.Mount(c => VStack(12, + Component( + new CountingValidatorProps(found => ctx = found, () => runs++, () => ownerRenders++)), + TextBlock("host"))); + + await Harness.Render(); + H.Check("Issue1262_RunOnce_ContextResolved", ctx is not null); + if (ctx is null) return; + + // Positive control: the validator must actually be reached, or "ran once" + // would be satisfied by never running at all. + H.Check("Issue1262_RunOnce_ValidatorReached", runs > 0, $"runs={runs}"); + H.Check("Issue1262_RunOnce_VerdictInstalled", ctx.GetMessages("email").Count == 1, + $"email={ctx.GetMessages("email").Count}"); + + // One evaluation per render of the owning component, whatever that count + // happens to be. Asserting a bare `runs == 1` would fail for a second render + // that legitimately re-validates, and would pass for a double evaluation + // inside a single render if only one render occurred — neither is the + // property under test. + H.Check("Issue1262_RunOnce_NotDoubled", runs == ownerRenders, + $"runs={runs} ownerRenders={ownerRenders}"); + + var done = H.CreateHost(); + done.Mount(c => TextBlock("Issue1262 run-once done")); + await Harness.Render(); + } + } + // ════════════════════════════════════════════════════════════════════════ + // Issue #1262 review — whose cancellation was it? + // + // A mounted async rule is cancelled on update and unmount, and that is + // routine. But the predicate takes no token of its own, so anything IT + // cancels is the app's business — treating that as lifecycle churn hides a + // real fault and silently leaves the stale verdict in place. The two are + // told apart by the token, not by the exception type. + // ════════════════════════════════════════════════════════════════════════ + + internal class Issue1262_PredicateCancellationIsReported(Harness h) : SelfTestFixtureBase(h) + { + private static IDisposable SubscribeToRuleErrors(List sink) + => Microsoft.UI.Reactor.Diagnostics.ReactorTrace.Subscribe( + e => + { + if (e.EventName != nameof(Core.Diagnostics.ReactorEventSource.SwallowedError)) return; + if (e.Payload.Count < 3) return; + if (e.Payload[1] as string != "ValidationRuleAsync.Evaluate") return; + lock (sink) sink.Add(e.Payload[2] as string ?? ""); + }, + global::System.Diagnostics.Tracing.EventLevel.Warning, + Core.Diagnostics.ReactorEventSource.Keywords.Errors); + + public override async Task RunAsync() + { + var reported = new List(); + using var sub = SubscribeToRuleErrors(reported); + + // The predicate cancels itself, with a token that is not the rule's. + var ctx = new ValidationContext(); + var host = H.CreateHost(); + host.Mount(c => VStack(12, + ValidationRuleAsync( + async () => + { + await Task.Yield(); + using var foreign = new global::System.Threading.CancellationTokenSource(); + foreign.Cancel(); + foreign.Token.ThrowIfCancellationRequested(); + return true; + }, + "never reached", "form"), + TextBlock("rule host")) + .Provide(ValidationContexts.Current, ctx)); + + for (var i = 0; i < 10; i++) + { + await Task.Delay(25); + await Harness.Render(); + lock (reported) { if (reported.Count > 0) break; } + } + + int seen; string names; + lock (reported) { seen = reported.Count; names = string.Join("|", reported); } + + // EventListener callbacks for managed EventSource events do not flow under + // NativeAOT publish — IsEnabled() returns false on the emit side, so the + // listener observes nothing regardless of what the classification did. The + // same guard NativeDockingReliabilityFixture uses, and for the same reason: + // asserting here would fail the AOT run for a runtime limitation rather than + // a defect. The JIT selftest run covers it. + if (global::System.Runtime.CompilerServices.RuntimeFeature.IsDynamicCodeSupported) + { + H.Check("Issue1262_Cancel_ForeignCancellationReported", seen > 0, + $"reported={seen} names={names}"); + } + else + { + // The harness requires every fixture to emit a check or a skip, and it + // is right to: a fixture that silently emits nothing is indistinguishable + // from one that was never reached. + H.Skip("Issue1262_Cancel_ForeignCancellationReported", + "EventListener callbacks do not flow under NativeAOT publish"); + } + + // Positive control: the same subscription, same operation name, must stay + // silent for the lifecycle cancellation it is supposed to ignore. A sink + // that reports everything would satisfy the check above for the wrong + // reason. + var quiet = new List(); + using var sub2 = SubscribeToRuleErrors(quiet); + + var ctx2 = new ValidationContext(); + var host2 = H.CreateHost(); + Action? setShow = null; + host2.Mount(c => + { + var (show, set) = c.UseState(true); + setShow = set; + return VStack(12, + When(show, () => ValidationRuleAsync( + async () => { await Task.Delay(5000); return true; }, + "slow", "form")), + TextBlock("cancel host")) + .Provide(ValidationContexts.Current, ctx2); + }); + + await Harness.Render(); + setShow!(false); // unmount cancels the in-flight pass + await Harness.Render(); + for (var i = 0; i < 6; i++) { await Task.Delay(25); await Harness.Render(); } + + int quietCount; string quietNames; + lock (quiet) { quietCount = quiet.Count; quietNames = string.Join("|", quiet); } + // Guarded for the same reason as the check above, and additionally because a + // listener that can never observe anything satisfies "stayed silent" + // vacuously — the control would stop controlling for anything. + if (global::System.Runtime.CompilerServices.RuntimeFeature.IsDynamicCodeSupported) + { + H.Check("Issue1262_Cancel_LifecycleCancellationSilent", quietCount == 0, + $"reported={quietCount} names={quietNames}"); + } + else + { + H.Skip("Issue1262_Cancel_LifecycleCancellationSilent", + "EventListener callbacks do not flow under NativeAOT publish"); + } + + var done = H.CreateHost(); + done.Mount(c => TextBlock("Issue1262 cancellation done")); + await Harness.Render(); + } + } + // ════════════════════════════════════════════════════════════════════════ + // Issue #1262 — ShowWhen.AfterFirstSubmit was unreachable on a FormField. + // + // FormField called ShouldShowErrors without the submitAttempted argument, + // so it defaulted to false forever and the policy behaved exactly like + // ShowWhen.Never — the same shape as the WhenTouched defect. Nothing in + // the framework supplied the flag; only the manual .WithErrorStyling(...) + // path could, by passing it by hand. MarkAllTouched() is the submit signal + // the guide tells callers to send, so the context records it. + // ════════════════════════════════════════════════════════════════════════ + + internal sealed record AfterSubmitProps(Action OnContext); + + internal sealed class AfterSubmitOwner : Component + { + public override Element Render() + { + var ctx = this.UseValidationContext(); + Props.OnContext(ctx); + + return VStack(8, + FormField( + TextBox("").Validate("afs", "", Validate.Required("afs is required")), + label: "AFS", + showWhen: ShowWhen.AfterFirstSubmit), + TextBlock("afs-tail")); + } + } + + internal class Issue1262_AfterFirstSubmitReveals(Harness h) : SelfTestFixtureBase(h) + { + public override async Task RunAsync() + { + var host = H.CreateHost(); + ValidationContext? ctx = null; + + host.Mount(c => VStack(12, + Component( + new AfterSubmitProps(found => ctx = found)), + TextBlock("host"))); + + await Harness.Render(); + H.Check("Issue1262_AFS_ContextResolved", ctx is not null); + if (ctx is null) return; + + // The verdict exists from the first render; only its display is gated. + H.Check("Issue1262_AFS_ErrorExistsInContext", ctx.HasError("afs"), + $"hasError={ctx.HasError("afs")}"); + H.Check("Issue1262_AFS_HiddenBeforeSubmit", + H.FindText("afs is required") is null, + "error text was visible before any submit attempt"); + H.Check("Issue1262_AFS_FlagStartsFalse", !ctx.SubmitAttempted); + + // The submit signal the guide documents. + ctx.MarkAllTouched(); + await Harness.Render(); + await Harness.Render(); + + H.Check("Issue1262_AFS_FlagSetAfterSubmit", ctx.SubmitAttempted); + H.Check("Issue1262_AFS_ShownAfterSubmit", + H.FindText("afs is required") is not null, + "error text still hidden after MarkAllTouched()"); + + // ResetAll returns the form to its pre-submit state. + ctx.ResetAll(); + await Harness.Render(); + await Harness.Render(); + H.Check("Issue1262_AFS_FlagClearedByResetAll", !ctx.SubmitAttempted); + + var done = H.CreateHost(); + done.Mount(c => TextBlock("Issue1262 afs done")); + await Harness.Render(); + } + } + // ════════════════════════════════════════════════════════════════════════ + // Issue #1262 review — focus moving *inside* a composite editor is not a blur. + // + // LostFocus/LosingFocus are routed, so they also fire when focus moves + // between descendants of one editor — a NumberBox's text part to its spin + // buttons, a DatePicker between selectors. The field has not been blurred + // then, and marking it touched reveals the error while the user is still + // inside the control. + // ════════════════════════════════════════════════════════════════════════ + + internal class Issue1262_InternalFocusMoveIsNotABlur(Harness h) : SelfTestFixtureBase(h) + { + public override async Task RunAsync() + { + var ctx = new ValidationContext(); + var host = H.CreateHost(); + + // Content with two focusable parts. Moving focus from one to the other is + // the routed-event case: LosingFocus bubbles to the content root even though + // focus never left the field. A single control cannot exercise this — it has + // only one focusable part, so no internal transition exists to route. + host.Mount(c => VStack(12, + FormField( + VStack(4, + TextBox("").AutomationId("PartA"), + TextBox("").AutomationId("PartB")), + label: "Amount", + fieldName: "amount"), + TextBox("").AutomationId("Elsewhere")) + .Provide(ValidationContexts.Current, ctx)); + + await Harness.Render(); + ctx.Add("amount", "amount is required"); + await Harness.Render(); + + static Microsoft.UI.Xaml.Controls.TextBox? ById(Harness harness, string id) + => harness.FindControl( + tb => Microsoft.UI.Xaml.Automation.AutomationProperties.GetAutomationId(tb) == id); + + var partA = ById(H, "PartA"); + var partB = ById(H, "PartB"); + var elsewhere = ById(H, "Elsewhere"); + + H.Check("Issue1262_InnerFocus_PartsMounted", + partA is not null && partB is not null && elsewhere is not null); + if (partA is null || partB is null || elsewhere is null) return; + + partA.Focus(Microsoft.UI.Xaml.FocusState.Programmatic); + await Harness.Render(); + H.Check("Issue1262_InnerFocus_NotTouchedOnEntry", !ctx.IsTouched("amount"), + $"touched={ctx.IsTouched("amount")}"); + + // The case under test: focus moves to a sibling part of the same field. + partB.Focus(Microsoft.UI.Xaml.FocusState.Programmatic); + await Harness.Render(); + await Harness.Render(); + + // Instrument check: the move must actually have happened, or "not touched" + // proves nothing (the previous version of this fixture focused the part that + // already had focus, so no transition fired and the assertion was vacuous). + var focusedNow = Microsoft.UI.Xaml.Input.FocusManager.GetFocusedElement(partB.XamlRoot); + H.Check("Issue1262_InnerFocus_MoveActuallyHappened", + ReferenceEquals(focusedNow, partB), + $"focused={focusedNow?.GetType().Name ?? ""}"); + + H.Check("Issue1262_InnerFocus_StillNotTouched", !ctx.IsTouched("amount"), + $"touched={ctx.IsTouched("amount")}"); + + // Positive control: leaving the field really does mark it touched, so the + // check above is not passing because nothing ever marks. + elsewhere.Focus(Microsoft.UI.Xaml.FocusState.Programmatic); + await Harness.Render(); + await Harness.Render(); + H.Check("Issue1262_InnerFocus_TouchedOnRealBlur", ctx.IsTouched("amount"), + $"touched={ctx.IsTouched("amount")}"); + + var done = H.CreateHost(); + done.Mount(c => TextBlock("Issue1262 inner focus done")); + await Harness.Render(); + } + } +} \ No newline at end of file diff --git a/tests/Reactor.AppTests.Host/SelfTest/SelfTestFixtureRegistry.cs b/tests/Reactor.AppTests.Host/SelfTest/SelfTestFixtureRegistry.cs index 7dc7eccf3..3c4fd3e9f 100644 --- a/tests/Reactor.AppTests.Host/SelfTest/SelfTestFixtureRegistry.cs +++ b/tests/Reactor.AppTests.Host/SelfTest/SelfTestFixtureRegistry.cs @@ -806,6 +806,31 @@ internal static class SelfTestFixtureRegistry "ValCov_ValidationCompositeLifecycleUpdateAndCleanup", "ValCov_ValidationRule", "ValCov_FormFieldHelpers", + "ValCov_Issue1262_BareValidate", + "ValCov_Issue1262_FormFieldAutoProvide", + "ValCov_Issue1262_ExplicitProvideWins", + "ValCov_Issue1262_FormFieldTouchedOnBlur", + "ValCov_Issue1262_FailingRuleNoLoop", + "ValCov_Issue1262_TouchBindingCleared", + "ValCov_Issue1262_RuleRetractsOnUnmount", + "ValCov_Issue1262_ChildRuleRetracts", + "ValCov_Issue1262_DisplacedRootBinding", + "ValCov_Issue1262_AsyncOnlyCachedField", + "ValCov_Issue1262_ChainedValueOverloads", + "ValCov_Issue1262_MountedAsyncRule", + "ValCov_Issue1262_AsyncRuleProviderRemoved", + "ValCov_Issue1262_AsyncRuleBecomesSync", + "ValCov_Issue1262_ValidatorOnlyInFormField", + "ValCov_Issue1262_DetachClearsBindings", + "ValCov_Issue1262_DetachRetiresRuleVerdict", + "ValCov_Issue1262_OffThreadMutationMarshals", + "ValCov_Issue1262_FormFieldNameMigration", + "ValCov_Issue1262_AttachedValidationStops", + "ValCov_Issue1262_UnmountWithdrawsVerdict", + "ValCov_Issue1262_ValidatorsRunOnce", + "ValCov_Issue1262_PredicateCancellationReported", + "ValCov_Issue1262_AfterFirstSubmitReveals", + "ValCov_Issue1262_InternalFocusMoveIsNotABlur", // Controls coverage — MaskEngine, InputFormatter, AutoSuggest "ControlsCov_MaskEngineBasic", "ControlsCov_MaskEngineNavigation", @@ -2706,6 +2731,31 @@ internal static string[] StaleTierDeclarations() => "ValCov_ValidationCompositeLifecycleUpdateAndCleanup" => new ValidationCoverageFixtures.CompositeLifecycleUpdateAndCleanup(harness), "ValCov_ValidationRule" => new ValidationCoverageFixtures.ValidationRuleExercise(harness), "ValCov_FormFieldHelpers" => new ValidationCoverageFixtures.FormFieldHelpersExercise(harness), + "ValCov_Issue1262_BareValidate" => new ValidationCoverageFixtures.Issue1262_BareValidateOnControls(harness), + "ValCov_Issue1262_FormFieldAutoProvide" => new ValidationCoverageFixtures.Issue1262_FormFieldWithoutExplicitProvide(harness), + "ValCov_Issue1262_ExplicitProvideWins" => new ValidationCoverageFixtures.Issue1262_ExplicitProvideStillWins(harness), + "ValCov_Issue1262_FormFieldTouchedOnBlur" => new ValidationCoverageFixtures.Issue1262_FormFieldTouchedOnBlur(harness), + "ValCov_Issue1262_FailingRuleNoLoop" => new ValidationCoverageFixtures.Issue1262_FailingRuleDoesNotLoop(harness), + "ValCov_Issue1262_TouchBindingCleared" => new ValidationCoverageFixtures.Issue1262_TouchBindingClearedWhenContextGoes(harness), + "ValCov_Issue1262_RuleRetractsOnUnmount" => new ValidationCoverageFixtures.Issue1262_RuleRetractsOnUnmount(harness), + "ValCov_Issue1262_ChildRuleRetracts" => new ValidationCoverageFixtures.Issue1262_RuleRetractsFromChildComponent(harness), + "ValCov_Issue1262_DisplacedRootBinding" => new ValidationCoverageFixtures.Issue1262_DisplacedRootBindingCleared(harness), + "ValCov_Issue1262_AsyncOnlyCachedField" => new ValidationCoverageFixtures.Issue1262_AsyncOnlyFieldOnCachedElement(harness), + "ValCov_Issue1262_ChainedValueOverloads" => new ValidationCoverageFixtures.Issue1262_ChainedValueOverloadsDoNotLoop(harness), + "ValCov_Issue1262_MountedAsyncRule" => new ValidationCoverageFixtures.Issue1262_MountedAsyncRuleRuns(harness), + "ValCov_Issue1262_AsyncRuleProviderRemoved" => new ValidationCoverageFixtures.Issue1262_AsyncRuleProviderRemovedMidFlight(harness), + "ValCov_Issue1262_AsyncRuleBecomesSync" => new ValidationCoverageFixtures.Issue1262_AsyncRuleBecomesSync(harness), + "ValCov_Issue1262_ValidatorOnlyInFormField" => new ValidationCoverageFixtures.Issue1262_ValidatorOnlyAttachmentInFormField(harness), + "ValCov_Issue1262_DetachClearsBindings" => new ValidationCoverageFixtures.Issue1262_DetachClearsValidationBindings(harness), + "ValCov_Issue1262_DetachRetiresRuleVerdict" => new ValidationCoverageFixtures.Issue1262_DetachRetiresRuleVerdict(harness), + "ValCov_Issue1262_OffThreadMutationMarshals" => new ValidationCoverageFixtures.Issue1262_OffThreadContextMutationMarshals(harness), + "ValCov_Issue1262_FormFieldNameMigration" => new ValidationCoverageFixtures.Issue1262_FormFieldNameMigration(harness), + "ValCov_Issue1262_AttachedValidationStops" => new ValidationCoverageFixtures.Issue1262_AttachedValidationWithdrawnWhenItStops(harness), + "ValCov_Issue1262_UnmountWithdrawsVerdict" => new ValidationCoverageFixtures.Issue1262_ValidatedControlUnmountWithdraws(harness), + "ValCov_Issue1262_ValidatorsRunOnce" => new ValidationCoverageFixtures.Issue1262_ValidatorsRunOncePerRender(harness), + "ValCov_Issue1262_PredicateCancellationReported" => new ValidationCoverageFixtures.Issue1262_PredicateCancellationIsReported(harness), + "ValCov_Issue1262_AfterFirstSubmitReveals" => new ValidationCoverageFixtures.Issue1262_AfterFirstSubmitReveals(harness), + "ValCov_Issue1262_InternalFocusMoveIsNotABlur" => new ValidationCoverageFixtures.Issue1262_InternalFocusMoveIsNotABlur(harness), // Controls coverage "ControlsCov_MaskEngineBasic" => new ControlsCoverageFixtures.MaskEngineBasic(harness), "ControlsCov_MaskEngineNavigation" => new ControlsCoverageFixtures.MaskEngineNavigation(harness), diff --git a/tests/Reactor.Tests/ValidationContextTests.cs b/tests/Reactor.Tests/ValidationContextTests.cs index 6ab5cadd4..24e17a67f 100644 --- a/tests/Reactor.Tests/ValidationContextTests.cs +++ b/tests/Reactor.Tests/ValidationContextTests.cs @@ -632,7 +632,14 @@ public void Version_Increments_On_Touch_And_Reset() ctx.Reset("f"); Assert.True(ctx.Version > v1); + // Reset/ResetAll bump only on a real state delta (issue #1262 review): an + // effect that resets every render must not repaint forever. + var vNoop = ctx.Version; + ctx.ResetAll(); + Assert.Equal(vNoop, ctx.Version); + ctx.SetInitialValue("f", "v2"); + ctx.MarkTouched("f"); var v2 = ctx.Version; ctx.ResetAll(); Assert.True(ctx.Version > v2); diff --git a/tests/Reactor.Tests/ValidationRenderScopeTests.cs b/tests/Reactor.Tests/ValidationRenderScopeTests.cs new file mode 100644 index 000000000..c76c9ee4b --- /dev/null +++ b/tests/Reactor.Tests/ValidationRenderScopeTests.cs @@ -0,0 +1,2744 @@ +using Microsoft.UI.Reactor.Core; +using Microsoft.UI.Reactor.Controls.Validation; +using System.Threading.Tasks; +using static Microsoft.UI.Reactor.Factories; +using static Microsoft.UI.Reactor.Controls.Validation.ValidationRuleDsl; +using Xunit; + +namespace Microsoft.UI.Reactor.Tests; + +/// +/// Covers the render-scoped validation path added for issue #1262: .Validate() +/// running eagerly during a render pass, the context being published to the subtree +/// automatically, and the change notification that repaints a form when the context is +/// mutated from an event handler. +/// +public class ValidationRenderScopeTests +{ + // ════════════════════════════════════════════════════════════════ + // .Validate() eager execution + // ════════════════════════════════════════════════════════════════ + + [Fact] + public void Validate_Outside_A_Render_Pass_Only_Attaches() + { + var ctx = new ValidationContext(); + + // No scope open — this is an element assembled in an event handler, a cached + // element, or a headless test. Nothing should reach the context. + var el = TextBox("").Validate("email", "", Validate.Required()); + + Assert.NotNull(el.GetValidation()); + Assert.Empty(ctx.GetAllMessages()); + Assert.Empty(ctx.RegisteredFields); + Assert.True(ctx.IsValid()); + } + + [Fact] + public void Validate_Inside_A_Render_Pass_Runs_Validators() + { + var ctx = new ValidationContext(); + + using (ValidationRenderScope.Begin(ctx)) + { + _ = TextBox("").Validate("email", "", Validate.Required(), Validate.Email()); + } + + // This is the whole bug: before the fix a bare .Validate() produced nothing, so + // IsValid() was trivially true and the form submitted. + Assert.False(ctx.IsValid()); + Assert.Contains("email", ctx.RegisteredFields); + Assert.Single(ctx.GetMessages("email")); + Assert.Equal("REQUIRED", ctx.GetMessages("email")[0].Code); + } + + [Fact] + public void Validate_Inside_A_Render_Pass_Registers_The_Field_For_MarkAllTouched() + { + var ctx = new ValidationContext(); + + using (ValidationRenderScope.Begin(ctx)) + { + _ = TextBox("").Validate("email", "", Validate.Required()); + } + + Assert.False(ctx.IsTouched("email")); + ctx.MarkAllTouched(); + Assert.True(ctx.IsTouched("email")); + } + + [Fact] + public void Validate_Inside_A_Render_Pass_Clears_Messages_When_The_Value_Becomes_Valid() + { + var ctx = new ValidationContext(); + + using (ValidationRenderScope.Begin(ctx)) + _ = TextBox("").Validate("email", "", Validate.Required(), Validate.Email()); + Assert.False(ctx.IsValid()); + + using (ValidationRenderScope.Begin(ctx)) + _ = TextBox("").Validate("email", "user@example.com", Validate.Required(), Validate.Email()); + + Assert.True(ctx.IsValid()); + Assert.Empty(ctx.GetMessages("email")); + } + + [Fact] + public void Validate_Without_A_Value_Stays_Attach_Only_Even_Inside_A_Render_Pass() + { + var ctx = new ValidationContext(); + + using (ValidationRenderScope.Begin(ctx)) + { + // No value overload — there is nothing to validate against. + _ = TextBox("").Validate("email", Validate.Required()); + } + + Assert.Empty(ctx.GetAllMessages()); + } + + [Fact] + public void Scope_Does_Not_Leak_Past_Its_Frame() + { + var ctx = new ValidationContext(); + + using (ValidationRenderScope.Begin(ctx)) + Assert.Same(ctx, ValidationRenderScope.Current); + + Assert.Null(ValidationRenderScope.Current); + Assert.False(ValidationRenderScope.InRender); + + // And a .Validate() after the frame closed reaches nothing. + _ = TextBox("").Validate("late", "", Validate.Required()); + Assert.Empty(ctx.GetMessages("late")); + } + + [Fact] + public void Nested_Frames_Restore_The_Enclosing_Context() + { + var outer = new ValidationContext(); + var inner = new ValidationContext(); + + using (ValidationRenderScope.Begin(outer)) + { + using (ValidationRenderScope.Begin(inner)) + Assert.Same(inner, ValidationRenderScope.Current); + + Assert.Same(outer, ValidationRenderScope.Current); + } + + Assert.Null(ValidationRenderScope.Current); + } + + // ════════════════════════════════════════════════════════════════ + // Idempotence — the guard against a render loop + // ════════════════════════════════════════════════════════════════ + + [Fact] + public void Revalidating_An_Unchanged_Value_Neither_Bumps_Version_Nor_Notifies() + { + var ctx = new ValidationContext(); + var notifications = 0; + ctx.Changed += () => notifications++; + + using (ValidationRenderScope.Begin(ctx)) + _ = TextBox("").Validate("email", "", Validate.Required()); + + var versionAfterFirstPass = ctx.Version; + var notificationsAfterFirstPass = notifications; + + // Five more render passes over the same value. Because .Validate() now runs on + // every pass, a version bump or a notification here would feed the re-render + // subscription and loop forever. + for (var i = 0; i < 5; i++) + { + using (ValidationRenderScope.Begin(ctx)) + _ = TextBox("").Validate("email", "", Validate.Required()); + } + + Assert.Equal(versionAfterFirstPass, ctx.Version); + Assert.Equal(notificationsAfterFirstPass, notifications); + Assert.Single(ctx.GetMessages("email")); // and not accumulated + } + + [Fact] + public void Revalidating_Outside_A_Render_Pass_Notifies_Only_On_A_Real_Change() + { + var ctx = new ValidationContext(); + var notifications = 0; + ctx.Changed += () => notifications++; + + ValidationReconciler.ValidateField(ctx, "email", "", Validate.Required()); + var afterFirst = notifications; + Assert.True(afterFirst > 0); + + ValidationReconciler.ValidateField(ctx, "email", "", Validate.Required()); + Assert.Equal(afterFirst, notifications); + + ValidationReconciler.ValidateField(ctx, "email", "user@example.com", Validate.Required()); + Assert.True(notifications > afterFirst); + } + + [Fact] + public void A_First_Validation_Notifies_Exactly_Once_With_Results_Already_Installed() + { + var ctx = new ValidationContext(); + var notifications = 0; + var messagesWhenNotified = -1; + + // Registering the value and installing the verdict used to be three calls, so a + // subscriber woke up mid-update and re-rendered against the previous pass's + // messages. Observe what the context looks like at notification time. + ctx.Changed += () => + { + notifications++; + messagesWhenNotified = ctx.GetMessages("email").Count; + }; + + ValidationReconciler.ValidateField(ctx, "email", "", Validate.Required(), Validate.MinLength(3)); + + Assert.Equal(1, notifications); + Assert.Equal(2, messagesWhenNotified); + } + + // ════════════════════════════════════════════════════════════════ + // Cross-field rules — the other reconcile-time writer + // ════════════════════════════════════════════════════════════════ + + [Fact] + public void A_Failing_Rule_Re_Evaluated_Is_Silent() + { + var ctx = new ValidationContext(); + var rule = ValidationRule(() => false, "Passwords must match", "confirm"); + + rule.Evaluate(ctx); + var notifications = 0; + ctx.Changed += () => notifications++; + var versionAfterFirst = ctx.Version; + + // ValidationRule mounts/updates evaluate during reconcile, outside any render + // scope. Clear-then-add raised Changed on every pass, and each notification + // drove another render — the reconciler tripped its re-entrancy limit. + for (var i = 0; i < 5; i++) + rule.Evaluate(ctx); + + Assert.Equal(0, notifications); + Assert.Equal(versionAfterFirst, ctx.Version); + Assert.Single(ctx.GetMessages("confirm")); + } + + [Fact] + public void The_Per_Render_Seed_Then_Notify_Pattern_Settles() + { + // DirtyResetDemo's shape: register, re-seed the baseline and re-notify the + // current value on *every* render. SetInitialValue used to rewind the current + // value, so once the user had typed, the rewind and the re-notify took turns + // and the subscription repainted forever. + var ctx = new ValidationContext(); + var notifications = 0; + ctx.Changed += () => notifications++; + + void RenderPass(string typed) + { + ctx.RegisterField("name"); + ctx.SetInitialValue("name", "John Doe"); + ctx.NotifyValueChanged("name", typed); + } + + RenderPass("John Doe"); + // One notification, for registering "name". Registration is observable state + // (RegisteredFields is public and MarkAllTouched iterates it), so the first pass + // that introduces a field is a real change — the value and baseline genuinely did + // not move (issue #1262 review). + Assert.Equal(1, notifications); + Assert.False(ctx.IsDirty("name")); + + RenderPass("John Doex"); // user typed + var afterEdit = notifications; + Assert.Equal(2, afterEdit); + Assert.True(ctx.IsDirty("name")); + + // Every subsequent repaint over the same value must be silent. + for (var i = 0; i < 5; i++) RenderPass("John Doex"); + + Assert.Equal(afterEdit, notifications); + Assert.True(ctx.IsDirty("name")); + } + + [Fact] + public void Re_Seeding_An_Initial_Value_Does_Not_Rewind_The_Current_One() + { + var ctx = new ValidationContext(); + ctx.SetInitialValue("name", "John Doe"); + ctx.NotifyValueChanged("name", "edited"); + + ctx.SetInitialValue("name", "John Doe"); + + Assert.True(ctx.IsDirty("name")); + } + + [Fact] + public void Re_Baselining_A_Dirty_Field_Notifies() + { + var ctx = new ValidationContext(); + ctx.SetInitialValue("name", "a"); + ctx.NotifyValueChanged("name", "b"); + Assert.True(ctx.IsDirty("name")); + + var notifications = 0; + ctx.Changed += () => notifications++; + + // Adopting the edited value as the new baseline flips IsDirty with no message + // or touched-state change, so subscribers would otherwise stay stale. + ctx.SetInitialValue("name", "b"); + + Assert.False(ctx.IsDirty("name")); + Assert.Equal(1, notifications); + + // ...and a re-seed that changes nothing observable stays quiet. + ctx.SetInitialValue("name", "b"); + Assert.Equal(1, notifications); + } + + // ════════════════════════════════════════════════════════════════ + // Async validators — one atomic install, no duplicates + // ════════════════════════════════════════════════════════════════ + + [Fact] + public async Task Async_Validators_Install_As_One_Notification() + { + var ctx = new ValidationContext(); + var seen = new List(); + ctx.Changed += () => seen.Add(ctx.GetMessages("username").Count); + + var validators = new[] + { + Validate.MustAsync(_ => global::System.Threading.Tasks.Task.FromResult(false), "Reserved"), + Validate.MustAsync(_ => global::System.Threading.Tasks.Task.FromResult(false), "Already taken"), + }; + + await ValidationReconciler.ValidateFieldAsync( + ctx, "username", "admin", validators, TestContext.Current.CancellationToken); + + // Adding each result as it resolved exposed a partial verdict and repainted + // between messages. Recording the value is a separate, legitimate notification + // (it moves dirty tracking), so the assertion is about the verdict: it must + // never be observed half-installed, and it must land exactly once. + Assert.DoesNotContain(1, seen); + Assert.Single(seen, count => count == 2); + Assert.Equal(2, ctx.GetMessages("username").Count); + } + + [Fact] + public async Task Repeating_Async_Validation_Replaces_Instead_Of_Appending() + { + var ctx = new ValidationContext(); + var validators = new[] + { + Validate.MustAsync(_ => global::System.Threading.Tasks.Task.FromResult(false), "Reserved"), + }; + + await ValidationReconciler.ValidateFieldAsync( + ctx, "username", "admin", validators, TestContext.Current.CancellationToken); + Assert.Single(ctx.GetMessages("username")); + + var notifications = 0; + ctx.Changed += () => notifications++; + + await ValidationReconciler.ValidateFieldAsync( + ctx, "username", "admin", validators, TestContext.Current.CancellationToken); + + Assert.Single(ctx.GetMessages("username")); + Assert.Equal(0, notifications); + } + + [Fact] + public async Task Repeated_Async_Validation_Stays_Single_Across_Many_Passes() + { + var ctx = new ValidationContext(); + var validators = new[] + { + Validate.MustAsync(_ => global::System.Threading.Tasks.Task.FromResult(false), "Reserved"), + }; + + // The duplicate only surfaced on the *third* pass: pass two produced an equal + // result, so the diff said "unchanged" and left the original instance installed + // while ownership had already been repointed at the discarded copy. + for (var i = 0; i < 4; i++) + { + await ValidationReconciler.ValidateFieldAsync( + ctx, "username", "admin", validators, TestContext.Current.CancellationToken); + } + + Assert.Single(ctx.GetMessages("username")); + } + + [Fact] + public async Task A_Late_Async_Result_For_An_Older_Value_Is_Discarded() + { + var ctx = new ValidationContext(); + var older = new global::System.Threading.Tasks.TaskCompletionSource(); + var newer = new global::System.Threading.Tasks.TaskCompletionSource(); + + var staleValidators = new[] + { + Validate.MustAsync(async _ => await older.Task, "stale verdict"), + }; + var freshValidators = new[] + { + Validate.MustAsync(async _ => await newer.Task, "fresh verdict"), + }; + + var stale = ValidationReconciler.ValidateFieldAsync( + ctx, "username", "old", staleValidators, TestContext.Current.CancellationToken); + var fresh = ValidationReconciler.ValidateFieldAsync( + ctx, "username", "new", freshValidators, TestContext.Current.CancellationToken); + + // The newer value's check resolves first and passes. + newer.SetResult(true); + await fresh; + Assert.True(ctx.IsValid()); + + // The older value's check then resolves and fails. Applying it would show an + // error that belongs to a value the user has already replaced. + older.SetResult(false); + await stale; + + Assert.True(ctx.IsValid()); + Assert.Empty(ctx.GetMessages("username")); + } + + // ════════════════════════════════════════════════════════════════ + // Several producers on one field + // ════════════════════════════════════════════════════════════════ + + [Fact] + public async Task A_Pass_Cleared_Mid_Flight_Cannot_Be_Resurrected_By_A_Later_Pass() + { + var ctx = new ValidationContext(); + var older = new global::System.Threading.Tasks.TaskCompletionSource(); + + var staleValidators = new[] + { + Validate.MustAsync(async _ => await older.Task, "stale verdict"), + }; + var freshValidators = new[] + { + Validate.MustAsync(_ => global::System.Threading.Tasks.Task.FromResult(true), "fresh verdict"), + }; + + // Pass one is in flight and holds a token. + var stale = ValidationReconciler.ValidateFieldAsync( + ctx, "username", "old", staleValidators, TestContext.Current.CancellationToken); + + // The field is cleared, retiring that token... + ctx.Clear("username"); + + // ...and a brand new pass opens. With a per-field counter this would have been + // handed the same number the in-flight pass is still holding. + await ValidationReconciler.ValidateFieldAsync( + ctx, "username", "new", freshValidators, TestContext.Current.CancellationToken); + + older.SetResult(false); + await stale; + + Assert.True(ctx.IsValid()); + Assert.Empty(ctx.GetMessages("username")); + } + + [Fact] + public void A_Shrinking_Rule_Set_Withdraws_The_Rules_That_Disappeared() + { + var ctx = new ValidationContext(); + + ValidationReconciler.EvaluateRules(ctx, "form-rules", + ValidationRule(() => false, "First rule failed", "form"), + ValidationRule(() => false, "Second rule failed", "form")); + Assert.Equal(2, ctx.GetMessages("form").Count); + + // The second rule is gone this time. Its message would otherwise keep the form + // invalid forever, because nothing re-evaluates a rule that no longer exists. + ValidationReconciler.EvaluateRules(ctx, "form-rules", + ValidationRule(() => false, "First rule failed", "form")); + + var texts = ctx.GetMessages("form").Select(m => m.Text).ToList(); + Assert.Single(texts); + Assert.Contains("First rule failed", texts); + } + + [Fact] + public void An_Emptied_Rule_Set_Leaves_The_Context_Valid() + { + var ctx = new ValidationContext(); + + ValidationReconciler.EvaluateRules(ctx, "form-rules", + ValidationRule(() => false, "Rule failed", "form")); + Assert.False(ctx.IsValid()); + + ValidationReconciler.EvaluateRules(ctx, "form-rules"); + + Assert.True(ctx.IsValid()); + Assert.Empty(ctx.GetMessages("form")); + } + + [Fact] + public void A_Rule_That_Moves_Field_Withdraws_From_The_Old_One() + { + var ctx = new ValidationContext(); + + ValidationReconciler.EvaluateRules(ctx, "range-rules", + ValidationRule(() => false, "Rule failed", "start")); + Assert.Single(ctx.GetMessages("start")); + + ValidationReconciler.EvaluateRules(ctx, "range-rules", + ValidationRule(() => false, "Rule failed", "end")); + + Assert.Empty(ctx.GetMessages("start")); + Assert.Single(ctx.GetMessages("end")); + } + + [Fact] + public void Rule_Sets_Do_Not_Disturb_Field_Level_Errors() + { + var ctx = new ValidationContext(); + ValidationReconciler.ValidateField(ctx, "form", "", Validate.Required("Field is required")); + + ValidationReconciler.EvaluateRules(ctx, "form-rules", + ValidationRule(() => false, "Rule failed", "form")); + Assert.Equal(2, ctx.GetMessages("form").Count); + + // Withdrawing the whole rule set must not take the sync verdict with it. + ValidationReconciler.EvaluateRules(ctx, "form-rules"); + + var texts = ctx.GetMessages("form").Select(m => m.Text).ToList(); + Assert.Single(texts); + Assert.Contains("Field is required", texts); + } + + [Fact] + public async Task A_Sync_Value_Change_Retires_An_In_Flight_Async_Pass() + { + var ctx = new ValidationContext(); + var gate = new global::System.Threading.Tasks.TaskCompletionSource(); + var validators = new[] + { + Validate.MustAsync(async _ => await gate.Task, "stale async verdict"), + }; + + // An async check opens for the old value... + var pending = ValidationReconciler.ValidateFieldAsync( + ctx, "username", "old", validators, TestContext.Current.CancellationToken); + + // ...then the user types, and the synchronous pass records the new value. + ValidationReconciler.ValidateField(ctx, "username", "new", Validate.Required()); + + gate.SetResult(false); + await pending; + + // The verdict belongs to a value that is no longer on screen. + Assert.True(ctx.IsValid()); + Assert.Empty(ctx.GetMessages("username")); + } + + [Fact] + public async Task A_Sync_Value_Change_Withdraws_An_Installed_Async_Verdict() + { + var ctx = new ValidationContext(); + var validators = new[] + { + Validate.MustAsync(_ => global::System.Threading.Tasks.Task.FromResult(false), "Reserved"), + }; + + ValidationReconciler.ValidateField(ctx, "username", "admin", Validate.Required()); + await ValidationReconciler.ValidateFieldAsync( + ctx, "username", "admin", validators, TestContext.Current.CancellationToken); + Assert.Single(ctx.GetMessages("username")); + + // Typing a new value must drop the async error computed for the old one. + ValidationReconciler.ValidateField(ctx, "username", "someone-else", Validate.Required()); + + Assert.True(ctx.IsValid()); + Assert.Empty(ctx.GetMessages("username")); + } + + [Fact] + public void Re_Validating_The_Same_Value_Keeps_The_Async_Verdict() + { + var ctx = new ValidationContext(); + ValidationReconciler.ValidateField(ctx, "username", "admin", Validate.Required()); + ctx.ApplyOwned("username", ValidationContext.AsyncProducer, + [new ValidationMessage("username", "Reserved")]); + Assert.Single(ctx.GetMessages("username")); + + // A re-render that revalidates the *same* value is not a value change, so the + // async verdict still applies and must survive. + ValidationReconciler.ValidateField(ctx, "username", "admin", Validate.Required()); + + Assert.Single(ctx.GetMessages("username")); + Assert.Equal("Reserved", ctx.GetMessages("username")[0].Text); + } + + [Fact] + public void A_Cross_Field_Rule_Does_Not_Erase_Field_Level_Errors() + { + var ctx = new ValidationContext(); + ValidationReconciler.ValidateField(ctx, "confirm", "", Validate.Required("Confirmation is required")); + Assert.Single(ctx.GetMessages("confirm")); + + ValidationRule(() => false, "Passwords must match", "confirm").Evaluate(ctx); + + var texts = ctx.GetMessages("confirm").Select(m => m.Text).ToList(); + Assert.Equal(2, texts.Count); + Assert.Contains("Confirmation is required", texts); + Assert.Contains("Passwords must match", texts); + } + + [Fact] + public void A_Passing_Rule_Retracts_Only_Its_Own_Message() + { + var ctx = new ValidationContext(); + ValidationReconciler.ValidateField(ctx, "confirm", "", Validate.Required("Confirmation is required")); + + // One rule, re-evaluated from one call site — a rule's identity is its code + // location, not its message text. + var matches = false; + void RunRule() => ValidationRule(() => matches, "Passwords must match", "confirm").Evaluate(ctx); + + RunRule(); + Assert.Equal(2, ctx.GetMessages("confirm").Count); + + // Whole-field replacement made a passing rule wipe the required error and report + // the form valid. + matches = true; + RunRule(); + + var texts = ctx.GetMessages("confirm").Select(m => m.Text).ToList(); + Assert.Single(texts); + Assert.Contains("Confirmation is required", texts); + Assert.False(ctx.IsValid()); + } + + [Fact] + public void Interleaved_Producers_On_One_Field_Settle() + { + var ctx = new ValidationContext(); + var notifications = 0; + ctx.Changed += () => notifications++; + + // Sync runs during render, the rule during reconcile — every pass, forever. + // Retract-and-append would swap their order each time, and an order change is a + // structural change, which would notify on every pass. + for (var i = 0; i < 5; i++) + { + ValidationReconciler.ValidateField(ctx, "confirm", "", Validate.Required("Confirmation is required")); + ValidationRule(() => false, "Passwords must match", "confirm").Evaluate(ctx); + } + + Assert.Equal(2, ctx.GetMessages("confirm").Count); + Assert.Equal(2, notifications); // one per producer's first real change, then silence + } + + [Fact] + public async Task Async_Validation_Leaves_Synchronous_Messages_Alone() + { + var ctx = new ValidationContext(); + ValidationReconciler.ValidateField(ctx, "username", "", Validate.Required("Username is required")); + Assert.Single(ctx.GetMessages("username")); + + var validators = new[] + { + Validate.MustAsync(_ => global::System.Threading.Tasks.Task.FromResult(false), "Reserved"), + }; + + await ValidationReconciler.ValidateFieldAsync( + ctx, "username", "", validators, TestContext.Current.CancellationToken); + + // Retracting the previous async pass must not take the sync verdict with it. + var texts = ctx.GetMessages("username").Select(m => m.Text).ToList(); + Assert.Equal(2, texts.Count); + Assert.Contains("Username is required", texts); + Assert.Contains("Reserved", texts); + + await ValidationReconciler.ValidateFieldAsync( + ctx, "username", "", validators, TestContext.Current.CancellationToken); + + texts = [.. ctx.GetMessages("username").Select(m => m.Text)]; + Assert.Equal(2, texts.Count); + Assert.Contains("Username is required", texts); + } + + [Fact] + public async Task An_Async_Rule_Re_Evaluated_To_The_Same_Verdict_Is_Silent() + { + var ctx = new ValidationContext(); + var rule = ValidationRuleAsync( + () => global::System.Threading.Tasks.Task.FromResult(false), + "Username is taken", + "username"); + + await rule.EvaluateAsync(ctx, TestContext.Current.CancellationToken); + + var notifications = 0; + ctx.Changed += () => notifications++; + var version = ctx.Version; + + // Clearing before the await would raise once for the clear and again for the + // identical failure, and briefly report the field valid in between. + await rule.EvaluateAsync(ctx, TestContext.Current.CancellationToken); + + Assert.Equal(0, notifications); + Assert.Equal(version, ctx.Version); + Assert.Single(ctx.GetMessages("username")); + } + + [Fact] + public void A_Rule_Flipping_Verdict_Notifies_Each_Way() + { + var ctx = new ValidationContext(); + var passing = true; + var rule = ValidationRule(() => passing, "Passwords must match", "confirm"); + + var notifications = 0; + ctx.Changed += () => notifications++; + + rule.Evaluate(ctx); + // One notification, for the field being registered — `rule.Evaluate` registers + // "confirm" the first time it runs. The verdict itself is passing, so no message + // is recorded; registration is the whole of the change (issue #1262 review). + Assert.Equal(1, notifications); + Assert.True(ctx.IsValid()); + + passing = false; + rule.Evaluate(ctx); + Assert.Equal(2, notifications); + Assert.False(ctx.IsValid()); + + passing = true; + rule.Evaluate(ctx); + Assert.Equal(3, notifications); + Assert.True(ctx.IsValid()); + + // The point of this test: re-evaluating a settled verdict stays silent, so the + // count above is not a repaint treadmill. + for (var i = 0; i < 5; i++) rule.Evaluate(ctx); + Assert.Equal(3, notifications); + } + + [Fact] + public void Changed_Is_Deferred_Until_The_Render_Pass_Ends() + { + var ctx = new ValidationContext(); + var notifications = 0; + ctx.Changed += () => notifications++; + + using (ValidationRenderScope.Begin(ctx)) + { + ctx.Add("email", "boom"); + ctx.MarkTouched("email"); + + // Nothing is announced mid-pass: the rendering component reads the new + // state later in the same pass, and notifying here would re-enter + // requestRerender from inside Render(). + Assert.Equal(0, notifications); + } + + // ...but the change is not dropped — other subscribers still need it. Two + // mutations, one delivery. + Assert.Equal(1, notifications); + + ctx.MarkTouched("password"); + Assert.Equal(2, notifications); + } + + [Fact] + public void A_Deferred_Notification_Reaches_A_Subscriber_That_Is_Not_The_Rendering_Component() + { + // The parent/child shape: a parent renders ctx.IsValid() and provides the + // context; the child's eager .Validate() invalidates it during the child's own + // render. Dropping that notification left the parent's summary stale forever. + var shared = new ValidationContext(); + var parentRerenders = 0; + + var parent = new RenderContext(); + parent.BeginRender(() => parentRerenders++); + var parentScope = new ContextScope(); + parentScope.Push(new Dictionary { [ValidationContexts.Current] = shared }); + parent.BeginRender(() => parentRerenders++, parentScope); + Assert.Same(shared, parent.UseValidationContext()); + parent.FlushEffects(); + Assert.True(shared.IsValid()); + + var before = parentRerenders; + + using (ValidationRenderScope.Begin(shared)) + { + _ = TextBox("").Validate("email", "", Validate.Required()); + Assert.Equal(before, parentRerenders); // not mid-pass + } + + Assert.False(shared.IsValid()); + Assert.True(parentRerenders > before); + } + + [Fact] + public void Nested_Frames_Defer_To_The_Outermost_Exit() + { + var ctx = new ValidationContext(); + var notifications = 0; + ctx.Changed += () => notifications++; + + using (ValidationRenderScope.Begin(ctx)) + { + using (ValidationRenderScope.Begin(ctx)) + { + ctx.Add("email", "boom"); + } + Assert.Equal(0, notifications); + } + + Assert.Equal(1, notifications); + } + + // ════════════════════════════════════════════════════════════════ + // Reset — must not notify when there is nothing to reset + // ════════════════════════════════════════════════════════════════ + + [Fact] + public void Resetting_An_Untouched_Unknown_Field_Is_Silent() + { + var ctx = new ValidationContext(); + var notifications = 0; + ctx.Changed += () => notifications++; + + ctx.Reset("never-seen"); + + // An effect that resets on every render would otherwise repaint forever. + Assert.Equal(0, notifications); + Assert.Equal(0, ctx.Version); + } + + [Fact] + public void Resetting_Real_State_Notifies_Once_Then_Goes_Quiet() + { + var ctx = new ValidationContext(); + ctx.SetInitialValue("email", "start@example.com"); + ctx.Add("email", "boom"); + ctx.MarkTouched("email"); + ctx.NotifyValueChanged("email", "changed@example.com"); + + var notifications = 0; + ctx.Changed += () => notifications++; + + ctx.Reset("email"); + Assert.Equal(1, notifications); + Assert.True(ctx.IsValid()); + Assert.False(ctx.IsTouched("email")); + + ctx.Reset("email"); + Assert.Equal(1, notifications); + } + + [Fact] + public void ResetAll_With_Nothing_To_Reset_Is_Silent() + { + var ctx = new ValidationContext(); + var notifications = 0; + ctx.Changed += () => notifications++; + + ctx.ResetAll(); + + Assert.Equal(0, notifications); + Assert.Equal(0, ctx.Version); + } + + // ════════════════════════════════════════════════════════════════ + // Change notification + // ════════════════════════════════════════════════════════════════ + + [Fact] + public void MarkAllTouched_Notifies_Once_And_Stays_Quiet_When_Already_Touched() + { + var ctx = new ValidationContext(); + ctx.RegisterField("email"); + ctx.RegisterField("password"); + + var notifications = 0; + ctx.Changed += () => notifications++; + + ctx.MarkAllTouched(); + Assert.Equal(1, notifications); + + // This is the submit-twice case. Without the real-change gate it would bump the + // version and repaint on every click forever. + ctx.MarkAllTouched(); + Assert.Equal(1, notifications); + } + + [Fact] + public void MarkAllTouched_With_No_Registered_Fields_Notifies_Once_For_The_Submit_Flag() + { + var ctx = new ValidationContext(); + var notifications = 0; + ctx.Changed += () => notifications++; + + // Contract change (issue #1262): the touched-set does not move here, but + // SubmitAttempted does, and that is observable state — ShowWhen.AfterFirstSubmit + // reads it, and a component can render it directly. Staying silent would leave an + // AfterFirstSubmit visualizer showing nothing after a submit it was told about. + ctx.MarkAllTouched(); + + Assert.Equal(1, notifications); + Assert.True(ctx.SubmitAttempted); + + // Still bounded: the flag only flips once, so submitting again is silent. This is + // the property the original version of this test was protecting. + ctx.MarkAllTouched(); + Assert.Equal(1, notifications); + } + + [Fact] + public void ClearAll_Notifies_Only_When_There_Was_Something_To_Clear() + { + var ctx = new ValidationContext(); + var notifications = 0; + ctx.Changed += () => notifications++; + + ctx.ClearAll(); + Assert.Equal(0, notifications); + + ctx.Add("email", "boom"); + var afterAdd = notifications; + ctx.ClearAll(); + Assert.Equal(afterAdd + 1, notifications); + } + + // ════════════════════════════════════════════════════════════════ + // External messages vs. per-render validation + // ════════════════════════════════════════════════════════════════ + + [Fact] + public void External_Messages_Survive_Revalidation_Of_An_Unchanged_Value() + { + var ctx = new ValidationContext(); + + using (ValidationRenderScope.Begin(ctx)) + _ = TextBox("").Validate("email", "taken@example.com", Validate.Required()); + + ctx.AddExternal("email", "Email already registered"); + + // A repaint for any unrelated reason re-runs .Validate(). Before the fix this + // called NotifyValueChanged unconditionally and wiped the server's verdict + // before the user could read it. + using (ValidationRenderScope.Begin(ctx)) + _ = TextBox("").Validate("email", "taken@example.com", Validate.Required()); + + Assert.Single(ctx.GetMessages("email")); + Assert.Equal("Email already registered", ctx.GetMessages("email")[0].Text); + Assert.False(ctx.IsValid()); + } + + [Fact] + public void External_Messages_Clear_Once_The_Value_Actually_Changes() + { + var ctx = new ValidationContext(); + + ctx.NotifyValueChanged("email", "taken@example.com"); + ctx.AddExternal("email", "Email already registered"); + Assert.Single(ctx.GetMessages("email")); + + ctx.NotifyValueChanged("email", "fresh@example.com"); + + Assert.Empty(ctx.GetMessages("email")); + } + + // ════════════════════════════════════════════════════════════════ + // Auto-provide + // ════════════════════════════════════════════════════════════════ + + [Fact] + public void A_Local_Context_Is_Published_To_The_Rendered_Subtree() + { + var render = new RenderContext(); + Element rendered; + ValidationContext resolved; + + using (ValidationRenderScope.Begin(null)) + { + render.BeginRender(() => { }); + resolved = render.UseValidationContext(); + rendered = ValidationRenderScope.ApplyProvide(TextBlock("body")); + } + + Assert.NotNull(rendered.ContextValues); + Assert.Same(resolved, rendered.ContextValues![ValidationContexts.Current]); + } + + [Fact] + public void An_Explicit_Provide_Reaches_Descendants_Not_The_Providers_Own_Eager_Validation() + { + var render = new RenderContext(); + var mine = new ValidationContext(); + Element rendered; + ValidationContext resolved; + + using (ValidationRenderScope.Begin(null)) + { + render.BeginRender(() => { }); + resolved = render.UseValidationContext(); + + // A bare control — nothing re-validates it later, so where this lands is + // observable rather than masked by FormField's reconcile-time pass. + var body = TextBox("").Validate("email", "", Validate.Required()); + rendered = ValidationRenderScope.ApplyProvide( + body.Provide(ValidationContexts.Current, mine)); + } + + // `.Provide` publishes to the SUBTREE. The providing component's own eager + // .Validate() already ran while the tree was being built, against the context + // its own hook resolved — matching how UseContext cannot see a value the same + // component provides. + Assert.False(resolved.IsValid()); + Assert.Single(resolved.GetMessages("email")); + Assert.True(mine.IsValid()); + Assert.Empty(mine.GetAllMessages()); + + // ...and the explicit value is what descendants will read. + Assert.Same(mine, rendered.ContextValues![ValidationContexts.Current]); + } + + [Fact] + public void An_Explicit_Provide_Is_What_A_Descendant_Resolves() + { + var mine = new ValidationContext(); + var scope = new ContextScope(); + scope.Push(new Dictionary { [ValidationContexts.Current] = mine }); + + // The descendant renders inside the provided scope, so its hook resolves `mine` + // and its eager .Validate() lands there. + var child = new RenderContext(); + using (ValidationRenderScope.Begin(mine)) + { + child.BeginRender(() => { }, scope); + Assert.Same(mine, child.UseValidationContext()); + _ = TextBox("").Validate("email", "", Validate.Required()); + } + + Assert.False(mine.IsValid()); + Assert.Single(mine.GetMessages("email")); + } + + [Fact] + public void An_Inherited_Context_Is_Not_Re_Provided() + { + var parent = new ValidationContext(); + var scope = new ContextScope(); + scope.Push(new Dictionary { [ValidationContexts.Current] = parent }); + + var render = new RenderContext(); + Element rendered; + + using (ValidationRenderScope.Begin(parent)) + { + render.BeginRender(() => { }, scope); + Assert.Same(parent, render.UseValidationContext()); + rendered = ValidationRenderScope.ApplyProvide(TextBlock("body")); + } + + // The ancestor already provides it; re-providing would only add allocation. + Assert.Null(rendered.ContextValues); + } + + [Fact] + public void UseValidationContext_Publishes_Its_Result_To_The_Active_Scope() + { + var render = new RenderContext(); + + using (ValidationRenderScope.Begin(null)) + { + render.BeginRender(() => { }); + var resolved = render.UseValidationContext(); + + Assert.Same(resolved, ValidationRenderScope.Current); + + // And from here on .Validate() in the same pass reaches it. + _ = TextBox("").Validate("email", "", Validate.Required()); + Assert.False(resolved.IsValid()); + } + } + + // ════════════════════════════════════════════════════════════════ + // Re-render subscription + // ════════════════════════════════════════════════════════════════ + + [Fact] + public void Mutating_The_Context_From_Outside_A_Render_Requests_A_Rerender() + { + var render = new RenderContext(); + var rerenders = 0; + + render.BeginRender(() => rerenders++); + var ctx = render.UseValidationContext(); + render.FlushEffects(); + ctx.RegisterField("email"); + + var before = rerenders; + + // The submit handler in the docs example does exactly this and nothing else — + // no state setter runs, so without the subscription nothing repaints. + ctx.MarkAllTouched(); + + Assert.True(rerenders > before); + } + + [Fact] + public void The_Rerender_Subscription_Is_Released_When_The_Component_Unmounts() + { + var render = new RenderContext(); + var rerenders = 0; + + render.BeginRender(() => rerenders++); + var ctx = render.UseValidationContext(); + render.FlushEffects(); + ctx.RegisterField("email"); + + render.RunCleanups(); + var after = rerenders; + + ctx.MarkAllTouched(); + + Assert.Equal(after, rerenders); + } + + [Fact] + public void A_Value_Change_Retracts_The_Async_Verdict_For_The_Old_Value() + { + var ctx = new ValidationContext(); + ctx.RegisterField("email"); + + var generation = ctx.BeginAsyncValidation("email"); + ctx.ApplyAsyncValidation("email", generation, [new ValidationMessage("email", "Already registered", Severity.Error, "TAKEN")]); + Assert.Single(ctx.GetMessages("email")); + + // The verdict was about the old value; it says nothing about the new one. + ctx.NotifyValueChanged("email", "someone-else@example.com"); + + Assert.Empty(ctx.GetMessages("email")); + } + + [Fact] + public void A_Value_Change_Retires_An_In_Flight_Async_Pass() + { + var ctx = new ValidationContext(); + ctx.RegisterField("email"); + + // Pass opened against the old value, still running. + var stale = ctx.BeginAsyncValidation("email"); + + ctx.NotifyValueChanged("email", "someone-else@example.com"); + + // It resolves afterwards and must not install a verdict about a value that + // is no longer on screen. + ctx.ApplyAsyncValidation("email", stale, [new ValidationMessage("email", "Already registered", Severity.Error, "TAKEN")]); + + Assert.Empty(ctx.GetMessages("email")); + } + + [Fact] + public async Task A_Value_Change_Retracts_An_Async_Rule_Verdict_Too() + { + var ctx = new ValidationContext(); + + var rule = ValidationRuleAsync(() => Task.FromResult(false), "End must follow start", "dates"); + await rule.EvaluateAsync(ctx, "rule#1", TestContext.Current.CancellationToken); + Assert.Single(ctx.GetMessages("dates")); + + // The rule owns its own producer key, not the field's plain async slot. + ctx.ApplyValidation("dates", "2026-01-02", []); + + Assert.Empty(ctx.GetMessages("dates")); + Assert.True(ctx.IsValid()); + } + + [Fact] + public async Task NotifyValueChanged_Retracts_An_Async_Rule_Verdict_Too() + { + var ctx = new ValidationContext(); + + var rule = ValidationRuleAsync(() => Task.FromResult(false), "End must follow start", "dates"); + await rule.EvaluateAsync(ctx, "rule#1", TestContext.Current.CancellationToken); + Assert.Single(ctx.GetMessages("dates")); + + ctx.NotifyValueChanged("dates", "2026-01-02"); + + Assert.Empty(ctx.GetMessages("dates")); + Assert.True(ctx.IsValid()); + } + + [Fact] + public async Task A_Value_Change_Retires_An_In_Flight_Async_Rule_Pass() + { + var ctx = new ValidationContext(); + var pending = new TaskCompletionSource(); + + var rule = ValidationRuleAsync(() => pending.Task, "End must follow start", "dates"); + var running = rule.EvaluateAsync(ctx, "rule#1", TestContext.Current.CancellationToken); + + ctx.NotifyValueChanged("dates", "2026-01-02"); + + pending.SetResult(false); + await running; + + Assert.Empty(ctx.GetMessages("dates")); + } + + [Fact] + public void A_Directly_Evaluated_Rule_Replaces_Its_Own_Message_When_The_Text_Changes() + { + var ctx = new ValidationContext(); + + // The message is interpolated, so it moves on every evaluation — the case that + // used to orphan the previous verdict under a message-derived producer key. + for (var start = 1; start <= 4; start++) + { + var rule = ValidationRule(() => false, $"Must be after day {start}", "end"); + rule.Evaluate(ctx); + } + + Assert.Single(ctx.GetMessages("end")); + Assert.Equal("Must be after day 4", ctx.GetMessages("end")[0].Text); + } + + [Fact] + public void A_Directly_Evaluated_Rule_Retracts_Once_It_Passes() + { + var ctx = new ValidationContext(); + + var ordered = false; + void RunRule() => ValidationRule(() => ordered, "Must be after start", "end").Evaluate(ctx); + + RunRule(); + Assert.Single(ctx.GetMessages("end")); + + // Same call site, now passing — it has to withdraw what it installed. + ordered = true; + RunRule(); + + Assert.Empty(ctx.GetMessages("end")); + } + + [Fact] + public void Two_Distinct_Rules_On_One_Field_Keep_Separate_Slots() + { + var ctx = new ValidationContext(); + + ValidationRule(() => false, "Range is closed", "dates").Evaluate(ctx); + ValidationRule(() => false, "Range is too long", "dates").Evaluate(ctx); + + Assert.Equal(2, ctx.GetMessages("dates").Count); + } + + [Fact] + public void A_Directly_Evaluated_Rule_Leaves_Sync_Field_Messages_Alone() + { + var ctx = new ValidationContext(); + + using (ValidationRenderScope.Begin(ctx)) + _ = TextBox("").Validate("end", "", Validate.Required()); + Assert.Single(ctx.GetMessages("end")); + + ValidationRule(() => false, "Must be after start", "end").Evaluate(ctx); + + // The old whole-field clear took the sync verdict with it. + Assert.Equal(2, ctx.GetMessages("end").Count); + } + + [Fact] + public void Evaluating_An_Async_Rule_Synchronously_Throws_Instead_Of_Passing_It() + { + var ctx = new ValidationContext(); + var rule = ValidationRuleAsync(() => Task.FromResult(false), "Name is taken", "name"); + + var ex = Assert.Throws(() => rule.Evaluate(ctx)); + + Assert.Contains("name", ex.Message, StringComparison.Ordinal); + // The silent failure mode was recording a passing verdict for a field that + // was never checked. + Assert.Empty(ctx.GetMessages("name")); + } + + [Fact] + public void The_Batch_Rule_Path_Rejects_An_Async_Rule_Too() + { + var ctx = new ValidationContext(); + + Assert.Throws(() => + ValidationReconciler.EvaluateRules( + ctx, + ValidationRule(() => false, "Sync rule", "a"), + ValidationRuleAsync(() => Task.FromResult(false), "Async rule", "b"))); + } + + [Fact] + public async Task The_Async_Batch_Path_Runs_Both_Kinds_Of_Rule() + { + var ctx = new ValidationContext(); + + await ValidationReconciler.EvaluateRulesAsync( + ctx, + ValidationRule(() => false, "Sync rule", "a"), + ValidationRuleAsync(() => Task.FromResult(false), "Async rule", "b")); + + Assert.Single(ctx.GetMessages("a")); + Assert.Single(ctx.GetMessages("b")); + Assert.Equal("Async rule", ctx.GetMessages("b")[0].Text); + } + + [Fact] + public async Task Retiring_A_Producer_Stops_Its_In_Flight_Pass_From_Installing() + { + var ctx = new ValidationContext(); + var pending = new TaskCompletionSource(); + + var rule = ValidationRuleAsync(() => pending.Task, "Name is taken", "name"); + var running = rule.EvaluateAsync(ctx, "rule#7", TestContext.Current.CancellationToken); + + // The rule leaves the tree while its check is still out. + ctx.RetireProducer("name", "rule#7"); + + pending.SetResult(false); + await running; + + Assert.Empty(ctx.GetMessages("name")); + } + + [Fact] + public async Task Retiring_A_Producer_Withdraws_What_It_Already_Installed() + { + var ctx = new ValidationContext(); + + var rule = ValidationRuleAsync(() => Task.FromResult(false), "Name is taken", "name"); + await rule.EvaluateAsync(ctx, "rule#7", TestContext.Current.CancellationToken); + Assert.Single(ctx.GetMessages("name")); + + ctx.RetireProducer("name", "rule#7"); + + Assert.Empty(ctx.GetMessages("name")); + Assert.True(ctx.IsValid()); + } + + [Fact] + public void A_Message_Whose_Text_Contains_The_Snapshot_Separators_Is_Still_Distinguished() + { + var ctx = new ValidationContext(); + var notifications = 0; + ctx.Changed += () => notifications++; + + using (ValidationRenderScope.Begin(ctx)) + _ = TextBox("").Validate("email", "", + Validate.Must(_ => false, "a"), + Validate.Must(_ => false, "b")); + + var two = ctx.GetMessages("email"); + Assert.Equal(2, two.Count); + Assert.Equal(1, notifications); + + // One message crafted to serialize exactly like those two under a + // separator-only encoding: it embeds the record separator and a second + // message header. Derived from the real metadata so it cannot drift. + var collider = $"a\u0003i\u0001{two[0].Severity}\u0001{two[0].Code}\u0001b"; + + using (ValidationRenderScope.Begin(ctx)) + _ = TextBox("").Validate("email", "", Validate.Must(_ => false, collider)); + + Assert.Single(ctx.GetMessages("email")); + Assert.Equal(2, notifications); + } + + [Fact] + public async Task The_Async_Field_Path_Registers_Its_Field() + { + var ctx = new ValidationContext(); + + await ValidationReconciler.ValidateFieldAsync( + ctx, "email", "", + [Validate.MustAsync(_ => Task.FromResult(false), "Already registered")], + TestContext.Current.CancellationToken); + + Assert.Contains("email", ctx.RegisteredFields); + + // The point of registering: MarkAllTouched() has to cover it. + ctx.MarkAllTouched(); + Assert.True(ctx.IsTouched("email")); + } + + [Fact] + public void The_Batch_Rule_Path_Registers_Every_Rule_Field() + { + var ctx = new ValidationContext(); + + ValidationReconciler.EvaluateRules( + ctx, + ValidationRule(() => false, "Range is closed", "start"), + ValidationRule(() => true, "Range is too long", "end")); + + Assert.Contains("start", ctx.RegisteredFields); + Assert.Contains("end", ctx.RegisteredFields); + } + + [Fact] + public async Task An_Overtaken_Async_Batch_Stands_Down_Instead_Of_Overwriting() + { + var ctx = new ValidationContext(); + var slow = new TaskCompletionSource(); + + // Older call for this set: blocks on field "a". + var older = ValidationReconciler.EvaluateRulesAsync( + ctx, "range-rules", ValidationRuleAsync(() => slow.Task, "Stale verdict", "a")); + + // Newer call for the same set, made while the older one is still out. + var newer = ValidationReconciler.EvaluateRulesAsync( + ctx, "range-rules", ValidationRuleAsync(() => Task.FromResult(false), "Current verdict", "b")); + + slow.SetResult(false); + await older; + await newer; + + // The older call stood down: it neither installed its own verdict nor retired + // the newer call's producer. + Assert.Empty(ctx.GetMessages("a")); + Assert.Single(ctx.GetMessages("b")); + Assert.Equal("Current verdict", ctx.GetMessages("b")[0].Text); + } + + [Fact] + public async Task Independent_Rule_Sets_Do_Not_Retract_Each_Other() + { + var ctx = new ValidationContext(); + + // Two unrelated callers sharing one context. Neither owns the other's rules. + ValidationReconciler.EvaluateRules(ctx, ValidationRule(() => false, "A failed", "a")); + ValidationReconciler.EvaluateRules(ctx, ValidationRule(() => false, "B failed", "b")); + + Assert.Single(ctx.GetMessages("a")); + Assert.Single(ctx.GetMessages("b")); + + // Named sets are scoped to their own id, so they are independent too. + await ValidationReconciler.EvaluateRulesAsync( + ctx, "set-one", ValidationRuleAsync(() => Task.FromResult(false), "C failed", "c")); + await ValidationReconciler.EvaluateRulesAsync( + ctx, "set-two", ValidationRuleAsync(() => Task.FromResult(false), "D failed", "d")); + + Assert.Single(ctx.GetMessages("c")); + Assert.Single(ctx.GetMessages("d")); + Assert.Single(ctx.GetMessages("a")); + } + + [Fact] + public void Two_Producers_With_Identical_Messages_Stay_Independent() + { + var ctx = new ValidationContext(); + + var firstPasses = false; + void RunFirst() => ValidationRule(() => firstPasses, "Range is invalid", "dates").Evaluate(ctx); + void RunSecond() => ValidationRule(() => false, "Range is invalid", "dates").Evaluate(ctx); + + RunFirst(); + RunSecond(); + Assert.Equal(2, ctx.GetMessages("dates").Count); + + // The first rule now passes. The second still fails, and its verdict happens to + // be word-for-word identical — retracting one must not take the other with it. + firstPasses = true; + RunFirst(); + + Assert.Single(ctx.GetMessages("dates")); + Assert.Equal("Range is invalid", ctx.GetMessages("dates")[0].Text); + Assert.False(ctx.IsValid()); + + // And the survivor is still owned, so it retracts when *it* passes. + RunSecond(); + Assert.Single(ctx.GetMessages("dates")); + } + + [Fact] + public async Task A_Sync_Call_Overtakes_A_Running_Async_Call_For_The_Same_Set() + { + var ctx = new ValidationContext(); + var slow = new TaskCompletionSource(); + + var older = ValidationReconciler.EvaluateRulesAsync( + ctx, "range-rules", ValidationRuleAsync(() => slow.Task, "Stale verdict", "a")); + + // A synchronous call for the same set while the async one is still out. It + // advances that set's generation, so the async call must stand down. + ValidationReconciler.EvaluateRules( + ctx, "range-rules", ValidationRule(() => false, "Current verdict", "b")); + + slow.SetResult(false); + await older; + + Assert.Empty(ctx.GetMessages("a")); + Assert.Single(ctx.GetMessages("b")); + Assert.Equal("Current verdict", ctx.GetMessages("b")[0].Text); + } + + [Fact] + public async Task A_Never_Completing_Rule_Does_Not_Block_Later_Batches() + { + var ctx = new ValidationContext(); + var never = new TaskCompletionSource(); + + // Deliberately never completed: a caller's predicate can hang. + var stuck = ValidationReconciler.EvaluateRulesAsync( + ctx, "range-rules", ValidationRuleAsync(() => never.Task, "Never resolves", "a")); + + // A later evaluation has to make progress regardless. + await ValidationReconciler.EvaluateRulesAsync( + ctx, "other-rules", ValidationRuleAsync(() => Task.FromResult(false), "Current verdict", "b")); + + Assert.Single(ctx.GetMessages("b")); + Assert.False(stuck.IsCompleted); + } + + [Fact] + public async Task A_Set_Producer_That_Turns_Synchronous_Keeps_Its_Verdict() + { + var ctx = new ValidationContext(); + + await ValidationReconciler.EvaluateRulesAsync( + ctx, "name-rules", ValidationRuleAsync(() => Task.FromResult(false), "Rule failed", "name")); + Assert.Single(ctx.GetMessages("name")); + + // Same position in the same set, now a synchronous rule — so the same producer. + await ValidationReconciler.EvaluateRulesAsync( + ctx, "name-rules", ValidationRule(() => false, "Rule failed", "name")); + Assert.Single(ctx.GetMessages("name")); + + // A value change retires async producers; this one is no longer async. + ctx.NotifyValueChanged("name", "anything"); + + Assert.Single(ctx.GetMessages("name")); + } + + [Fact] + public void A_Rule_Set_Commits_Atomically_Against_A_Reentrant_Evaluation() + { + var ctx = new ValidationContext(); + var reentered = false; + + ctx.Changed += () => + { + if (reentered) return; + reentered = true; + + // A subscriber woken mid-commit re-evaluates the same set. The outer call + // must not go on installing producers it no longer owns. + ValidationReconciler.EvaluateRules( + ctx, "form-rules", ValidationRule(() => true, "Inner rule", "a")); + }; + + ValidationReconciler.EvaluateRules( + ctx, "form-rules", + ValidationRule(() => false, "Outer first", "a"), + ValidationRule(() => false, "Outer second", "b")); + + Assert.True(reentered); + + // The inner call is the newest for this set, and it owns the set: nothing from + // the outer call may be left orphaned. + Assert.Empty(ctx.GetMessages("a")); + Assert.Empty(ctx.GetMessages("b")); + Assert.True(ctx.IsValid()); + } + + [Fact] + public async Task The_Async_Field_Helper_Records_The_Value_It_Validated() + { + var ctx = new ValidationContext(); + ctx.SetInitialValue("email", ""); + + await ValidationReconciler.ValidateFieldAsync( + ctx, "email", "someone@example.com", + [Validate.MustAsync(_ => Task.FromResult(true), "Already registered")], + TestContext.Current.CancellationToken); + + // Recording the value is what makes dirty tracking work without a separate + // NotifyValueChanged call. + Assert.True(ctx.IsDirty("email")); + } + + [Fact] + public async Task The_Async_Field_Helper_Retires_The_Verdict_For_The_Previous_Value() + { + var ctx = new ValidationContext(); + + await ValidationReconciler.ValidateFieldAsync( + ctx, "email", "taken@example.com", + [Validate.MustAsync(_ => Task.FromResult(false), "Already registered")], + TestContext.Current.CancellationToken); + Assert.Single(ctx.GetMessages("email")); + + // Re-invoked on a new value with a check that never resolves: the error about + // the old value must not stay on screen while it is pending. + var pending = new TaskCompletionSource(); + var running = ValidationReconciler.ValidateFieldAsync( + ctx, "email", "free@example.com", + [Validate.MustAsync(_ => pending.Task, "Already registered")], + TestContext.Current.CancellationToken); + + Assert.Empty(ctx.GetMessages("email")); + + pending.SetResult(true); + await running; + Assert.Empty(ctx.GetMessages("email")); + } + + [Fact] + public async Task A_Sync_Evaluation_Retires_An_In_Flight_Async_Pass_For_The_Same_Rule() + { + var ctx = new ValidationContext(); + var pending = new TaskCompletionSource(); + + var asyncRule = ValidationRuleAsync(() => pending.Task, "Async verdict", "dates"); + var running = asyncRule.EvaluateAsync(ctx, "rule#3", TestContext.Current.CancellationToken); + + // The same producer is evaluated synchronously while the async pass is out. + ValidationRule(() => false, "Sync verdict", "dates").Evaluate(ctx, "rule#3"); + Assert.Equal("Sync verdict", ctx.GetMessages("dates")[0].Text); + + // The late async result must not overwrite the newer synchronous one. + pending.SetResult(false); + await running; + + Assert.Single(ctx.GetMessages("dates")); + Assert.Equal("Sync verdict", ctx.GetMessages("dates")[0].Text); + } + + [Fact] + public void Two_Rules_Sharing_A_Predicate_Keep_Separate_Slots() + { + var ctx = new ValidationContext(); + + // Both rules use the *same* predicate method, so a method-only identity would + // collapse them into one slot and let one retract the other. + static bool RangeValid() => false; + + ValidationReconciler.EvaluateRules( + ctx, + ValidationRule(RangeValid, "Range is closed", "dates"), + ValidationRule(RangeValid, "Range is too long", "dates")); + + Assert.Equal(2, ctx.GetMessages("dates").Count); + + var texts = ctx.GetMessages("dates").Select(m => m.Text).ToList(); + Assert.Contains("Range is closed", texts); + Assert.Contains("Range is too long", texts); + } + + [Fact] + public async Task A_Hung_Async_Rule_Releases_Its_Evaluation_When_Cancelled() + { + var ctx = new ValidationContext(); + var never = new TaskCompletionSource(); + using var cts = new global::System.Threading.CancellationTokenSource(); + + var rule = ValidationRuleAsync(() => never.Task, "Never resolves", "name"); + var running = rule.EvaluateAsync(ctx, "rule#9", cts.Token); + + // The predicate takes no token, so only an explicitly cancellation-aware await + // can release the evaluation — and with it the context it would otherwise hold. + cts.Cancel(); + + await Assert.ThrowsAnyAsync(() => running); + Assert.False(never.Task.IsCompleted); + Assert.Empty(ctx.GetMessages("name")); + } + + [Fact] + public void External_Errors_Survive_Revalidating_The_Same_Value() + { + var ctx = new ValidationContext(); + + ValidationReconciler.ValidateField(ctx, "email", "user@example.com", Validate.Required()); + ctx.AddExternal("email", "Email already registered"); + + // Re-running the same validators over an unchanged value says nothing new about + // the server's verdict, so it must not wipe it. + ValidationReconciler.ValidateField(ctx, "email", "user@example.com", Validate.Required()); + + var texts = ctx.GetMessages("email").Select(m => m.Text).ToList(); + Assert.Single(texts); + Assert.Contains("Email already registered", texts); + + // A real value change does clear it. + ValidationReconciler.ValidateField(ctx, "email", "other@example.com", Validate.Required()); + Assert.Empty(ctx.GetMessages("email")); + } + + [Fact] + public async Task A_Hung_Batch_Releases_Its_Evaluation_When_Cancelled() + { + var ctx = new ValidationContext(); + var never = new TaskCompletionSource(); + using var cts = new global::System.Threading.CancellationTokenSource(); + + var running = ValidationReconciler.EvaluateRulesAsync( + ctx, cts.Token, ValidationRuleAsync(() => never.Task, "Never resolves", "a")); + + cts.Cancel(); + + await Assert.ThrowsAnyAsync(() => running); + Assert.False(never.Task.IsCompleted); + Assert.Empty(ctx.GetMessages("a")); + } + + [Fact] + public async Task A_Hung_Named_Set_Releases_Its_Evaluation_And_Installs_Nothing() + { + var ctx = new ValidationContext(); + var never = new TaskCompletionSource(); + using var cts = new global::System.Threading.CancellationTokenSource(); + + var running = ValidationReconciler.EvaluateRulesAsync( + ctx, "range-rules", cts.Token, + ValidationRuleAsync(() => Task.FromResult(false), "Resolved verdict", "a"), + ValidationRuleAsync(() => never.Task, "Never resolves", "b")); + + cts.Cancel(); + + await Assert.ThrowsAnyAsync(() => running); + + // Verdicts are computed before any is committed, so a cancelled set installs + // nothing at all — not even the rule that had already resolved. + Assert.Empty(ctx.GetMessages("a")); + Assert.Empty(ctx.GetMessages("b")); + } + + [Fact] + public void Two_Callers_Sharing_A_Named_Predicate_Need_A_Set_Id() + { + static bool RangeValid() => false; + + // Same named method, same field, same position: the only caller-derived + // component of a direct key is the predicate's method, so these two callers + // land in one slot and the second replaces the first. + var shared = new ValidationContext(); + ValidationReconciler.EvaluateRules(shared, ValidationRule(RangeValid, "Caller A", "dates")); + ValidationReconciler.EvaluateRules(shared, ValidationRule(RangeValid, "Caller B", "dates")); + + Assert.Single(shared.GetMessages("dates")); + Assert.Equal("Caller B", shared.GetMessages("dates")[0].Text); + + // A set id is the documented way to keep them apart. + var scoped = new ValidationContext(); + ValidationReconciler.EvaluateRules(scoped, "caller-a", ValidationRule(RangeValid, "Caller A", "dates")); + ValidationReconciler.EvaluateRules(scoped, "caller-b", ValidationRule(RangeValid, "Caller B", "dates")); + + var texts = scoped.GetMessages("dates").Select(m => m.Text).ToList(); + Assert.Equal(2, texts.Count); + Assert.Contains("Caller A", texts); + Assert.Contains("Caller B", texts); + } + + [Fact] + public async Task A_Value_Change_Discards_A_Pending_Rule_Set_Verdict() + { + var ctx = new ValidationContext(); + var pending = new TaskCompletionSource(); + + var running = ValidationReconciler.EvaluateRulesAsync( + ctx, "range-rules", ValidationRuleAsync(() => pending.Task, "Range is invalid", "dates")); + + // The field moves on while the predicate is still out, so the verdict it is + // about to produce describes a value that no longer exists. + ctx.NotifyValueChanged("dates", "2026-02-02"); + + pending.SetResult(false); + await running; + + Assert.Empty(ctx.GetMessages("dates")); + Assert.True(ctx.IsValid()); + } + + [Fact] + public async Task A_Rule_Set_Notification_Can_Re_Enter_Evaluation() + { + var ctx = new ValidationContext(); + var reentered = false; + + ctx.Changed += () => + { + if (reentered) return; + reentered = true; + + // A handler that evaluates another named set must not be blocked by a lock + // the committing call is still holding. + ValidationReconciler.EvaluateRules( + ctx, "other-rules", ValidationRule(() => false, "Other failed", "b")); + }; + + await ValidationReconciler.EvaluateRulesAsync( + ctx, "range-rules", ValidationRuleAsync(() => Task.FromResult(false), "Range failed", "a")); + + Assert.True(reentered); + Assert.Single(ctx.GetMessages("a")); + Assert.Single(ctx.GetMessages("b")); + } + + [Fact] + public void A_Validator_Only_Attachment_Is_Not_Validated_Against_Null() + { + var ctx = new ValidationContext(); + + // The validator-only overload never supplies a value, so running it would + // validate null and report "required" for a control that has text. + var attached = TextBox("Alice").Validate("name", Validate.Required()).GetValidation(); + Assert.NotNull(attached); + Assert.False(attached.HasValue); + + // The value overloads do supply one, including a legitimately null value. + var withValue = TextBox("").Validate("name", "", Validate.Required()).GetValidation(); + Assert.NotNull(withValue); + Assert.True(withValue.HasValue); + + var withNull = TextBox("").Validate("name", (object?)null, Validate.Required()).GetValidation(); + Assert.NotNull(withNull); + Assert.True(withNull.HasValue); + } + + [Fact] + public async Task A_Set_That_Turns_Synchronous_Keeps_Its_Verdict_Across_A_Value_Change() + { + var ctx = new ValidationContext(); + + await ValidationReconciler.EvaluateRulesAsync( + ctx, "name-rules", ValidationRuleAsync(() => Task.FromResult(false), "Rule failed", "name")); + Assert.Single(ctx.GetMessages("name")); + + // Same set, same position, now evaluated through the synchronous overload. + ValidationReconciler.EvaluateRules( + ctx, "name-rules", ValidationRule(() => false, "Rule failed", "name")); + Assert.Single(ctx.GetMessages("name")); + + // A value change retires async producers; this one is no longer async. + ctx.NotifyValueChanged("name", "anything"); + + Assert.Single(ctx.GetMessages("name")); + } + + [Fact] + public void A_Validator_Only_Call_Appended_To_A_Value_Chain_Still_Runs() + { + var ctx = new ValidationContext(); + + using (ValidationRenderScope.Begin(ctx)) + { + _ = TextBox("ab") + .Validate("code", "ab", Validate.Required()) + .Validate("code", Validate.MinLength(3, "Too short")); + } + + // Without re-running the merged set, only the Required verdict would exist and + // MinLength would be silently dropped for a bare control. + var texts = ctx.GetMessages("code").Select(m => m.Text).ToList(); + Assert.Single(texts); + Assert.Contains("Too short", texts); + } + + [Fact] + public void A_Standalone_Validator_Only_Call_Stays_Attach_Only() + { + var ctx = new ValidationContext(); + + using (ValidationRenderScope.Begin(ctx)) + _ = TextBox("").Validate("code", Validate.Required()); + + Assert.Empty(ctx.GetAllMessages()); + } + + [Fact] + public async Task A_Failed_Sync_Evaluation_Leaves_The_Async_Generation_Intact() + { + var ctx = new ValidationContext(); + + // A verdict installed asynchronously under this producer. + var rule = ValidationRuleAsync(() => Task.FromResult(false), "Name is taken", "name"); + await rule.EvaluateAsync(ctx, "rule#5", TestContext.Current.CancellationToken); + Assert.Single(ctx.GetMessages("name")); + + // Evaluating the same async rule synchronously is a caller error and throws. + Assert.Throws(() => rule.Evaluate(ctx, "rule#5")); + + // The throw must not have stripped the generation, or nothing could ever + // retract the message it left behind. + ctx.NotifyValueChanged("name", "someone-else"); + + Assert.Empty(ctx.GetMessages("name")); + Assert.True(ctx.IsValid()); + } + + [Fact] + public async Task Clearing_A_Field_Retires_A_Pending_Set_That_Also_Writes_It() + { + var ctx = new ValidationContext(); + var pending = new TaskCompletionSource(); + + // First evaluation records the set's membership over both fields. + await ValidationReconciler.EvaluateRulesAsync( + ctx, "form-rules", + ValidationRuleAsync(() => Task.FromResult(false), "A failed", "a"), + ValidationRule(() => false, "B failed", "b")); + Assert.Single(ctx.GetMessages("b")); + + // Second evaluation hangs on field "a". + var running = ValidationReconciler.EvaluateRulesAsync( + ctx, "form-rules", + ValidationRuleAsync(() => pending.Task, "A failed", "a"), + ValidationRule(() => false, "B failed", "b")); + + // Clearing "b" retires no async token — the only one belongs to "a" — so the + // ticket is what has to stop this set reinstalling the verdict just removed. + ctx.Clear("b"); + Assert.Empty(ctx.GetMessages("b")); + + pending.SetResult(false); + await running; + + Assert.Empty(ctx.GetMessages("b")); + } + + [Fact] + public async Task ClearAll_Retires_A_Pending_Rule_Set_Commit() + { + var ctx = new ValidationContext(); + var pending = new TaskCompletionSource(); + + var running = ValidationReconciler.EvaluateRulesAsync( + ctx, "range-rules", + ValidationRuleAsync(() => pending.Task, "Range is invalid", "dates"), + ValidationRule(() => false, "Order is wrong", "dates")); + + ctx.ClearAll(); + + pending.SetResult(false); + await running; + + // The evaluation was in flight when the context was cleared; it must not + // reinstall what the clear removed. + Assert.Empty(ctx.GetMessages("dates")); + Assert.True(ctx.IsValid()); + } + + [Fact] + public async Task Clearing_One_Field_Retires_Only_The_Sets_That_Write_It() + { + var ctx = new ValidationContext(); + var pending = new TaskCompletionSource(); + + // Establish membership for both sets so the field filter has something to read. + await ValidationReconciler.EvaluateRulesAsync( + ctx, "other-rules", ValidationRuleAsync(() => Task.FromResult(false), "Other failed", "other")); + + var running = ValidationReconciler.EvaluateRulesAsync( + ctx, "other-rules", ValidationRuleAsync(() => pending.Task, "Other failed again", "other")); + + // A clear on an unrelated field must not stand this evaluation down. + ctx.Clear("unrelated"); + + pending.SetResult(false); + await running; + + Assert.Single(ctx.GetMessages("other")); + Assert.Equal("Other failed again", ctx.GetMessages("other")[0].Text); + } + + [Fact] + public async Task A_Value_Change_Retires_A_Pending_Set_With_A_Sync_Rule_On_That_Field() + { + var ctx = new ValidationContext(); + var pending = new TaskCompletionSource(); + + // Membership first, so the field filter has something to match on. + await ValidationReconciler.EvaluateRulesAsync( + ctx, "form-rules", + ValidationRule(() => false, "B failed (first pass)", "b"), + ValidationRuleAsync(() => Task.FromResult(false), "A failed", "a")); + Assert.Equal("B failed (first pass)", ctx.GetMessages("b")[0].Text); + + // Sync rule first, blocked async rule second — the sync verdict for "b" is + // computed before the await and carries no generation of its own. + var running = ValidationReconciler.EvaluateRulesAsync( + ctx, "form-rules", + ValidationRule(() => false, "B failed (second pass)", "b"), + ValidationRuleAsync(() => pending.Task, "A failed", "a")); + + // "b" moves on while the second rule is still out, so the verdict already + // computed for it describes a value that no longer exists. + ctx.NotifyValueChanged("b", "new value"); + + pending.SetResult(false); + await running; + + // The set stood down: the second pass's verdict was never installed. + Assert.Single(ctx.GetMessages("b")); + Assert.Equal("B failed (first pass)", ctx.GetMessages("b")[0].Text); + } + + [Fact] + public void A_Value_Change_Leaves_Sync_Messages_For_The_New_Value_Intact() + { + var ctx = new ValidationContext(); + + using (ValidationRenderScope.Begin(ctx)) + _ = TextBox("").Validate("email", "", Validate.Required()); + + Assert.Single(ctx.GetMessages("email")); + + // Retiring the async producer must not take the sync verdict with it. + ctx.NotifyValueChanged("email", ""); + ctx.NotifyValueChanged("email", " "); + + Assert.Single(ctx.GetMessages("email")); + Assert.Equal("REQUIRED", ctx.GetMessages("email")[0].Code); + } + + [Fact] + public void A_Reconcile_Frame_Defers_Notifications_Raised_Between_Renders() + { + var ctx = new ValidationContext(); + var notified = 0; + ctx.Changed += () => notified++; + + using (ValidationRenderScope.BeginReconcile()) + { + // No component is rendering, so .Validate() must still be attach-only. + Assert.Null(ValidationRenderScope.Current); + + // This is what a rule unmounting mid-reconcile does. + ctx.Add("dates", "End must follow start"); + Assert.Equal(0, notified); + } + + Assert.Equal(1, notified); + } + + [Fact] + public void A_Deferred_Notification_Reaches_A_Subscriber_That_Arrives_After_The_Flush() + { + var ctx = new ValidationContext(); + var notified = 0; + + using (ValidationRenderScope.Begin(ctx)) + _ = TextBox("").Validate("email", "", Validate.Required()); + + // The frame has closed and the deferral already flushed — a host flushes root + // effects only after reconciliation, so the parent subscribes at about here. + ctx.Changed += () => notified++; + + Assert.Equal(1, notified); + } + + [Fact] + public void A_Held_Notification_Is_Delivered_Once_Not_Per_Subscriber() + { + var ctx = new ValidationContext(); + int first = 0, second = 0; + + using (ValidationRenderScope.Begin(ctx)) + _ = TextBox("").Validate("email", "", Validate.Required()); + + ctx.Changed += () => first++; + ctx.Changed += () => second++; + + Assert.Equal(1, first); + Assert.Equal(0, second); + } + + [Fact] + public async Task Overlapping_Async_Rule_Evaluations_Discard_The_Older_Result() + { + var ctx = new ValidationContext(); + var slow = new TaskCompletionSource(); + var quick = new TaskCompletionSource(); + + var failing = ValidationRuleAsync(() => slow.Task, "End must follow start", "dates"); + var passing = failing with { AsyncPredicate = () => quick.Task }; + + // Same producer: two evaluations of one mounted rule, overlapping. + var older = failing.EvaluateAsync(ctx, "rule#1", TestContext.Current.CancellationToken); + var newer = passing.EvaluateAsync(ctx, "rule#1", TestContext.Current.CancellationToken); + + quick.SetResult(true); + await newer; + Assert.Empty(ctx.GetMessages("dates")); + + // The older run resolves last and must not reinstate its verdict. + slow.SetResult(false); + await older; + + Assert.Empty(ctx.GetMessages("dates")); + } + + [Fact] + public async Task An_Async_Rule_Still_Applies_Its_Own_Newest_Result() + { + var ctx = new ValidationContext(); + + var rule = ValidationRuleAsync(() => Task.FromResult(false), "End must follow start", "dates"); + await rule.EvaluateAsync(ctx, "rule#1", TestContext.Current.CancellationToken); + + Assert.Single(ctx.GetMessages("dates")); + Assert.Equal("End must follow start", ctx.GetMessages("dates")[0].Text); + } + + [Fact] + public void Chained_Value_Overloads_Settle_Instead_Of_Repainting_Forever() + { + var ctx = new ValidationContext(); + var notifications = 0; + ctx.Changed += () => notifications++; + + // Each call in the chain eagerly applies its own intermediate validator set + // under the same producer, so every pass removes the later message and puts it + // straight back. The net state never moves, and announcing that churn would + // schedule another render that churns identically — forever. + for (var pass = 0; pass < 5; pass++) + { + using (ValidationRenderScope.Begin(ctx)) + { + _ = TextBox("abc") + .Validate("email", "abc", Validate.Email()) + .Validate("email", "abc", Validate.MinLength(10)); + } + } + + Assert.Equal(2, ctx.GetMessages("email").Count); + Assert.Equal(1, notifications); + } + + [Fact] + public void A_Net_Zero_Pass_Leaves_Version_Alone() + { + var ctx = new ValidationContext(); + + using (ValidationRenderScope.Begin(ctx)) + { + _ = TextBox("abc") + .Validate("email", "abc", Validate.Email()) + .Validate("email", "abc", Validate.MinLength(10)); + } + + var settled = ctx.Version; + + // Version is documented for change detection in hooks and memos, so a pass that + // churns and lands where it started must not read as a change. + for (var pass = 0; pass < 4; pass++) + { + using (ValidationRenderScope.Begin(ctx)) + { + _ = TextBox("abc") + .Validate("email", "abc", Validate.Email()) + .Validate("email", "abc", Validate.MinLength(10)); + } + } + + Assert.Equal(settled, ctx.Version); + } + + [Fact] + public void A_Real_Change_During_A_Render_Still_Moves_Version() + { + var ctx = new ValidationContext(); + + using (ValidationRenderScope.Begin(ctx)) + _ = TextBox("").Validate("email", "", Validate.Required()); + var settled = ctx.Version; + + using (ValidationRenderScope.Begin(ctx)) + _ = TextBox("a@b.co").Validate("email", "a@b.co", Validate.Required()); + + Assert.Empty(ctx.GetMessages("email")); + Assert.True(ctx.Version > settled, $"settled={settled} now={ctx.Version}"); + } + + [Fact] + public void Reordering_A_Field_Messages_Is_Announced() + { + var ctx = new ValidationContext(); + var notifications = 0; + ctx.Changed += () => notifications++; + + using (ValidationRenderScope.Begin(ctx)) + _ = TextBox("abc") + .Validate("email", "abc", Validate.Email("A"), Validate.MinLength(10, "B")); + Assert.Equal(1, notifications); + Assert.Equal("A", ctx.GetMessages("email")[0].Text); + + // Same set, different order. GetMessages exposes order and callers read the + // first message, so this is a real change. + using (ValidationRenderScope.Begin(ctx)) + _ = TextBox("abc") + .Validate("email", "abc", Validate.MinLength(10, "B"), Validate.Email("A")); + + Assert.Equal("B", ctx.GetMessages("email")[0].Text); + Assert.Equal(2, notifications); + } + + [Fact] + public void A_Chained_Chain_Still_Announces_A_Real_Change() + { + var ctx = new ValidationContext(); + var notifications = 0; + ctx.Changed += () => notifications++; + + using (ValidationRenderScope.Begin(ctx)) + { + _ = TextBox("abc") + .Validate("email", "abc", Validate.Email()) + .Validate("email", "abc", Validate.MinLength(10)); + } + Assert.Equal(1, notifications); + + // The user fixes the value: the suppression must not swallow this. + using (ValidationRenderScope.Begin(ctx)) + { + _ = TextBox("someone@example.com") + .Validate("email", "someone@example.com", Validate.Email()) + .Validate("email", "someone@example.com", Validate.MinLength(10)); + } + + Assert.Empty(ctx.GetMessages("email")); + Assert.Equal(2, notifications); + } + + [Fact] + public void Suppression_Does_Not_Swallow_A_Touch_Made_During_A_Net_Zero_Pass() + { + var ctx = new ValidationContext(); + var notifications = 0; + ctx.Changed += () => notifications++; + + using (ValidationRenderScope.Begin(ctx)) + _ = TextBox("").Validate("email", "", Validate.Required()); + Assert.Equal(1, notifications); + + // Same messages as last time, but a field also became touched — non-message + // state, so the pass is not net-zero. + using (ValidationRenderScope.Begin(ctx)) + { + _ = TextBox("").Validate("email", "", Validate.Required()); + ctx.MarkTouched("email"); + } + + Assert.True(ctx.IsTouched("email")); + Assert.Equal(2, notifications); + } + + [Fact] + public async Task Async_Producers_On_One_Field_Do_Not_Cancel_Each_Other() + { + var ctx = new ValidationContext(); + var closed = new TaskCompletionSource(); + var tooLong = new TaskCompletionSource(); + + var ruleA = ValidationRuleAsync(() => closed.Task, "Range is closed", "dates"); + var ruleB = ValidationRuleAsync(() => tooLong.Task, "Range is too long", "dates"); + + // Both passes are open before either applies — a token shared across the field + // would let B's Begin retire A's still-pending pass. + var a = ruleA.EvaluateAsync(ctx, "rule#1", TestContext.Current.CancellationToken); + var b = ruleB.EvaluateAsync(ctx, "rule#2", TestContext.Current.CancellationToken); + + closed.SetResult(false); + await a; + tooLong.SetResult(false); + await b; + + Assert.Equal(2, ctx.GetMessages("dates").Count); + } + // ════════════════════════════════════════════════════════════════ + // Producer ownership stamps (issue #1262 review) + // ════════════════════════════════════════════════════════════════ + + [Fact] + public void RetiredProducersDoNotAccumulateStamps() + { + var ctx = new ValidationContext(); + + // Every mounted rule gets a fresh identity, so a long-lived context sees an + // unbounded number of producers come and go over its lifetime. + for (var i = 0; i < 200; i++) + { + var producer = $"rule#{i}"; + ctx.ApplyOwned("form", producer, + [new ValidationMessage("form", $"failed {i}")]); + Assert.Equal(1, ctx.ProducerStampEntryCount); + + ctx.RetireProducer("form", producer); + Assert.Equal(0, ctx.ProducerStampEntryCount); + } + + Assert.Empty(ctx.GetMessages("form")); + } + + [Fact] + public void ProducerStampSurvivesAnUnchangedRepublish() + { + var ctx = new ValidationContext(); + ValidationMessage Message() => new("form", "failed"); + + ctx.ApplyOwned("form", "a", [Message()]); + var first = ctx.GetProducerStamp("form", "a"); + + // A pass that reproduces the same verdict still makes its writer the current + // owner, so the stamp moves even though the messages did not. + ctx.ApplyOwned("form", "a", [Message()]); + var second = ctx.GetProducerStamp("form", "a"); + + Assert.NotEqual(0, first); + Assert.True(second > first, $"first={first} second={second}"); + Assert.Equal(1, ctx.ProducerStampEntryCount); + } + + [Fact] + public void StampedRetireIsIgnoredOnceAnotherWriterOwnsTheSlot() + { + var ctx = new ValidationContext(); + + ctx.ApplyOwned("form", "sync", [new ValidationMessage("form", "outgoing")]); + var outgoing = ctx.GetProducerStamp("form", "sync"); + + // The incoming control installs its verdict before the outgoing one is torn + // down, and both write the same slot. + ctx.ApplyOwned("form", "sync", [new ValidationMessage("form", "incoming")]); + + ctx.RetireProducer("form", "sync", outgoing); + + var remaining = ctx.GetMessages("form"); + Assert.Single(remaining); + Assert.Equal("incoming", remaining[0].Text); + } + [Fact] + public void ReBaseliningDuringRenderStillNotifies() + { + var ctx = new ValidationContext(); + ctx.SetInitialValue("f", "a"); + ctx.NotifyValueChanged("f", "b"); + Assert.True(ctx.IsDirty("f")); + + var notifications = 0; + ctx.Changed += () => notifications++; + + // Establish a delivered snapshot; net-zero suppression only compares against + // one that exists. + using (ValidationRenderScope.BeginReconcile()) + { + ctx.AddExternal("f", "seed"); + } + var afterSeed = notifications; + + // Re-baselining an edited field flips IsDirty without touching messages, + // touched flags or the current value — the only thing that moves is the + // baseline itself, so a snapshot that omits it reports "nothing changed" and + // the notification is dropped. + using (ValidationRenderScope.BeginReconcile()) + { + ctx.SetInitialValue("f", "b"); + } + + Assert.False(ctx.IsDirty("f")); + Assert.True(notifications > afterSeed, $"afterSeed={afterSeed} now={notifications}"); + } + // ════════════════════════════════════════════════════════════════ + // Version stability and batch atomicity (issue #1262 review) + // ════════════════════════════════════════════════════════════════ + + [Fact] + public void NetZeroValueChurnDoesNotAdvanceVersion() + { + var ctx = new ValidationContext(); + + // Two links on one field carrying different values: the current value is + // rewritten twice per pass and lands where it started. Changed is already + // suppressed for this; Version has to be too, or a UseMemo keyed on it re-runs + // forever for a context that never moved. + void Pass() + { + using (ValidationRenderScope.BeginReconcile()) + { + ctx.ApplyValidation("f", "", [new ValidationMessage("f", "required")]); + ctx.ApplyValidation("f", "bb", []); + } + } + + Pass(); + var settled = ctx.Version; + for (var i = 0; i < 5; i++) Pass(); + + Assert.Equal(settled, ctx.Version); + } + + [Fact] + public void RealChangeDuringRenderStillAdvancesVersionOnce() + { + var ctx = new ValidationContext(); + + using (ValidationRenderScope.BeginReconcile()) + { + ctx.ApplyValidation("f", "", [new ValidationMessage("f", "required")]); + } + var afterFirst = ctx.Version; + + // A pass that genuinely moves the state bumps — exactly once, not once per write. + using (ValidationRenderScope.BeginReconcile()) + { + ctx.ApplyValidation("f", "abc", []); + ctx.MarkTouched("f"); + } + + Assert.True(ctx.Version > afterFirst, $"before={afterFirst} after={ctx.Version}"); + Assert.Equal(afterFirst + 1, ctx.Version); + } + + [Fact] + public void RejectedRuleBatchInstallsNothing() + { + var ctx = new ValidationContext(); + + var syncRule = ValidationRuleDsl.ValidationRule(() => false, "sync failed", "form"); + var asyncRule = ValidationRuleDsl.ValidationRuleAsync( + async () => { await Task.Yield(); return false; }, "async failed", "form"); + + // The batch is rejected because it contains an async rule. It must be rejected + // whole: evaluating rule by rule left the sync verdict installed by a call that + // reported failure. + Assert.Throws( + () => ValidationReconciler.EvaluateRules(ctx, syncRule, asyncRule)); + + Assert.Empty(ctx.GetMessages("form")); + Assert.True(ctx.IsValid()); + } + // ════════════════════════════════════════════════════════════════ + // Baseline moves (issue #1262 review) + // ════════════════════════════════════════════════════════════════ + + [Fact] + public void ReBaseliningAStillDirtyFieldIsObservable() + { + var ctx = new ValidationContext(); + ctx.SetInitialValue("f", "a"); + ctx.NotifyValueChanged("f", "b"); + + var notifications = 0; + ctx.Changed += () => notifications++; + var versionBefore = ctx.Version; + + // Dirty before and after — the flag alone cannot see this — but Reset now hands + // back "c" instead of "a", which is a real change to observable state. + ctx.SetInitialValue("f", "c"); + + Assert.True(ctx.IsDirty("f")); + Assert.True(ctx.Version > versionBefore, $"before={versionBefore} after={ctx.Version}"); + Assert.Equal(1, notifications); + Assert.Equal("c", ctx.Reset("f")); + } + + [Fact] + public void IdenticalReBaselineStaysSilent() + { + var ctx = new ValidationContext(); + ctx.SetInitialValue("f", "a"); + ctx.NotifyValueChanged("f", "b"); + + var notifications = 0; + ctx.Changed += () => notifications++; + var versionBefore = ctx.Version; + + // The per-render re-seed a component does on every pass. Announcing this is the + // repaint loop SetInitialValue's remarks warn about. + for (var i = 0; i < 5; i++) ctx.SetInitialValue("f", "a"); + + Assert.Equal(versionBefore, ctx.Version); + Assert.Equal(0, notifications); + } + + [Fact] + public void FirstBaselineSeedStaysSilent() + { + var ctx = new ValidationContext(); + var notifications = 0; + ctx.Changed += () => notifications++; + var versionBefore = ctx.Version; + + // Registration is not a change: nothing was dirty before and nothing is now. + ctx.SetInitialValue("fresh", "a"); + + Assert.Equal(versionBefore, ctx.Version); + Assert.Equal(0, notifications); + Assert.False(ctx.IsDirty("fresh")); + } + // ════════════════════════════════════════════════════════════════ + // ShowWhen.AfterFirstSubmit reachability (issue #1262) + // ════════════════════════════════════════════════════════════════ + + [Fact] + public void AfterFirstSubmitHidesUntilMarkAllTouched() + { + var ctx = new ValidationContext(); + ctx.RegisterField("f"); + ctx.Add("f", "required"); + + // Before a submit attempt the policy hides, exactly like the guide says. + Assert.False(ctx.SubmitAttempted); + Assert.False(ErrorStyling.ShouldShowErrors( + ctx, "f", ShowWhen.AfterFirstSubmit, ctx.SubmitAttempted)); + + // MarkAllTouched is the submit signal the guide tells callers to send. + ctx.MarkAllTouched(); + + Assert.True(ctx.SubmitAttempted); + Assert.True(ErrorStyling.ShouldShowErrors( + ctx, "f", ShowWhen.AfterFirstSubmit, ctx.SubmitAttempted)); + } + + [Fact] + public void ResetAllReturnsToThePreSubmitState() + { + var ctx = new ValidationContext(); + ctx.RegisterField("f"); + ctx.SetInitialValue("f", "a"); + ctx.Add("f", "required"); + ctx.MarkAllTouched(); + Assert.True(ctx.SubmitAttempted); + + ctx.ResetAll(); + + // A reset puts the form back before its submit, so the next reveal waits for a + // fresh attempt rather than showing immediately. + Assert.False(ctx.SubmitAttempted); + } + + [Fact] + public void FirstSubmitAttemptNotifiesEvenWhenEveryFieldWasAlreadyTouched() + { + var ctx = new ValidationContext(); + ctx.RegisterField("f"); + ctx.MarkTouched("f"); + + var notifications = 0; + ctx.Changed += () => notifications++; + + // Every field is touched already, so the touched-set does not move — but the + // submit flag does, and an AfterFirstSubmit visualizer has to repaint for it. + ctx.MarkAllTouched(); + + Assert.True(ctx.SubmitAttempted); + Assert.Equal(1, notifications); + + // A second attempt changes nothing and stays silent. + ctx.MarkAllTouched(); + Assert.Equal(1, notifications); + } + [Fact] + public void WhenDirtyStaysSilentWithoutABaseline() + { + var ctx = new ValidationContext(); + ctx.RegisterField("f"); + ctx.Add("f", "required"); + ctx.NotifyValueChanged("f", "typed something"); + + // Documented precondition: dirty is measured against a baseline, and only + // SetInitialValue records one. Without it the value can change all it likes and + // the field is never dirty, so WhenDirty never displays. + Assert.False(ctx.IsDirty("f")); + Assert.False(ErrorStyling.ShouldShowErrors(ctx, "f", ShowWhen.WhenDirty)); + + ctx.SetInitialValue("f", ""); + Assert.True(ctx.IsDirty("f")); + Assert.True(ErrorStyling.ShouldShowErrors(ctx, "f", ShowWhen.WhenDirty)); + } + [Fact] + public void SubmitDuringARenderFrameIsNotSuppressedAsNetZero() + { + var ctx = new ValidationContext(); + ctx.RegisterField("f"); + ctx.Add("f", "required"); + ctx.MarkTouched("f"); + + var notifications = 0; + ctx.Changed += () => notifications++; + + // Establish a delivered snapshot, so net-zero suppression has something to + // compare against — it only suppresses when it knows what subscribers last saw. + using (ValidationRenderScope.BeginReconcile()) + { + ctx.AddExternal("f", "seed"); + } + var afterSeed = notifications; + var versionAfterSeed = ctx.Version; + + // MarkAllTouched from inside a frame, with every field already touched. The + // touched set does not move, the messages do not move, the values do not move — + // the submit flag is the entire delta, and a ShowWhen.AfterFirstSubmit field + // depends on hearing about it. + using (ValidationRenderScope.BeginReconcile()) + { + ctx.MarkAllTouched(); + } + + Assert.True(ctx.SubmitAttempted); + Assert.True(notifications > afterSeed, + $"afterSeed={afterSeed} now={notifications}"); + Assert.True(ctx.Version > versionAfterSeed, + $"version {versionAfterSeed} -> {ctx.Version}"); + } + + [Fact] + public void RepeatedSubmitDuringARenderFrameStaysSuppressed() + { + var ctx = new ValidationContext(); + ctx.RegisterField("f"); + ctx.Add("f", "required"); + ctx.MarkAllTouched(); + + var notifications = 0; + ctx.Changed += () => notifications++; + using (ValidationRenderScope.BeginReconcile()) { ctx.AddExternal("f", "seed"); } + var afterSeed = notifications; + var versionAfterSeed = ctx.Version; + + // The flag is already set, so these frames really are net-zero and must stay + // silent — the property the suppression exists for. + for (var i = 0; i < 4; i++) + { + using (ValidationRenderScope.BeginReconcile()) { ctx.MarkAllTouched(); } + } + + Assert.Equal(afterSeed, notifications); + Assert.Equal(versionAfterSeed, ctx.Version); + } + // ════════════════════════════════════════════════════════════════ + // Registration and per-field producer cleanup (issue #1262 review) + // ════════════════════════════════════════════════════════════════ + + [Fact] + public void RegisteringAFieldIsObservable() + { + var ctx = new ValidationContext(); + var notifications = 0; + ctx.Changed += () => notifications++; + var versionBefore = ctx.Version; + + ctx.RegisterField("f"); + + // RegisteredFields is public, and MarkAllTouched iterates it — a subscriber + // rendering the field set has to hear about a new one. + Assert.Contains("f", ctx.RegisteredFields); + Assert.Equal(1, notifications); + Assert.True(ctx.Version > versionBefore); + + // Re-registering the same field changes nothing and stays silent. + ctx.RegisterField("f"); + Assert.Equal(1, notifications); + } + + [Fact] + public void RegistrationNotifiesEvenWhenValueAndMessagesAreUnchanged() + { + var ctx = new ValidationContext(); + var notifications = 0; + ctx.Changed += () => notifications++; + + // Value null, no messages: before the fix nothing here counted as a change, so + // the newly registered field was invisible to subscribers. + ctx.ApplyValidation("f", null, []); + + Assert.Contains("f", ctx.RegisteredFields); + Assert.Equal(1, notifications); + + // Same call again is genuinely net-zero. + ctx.ApplyValidation("f", null, []); + Assert.Equal(1, notifications); + } + + [Fact] + public void ClearingAFieldDropsItsProducerStamps() + { + var ctx = new ValidationContext(); + + // Every per-field clearing path has to drop the stamp with the ownership, or the + // documented invariant breaks and the map grows without bound for dynamically + // named fields. + for (var i = 0; i < 100; i++) + { + ctx.ApplyOwned($"f{i}", "sync", [new ValidationMessage($"f{i}", "bad")]); + Assert.Equal(1, ctx.ProducerStampEntryCount); + ctx.Clear($"f{i}"); + Assert.Equal(0, ctx.ProducerStampEntryCount); + } + + ctx.ApplyOwned("r", "sync", [new ValidationMessage("r", "bad")]); + ctx.Reset("r"); + Assert.Equal(0, ctx.ProducerStampEntryCount); + } + [Fact] + public void RetiringAProducerKeepsAnIdenticalMessageInstanceOwnedByAnother() + { + var ctx = new ValidationContext(); + + // A validator may legally cache and return one immutable message instance, and + // Add(ValidationMessage) is public — so the same instance can legitimately be in + // a field twice, contributed by two different owners. + var shared = new ValidationMessage("f", "must not be empty"); + + ctx.ApplyOwned("f", "sync", [shared]); + ctx.Add(shared); + Assert.Equal(2, ctx.GetMessages("f").Count); + + // Retiring one owner must take exactly its own contribution. + ctx.RetireProducer("f", "sync"); + + var remaining = ctx.GetMessages("f"); + Assert.Single(remaining); + Assert.Same(shared, remaining[0]); + Assert.False(ctx.IsValid()); + } + [Fact] + public void AbandonedClaimsAreRetiredWithoutWaitingForAnotherRender() + { + var ctx = new ValidationContext(); + + // A render that validates and then throws: the host installs its error fallback + // and returns without reconciling, so nothing downstream ever consumes the + // claim. Relying on the *next* render to clean up is not enough — for a + // terminal fallback there is no next render. + using (ValidationRenderScope.Begin(ctx)) + { + _ = TextBox("").Validate("ghost", "", Validate.Required("ghost is required")); + } + + Assert.Single(ctx.GetMessages("ghost")); + + // What the host's error path now calls. + ValidationRenderScope.AbandonPendingClaims(); + + Assert.Empty(ctx.GetMessages("ghost")); + Assert.True(ctx.IsValid()); + } + + [Fact] + public void AbandoningClaimsDoesNotDisturbAnAdoptedSlot() + { + var ctx = new ValidationContext(); + + // Guard: abandoning must not retract a verdict a mounted control already owns. + // The stamped withdrawal is what makes this safe on any abort path, including + // one taken after reconciliation had already started. + ctx.ApplyOwned("live", ValidationContext.SyncProducer, + [new ValidationMessage("live", "still required")]); + + ValidationRenderScope.AbandonPendingClaims(); + + Assert.Single(ctx.GetMessages("live")); + } +} \ No newline at end of file diff --git a/tests/Reactor.Tests/ValidationRuleTests.cs b/tests/Reactor.Tests/ValidationRuleTests.cs index 0649bbec7..44f7ab0e9 100644 --- a/tests/Reactor.Tests/ValidationRuleTests.cs +++ b/tests/Reactor.Tests/ValidationRuleTests.cs @@ -162,7 +162,9 @@ public async Task AsyncValidationRule_Respects_Cancellation() "msg", "f"); - await Assert.ThrowsAsync(() => + // Cancellation surfaces as an OperationCanceledException; the exact subclass + // depends on how the wait was cancelled, so don't pin it. + await Assert.ThrowsAnyAsync(() => rule.EvaluateAsync(ctx, cts.Token)); }