From 2d2e4f17eb435bea5a3ec3b14804826c1b2b32be Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Wed, 23 Sep 2026 12:14:47 -0700 Subject: [PATCH 01/67] Fix validation context: .Validate() never ran outside FormField Following the Forms guide's "Validation Context" example produced a form that submitted while empty. Five defects compounded: 1. `.Validate()` only attached a ValidationAttached record. The sole consumer was CompositeLifecycle's FormField path, so validators on a plain control never ran: no field registered, no message produced, IsValid() trivially true. This contradicts spec 011 1A.5, which was checked off but only ever implemented for FormField. 2. UseValidationContext() returned a component-local context it never provided, so FormField and the visualizers could not find it either. 3. Validation ran during reconcile, after Render() had built the tree, so a component reading the context inline was always one pass stale. 4. Mutating the context scheduled no re-render, so an invalid submit repainted nothing. 5. FormField's default ShowWhen.WhenTouched could never fire: the guide promises errors appear "after the field is touched (focus then blur)", but nothing ever called MarkTouched. A ValidationRenderScope, opened by the reconciler around every render, links the rendering component's context to the value-carrying .Validate() overload so validators run during render and are readable by the same pass. The hook publishes a local context to the rendered subtree (an explicit .Provide still wins), subscribes to a new ValidationContext.Changed event to repaint, and FormField marks its field touched on blur. Loop safety: requestRerender runs inline and throws past MaxRerenderReentrancy, so results are applied through a diffing ReplaceInternal (identical re-validation is silent) and Changed is suppressed while a render is in flight. Also fixes NotifyValueChanged discarding external messages unconditionally, which per-render validation would otherwise make destructive to AddExternal server errors. Fixes #1262 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 373c7212-34f6-4ddf-8ae9-59553c77cf48 --- CHANGELOG.md | 37 ++ docs/_pipeline/templates/forms.md.dt | 38 ++ docs/guide/forms.md | 38 ++ .../reactor-dsl/references/reactor.api.txt | 1 + plugins/reactor/skills/reactor-forms/SKILL.md | 19 +- skills/forms.md | 53 ++- skills/reactor.api.txt | 1 + .../Validation/UseValidationContext.cs | 52 ++- .../Controls/Validation/ValidateExtensions.cs | 37 +- .../Controls/Validation/ValidationContext.cs | 143 +++++- .../Validation/ValidationReconciler.cs | 27 +- .../Validation/ValidationRenderScope.cs | 126 ++++++ src/Reactor/Core/Reconciler.Mount.cs | 16 +- src/Reactor/Core/Reconciler.cs | 16 +- .../Core/V1Protocol/CompositeLifecycle.cs | 49 +++ src/Reactor/Hosting/ReactorHost.cs | 11 +- src/Reactor/Hosting/ReactorHostControl.cs | 11 +- .../Fixtures/ValidationCoverageFixtures.cs | 230 ++++++++++ .../SelfTest/SelfTestFixtureRegistry.cs | 8 + .../ValidationRenderScopeTests.cs | 414 ++++++++++++++++++ 20 files changed, 1265 insertions(+), 62 deletions(-) create mode 100644 src/Reactor/Controls/Validation/ValidationRenderScope.cs create mode 100644 tests/Reactor.Tests/ValidationRenderScopeTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 42c660613..6feaf4b61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,14 +28,51 @@ Conventions for contributors: ### Added +- `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 (issue #1262). +- `FormField` now marks its field touched when the editor loses focus, so the + default `ShowWhen.WhenTouched` reveals errors on blur as the guide describes. + Previously nothing in the framework ever called `MarkTouched`, leaving the + default unreachable unless the app marked fields by hand (spec 011 §1E.1, + issue #1262). + ### Changed +- `.Validate(fieldName, value, validators…)` now runs its validators during the + render that calls it, instead of only when a `FormField` or visualizer mounts + the element. Results are therefore readable by the same `Render()` that + produced them. The validator-only `.Validate(fieldName, validators…)` overload + is unchanged and still attach-only (spec 011 §1A.5, issue #1262). +- `UseValidationContext()` publishes a component-local context to the rendered + subtree automatically, so `FormField`, `ValidationVisualizer`, and nested + components no longer require an explicit + `.Provide(ValidationContexts.Current, ctx)`. An explicit provide still takes + precedence (issue #1262). + ### Deprecated ### Removed ### Fixed +- Validators attached to a plain control with `.Validate()` never ran unless the + control was wrapped in a `FormField`, so a form built from the documented + "Validation Context" example registered no fields, produced no messages, + reported `IsValid() == true`, and submitted while empty (issue #1262). +- `ValidationContext.NotifyValueChanged` discarded external messages on every + call rather than only when the value actually changed, so a server-side error + added with `AddExternal` was wiped by the next render (issue #1262). +- `ValidationContext.MarkAllTouched` bumped `Version` even when every registered + field was already touched, and re-running validators over an unchanged value + bumped it twice per pass; both are now silent when nothing changed (issue #1262). +- `FormField`'s default `ShowWhen.WhenTouched` could never display an error: the + guide promises errors appear "after the field is touched (focus then blur)", + but no focus or blur handler existed and `MarkTouched` was reachable only from + app code, so the documented `FormField` example stayed silent forever + (issue #1262). + ### Security ## [0.1.0-preview.16] — 2026-09-22 diff --git a/docs/_pipeline/templates/forms.md.dt b/docs/_pipeline/templates/forms.md.dt index a5e9c093a..f118d7d56 100644 --- a/docs/_pipeline/templates/forms.md.dt +++ b/docs/_pipeline/templates/forms.md.dt @@ -184,6 +184,44 @@ Key pieces: - **`ctx.IsValid()`** returns `true` when no error-severity messages exist. - **`ctx.MarkAllTouched()`** reveals all errors on submit attempt. +### How validation runs + +Three 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 wins over +the automatic one — reach for it when you want a context to span siblings that +each create their own. + +**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, and validators attached to an element built outside a render +> pass — cached in a field, assembled inside an event handler — have no context +> to reach. In both cases `.Validate()` only records the validators, and +> `FormField` runs them when it mounts. Pass the value if you want the eager +> behaviour. + ## FormField Helper `FormField()` wraps a control with a label, required indicator, description diff --git a/docs/guide/forms.md b/docs/guide/forms.md index dd7171661..07ef16335 100644 --- a/docs/guide/forms.md +++ b/docs/guide/forms.md @@ -347,6 +347,44 @@ Key pieces: - **`ctx.IsValid()`** returns `true` when no error-severity messages exist. - **`ctx.MarkAllTouched()`** reveals all errors on submit attempt. +### How validation runs + +Three 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 wins over +the automatic one — reach for it when you want a context to span siblings that +each create their own. + +**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, and validators attached to an element built outside a render +> pass — cached in a field, assembled inside an event handler — have no context +> to reach. In both cases `.Validate()` only records the validators, and +> `FormField` runs them when it mounts. Pass the value if you want the eager +> behaviour. + ## FormField Helper `FormField()` wraps a control with a label, required indicator, description diff --git a/plugins/reactor/skills/reactor-dsl/references/reactor.api.txt b/plugins/reactor/skills/reactor-dsl/references/reactor.api.txt index 2f956a2f1..bb0205566 100644 --- a/plugins/reactor/skills/reactor-dsl/references/reactor.api.txt +++ b/plugins/reactor/skills/reactor-dsl/references/reactor.api.txt @@ -6123,6 +6123,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 diff --git a/plugins/reactor/skills/reactor-forms/SKILL.md b/plugins/reactor/skills/reactor-forms/SKILL.md index 867f6abb6..2f189b37a 100644 --- a/plugins/reactor/skills/reactor-forms/SKILL.md +++ b/plugins/reactor/skills/reactor-forms/SKILL.md @@ -126,9 +126,21 @@ 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 and +only attaches — `FormField` runs those when it mounts. + +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. Write +`.Provide(...)` explicitly only to share one context across sibling components +that would otherwise each create their own — an explicit provide always wins. + +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 +156,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 diff --git a/skills/forms.md b/skills/forms.md index 813a306b9..081a24203 100644 --- a/skills/forms.md +++ b/skills/forms.md @@ -87,34 +87,55 @@ 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 and only attaches. + +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. Write `.Provide(...)` explicitly only +to share one context across sibling components that would otherwise each make +their own — an explicit provide always wins. + +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,12 +163,13 @@ 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 ); ``` @@ -209,9 +231,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..bb0205566 100644 --- a/skills/reactor.api.txt +++ b/skills/reactor.api.txt @@ -6123,6 +6123,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 diff --git a/src/Reactor/Controls/Validation/UseValidationContext.cs b/src/Reactor/Controls/Validation/UseValidationContext.cs index 38025671e..caf26f21b 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,42 @@ 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. + var (ticket, _) = ctx.UseState(new global::System.Runtime.CompilerServices.StrongBox(0)); + var (revision, setRevision) = ctx.UseState(0, threadSafe: true); + _ = 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..db7749dc9 100644 --- a/src/Reactor/Controls/Validation/ValidateExtensions.cs +++ b/src/Reactor/Controls/Validation/ValidateExtensions.cs @@ -46,9 +46,20 @@ public static T Validate(this T el, string fieldName, params IValidator[] val } /// - /// 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 and the visualizers keep + /// working for elements built outside a render pass. Re-running them is harmless: + /// results are applied with a structural diff. + /// /// public static T Validate(this T el, string fieldName, object? value, params IValidator[] validators) where T : Element { @@ -61,6 +72,8 @@ public static T Validate(this T el, string fieldName, object? value, params I Validators = [.. existing.Validators, .. validators] } : new ValidationAttached(fieldName, validators, []) { Value = value }; + + RunDuringRender(merged, value); return (T)el.SetAttached(merged); } @@ -94,9 +107,27 @@ public static T ValidateAsync(this T el, string fieldName, object? value, par AsyncValidators = [.. existing.AsyncValidators, .. asyncValidators] } : new ValidationAttached(fieldName, [], asyncValidators) { Value = value }; + + // Async validators cannot resolve inside a synchronous render, but the field + // still has to be registered or MarkAllTouched() would skip it. + ValidationRenderScope.Current?.RegisterField(fieldName); 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); + } + /// /// 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..da7f4786e 100644 --- a/src/Reactor/Controls/Validation/ValidationContext.cs +++ b/src/Reactor/Controls/Validation/ValidationContext.cs @@ -16,6 +16,26 @@ 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 — the rendering + /// component reads the new state later in the same pass, and notifying would + /// re-enter the reconciler's re-render path from inside Render(). + /// + /// + public event Action? Changed; + /// /// Monotonically increasing version number, bumped on every mutation. /// Useful for change detection in hooks/memos. @@ -25,6 +45,17 @@ 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. + /// + private void RaiseChanged() + { + if (ValidationRenderScope.InRender) return; + Changed?.Invoke(); + } + // ════════════════════════════════════════════════════════════════ // Field registration // ════════════════════════════════════════════════════════════════ @@ -76,6 +107,7 @@ public void Add(ValidationMessage message) list.Add(message); _version++; } + RaiseChanged(); } /// @@ -102,6 +134,7 @@ public void AddExternal(ValidationMessage message) list.Add(message); _version++; } + RaiseChanged(); } // ════════════════════════════════════════════════════════════════ @@ -113,13 +146,15 @@ public void AddExternal(ValidationMessage message) /// 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++; } + if (changed) RaiseChanged(); } /// @@ -128,11 +163,56 @@ public void Clear(string field) /// internal void ClearInternal(string field) { + bool changed; + lock (_lock) + { + changed = _messages.Remove(field); + if (changed) _version++; + } + if (changed) RaiseChanged(); + } + + /// + /// Replaces the internal (validator-produced) messages for a field in one step, + /// bumping and raising only when the new + /// set actually differs from the old one. + /// + /// This is what makes per-render validation safe. The previous + /// ClearInternal + N×Add sequence bumped the version two or more + /// times on *every* pass even when the value and its verdict were unchanged, so once + /// validators run on every render a change-notification built on that signal would + /// re-render forever. is a record, so the comparison + /// below is ordinary structural equality. + /// + /// + internal void ReplaceInternal(string field, List messages) + { + bool changed; lock (_lock) { - if (_messages.Remove(field)) + _messages.TryGetValue(field, out var existing); + changed = !SameMessages(existing, messages); + if (changed) + { + if (messages.Count == 0) + _messages.Remove(field); + else + _messages[field] = messages; _version++; + } } + if (changed) RaiseChanged(); + } + + 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; } /// @@ -140,11 +220,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) _version++; } + if (changed) RaiseChanged(); } /// @@ -152,12 +234,15 @@ public void ClearExternal(string field) /// public void ClearAll() { + bool changed; lock (_lock) { + changed = _messages.Count > 0 || _externalMessages.Count > 0; _messages.Clear(); _externalMessages.Clear(); - _version++; + if (changed) _version++; } + if (changed) RaiseChanged(); } // ════════════════════════════════════════════════════════════════ @@ -295,11 +380,13 @@ 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) _version++; } + if (changed) RaiseChanged(); } /// @@ -307,12 +394,16 @@ public void MarkTouched(string field) /// public void MarkAllTouched() { + bool changed = false; lock (_lock) { foreach (var field in _registeredFields) - _touchedFields.Add(field); - _version++; + { + if (_touchedFields.Add(field)) changed = true; + } + if (changed) _version++; } + if (changed) RaiseChanged(); } // ════════════════════════════════════════════════════════════════ @@ -332,17 +423,31 @@ public void SetInitialValue(string field, object? value) } /// - /// 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. + /// /// 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); + _version++; } + RaiseChanged(); } /// @@ -388,16 +493,18 @@ public bool IsDirty() /// public object? Reset(string field) { + object? initial; lock (_lock) { _touchedFields.Remove(field); _messages.Remove(field); _externalMessages.Remove(field); - _initialValues.TryGetValue(field, out var initial); + _initialValues.TryGetValue(field, out initial); _currentValues[field] = initial; _version++; - return initial; } + RaiseChanged(); + return initial; } /// @@ -406,20 +513,22 @@ public bool IsDirty() /// public IReadOnlyDictionary ResetAll() { + Dictionary result; lock (_lock) { _touchedFields.Clear(); _messages.Clear(); _externalMessages.Clear(); - var result = new Dictionary(); + result = new Dictionary(); foreach (var (field, initial) in _initialValues) { _currentValues[field] = initial; result[field] = initial; } _version++; - return result; } + RaiseChanged(); + return result; } // ════════════════════════════════════════════════════════════════ diff --git a/src/Reactor/Controls/Validation/ValidationReconciler.cs b/src/Reactor/Controls/Validation/ValidationReconciler.cs index 5303d7aa6..f3112cf6f 100644 --- a/src/Reactor/Controls/Validation/ValidationReconciler.cs +++ b/src/Reactor/Controls/Validation/ValidationReconciler.cs @@ -13,6 +13,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, @@ -21,15 +26,8 @@ public static void ValidateField( 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.ReplaceInternal(fieldName, Run(validators, value, fieldName)); } /// @@ -41,15 +39,20 @@ public static void ValidateAttached( object? value) { ctx.RegisterField(attached.FieldName); - ctx.ClearInternal(attached.FieldName); ctx.NotifyValueChanged(attached.FieldName, value); + ctx.ReplaceInternal(attached.FieldName, 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; } /// diff --git a/src/Reactor/Controls/Validation/ValidationRenderScope.cs b/src/Reactor/Controls/Validation/ValidationRenderScope.cs new file mode 100644 index 000000000..cbb4223b1 --- /dev/null +++ b/src/Reactor/Controls/Validation/ValidationRenderScope.cs @@ -0,0 +1,126 @@ +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; + + /// + /// 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 suppress its change notification: + /// a mutation made *during* a render needs no re-render, because the component doing + /// the rendering observes the new value later in the very same pass. Notifying + /// anyway would re-enter requestRerender from inside Render(), which + /// the reconciler treats as a render loop. + /// + /// + internal static bool InRender => t_depth > 0; + + /// + /// 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) + { + var frame = new Frame(t_context, t_pendingProvide); + t_context = inherited; + t_pendingProvide = null; + t_depth++; + return frame; + } + + /// + /// 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; + + internal Frame(ValidationContext? previousContext, ValidationContext? previousPendingProvide) + { + _previousContext = previousContext; + _previousPendingProvide = previousPendingProvide; + } + + public void Dispose() + { + t_context = _previousContext; + t_pendingProvide = _previousPendingProvide; + if (t_depth > 0) t_depth--; + } + } +} diff --git a/src/Reactor/Core/Reconciler.Mount.cs b/src/Reactor/Core/Reconciler.Mount.cs index 84fc82e8c..92594b64e 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; @@ -800,7 +801,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 +840,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 +880,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.cs b/src/Reactor/Core/Reconciler.cs index d824c13f9..75fc65681 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; @@ -1850,19 +1851,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 diff --git a/src/Reactor/Core/V1Protocol/CompositeLifecycle.cs b/src/Reactor/Core/V1Protocol/CompositeLifecycle.cs index 552bd4b23..f3e9813c3 100644 --- a/src/Reactor/Core/V1Protocol/CompositeLifecycle.cs +++ b/src/Reactor/Core/V1Protocol/CompositeLifecycle.cs @@ -85,6 +85,7 @@ internal static WinUI.StackPanel MountFormField(Reconciler reconciler, FormField { ApplyFormFieldAutomation(contentControl, ff.Label); ApplyFormFieldErrorStyling(contentControl, valCtx, fieldName, ff.ShowWhen); + WireTouchedOnBlur(contentControl, valCtx, fieldName); panel.Children.Add(contentControl); } else @@ -156,6 +157,7 @@ internal static WinUI.StackPanel MountFormField(Reconciler reconciler, FormField ApplyFormFieldAutomation(existingContent, newFf.Label); ApplyFormFieldErrorStyling(existingContent, valCtx, fieldName, newFf.ShowWhen); + WireTouchedOnBlur(existingContent, valCtx, fieldName); // [2] Update description/error text if (panel.Children[2] is TextBlock descTb) @@ -303,6 +305,53 @@ internal static UIElement MountValidationRule(Reconciler reconciler, ValidationR return null; // keep existing collapsed placeholder } + /// + /// 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 contentControl, ValidationContext? valCtx, string? fieldName) + { + if (valCtx is null || string.IsNullOrEmpty(fieldName)) return; + if (contentControl is not FrameworkElement fe) return; + + if (_touchBindings.TryGetValue(fe, out var existing)) + { + existing.Context = valCtx; + existing.FieldName = fieldName; + return; + } + + var binding = new TouchBinding { Context = valCtx, FieldName = fieldName }; + _touchBindings.Add(fe, binding); + fe.LostFocus += (_, _) => + { + if (binding.Context is { } ctx && binding.FieldName is { Length: > 0 } field) + ctx.MarkTouched(field); + }; + } + + private sealed class TouchBinding + { + internal ValidationContext? Context; + internal string? FieldName; + } + + private static readonly global::System.Runtime.CompilerServices.ConditionalWeakTable _touchBindings = new(); + private static void ApplyFormFieldAutomation(UIElement contentControl, string? label) { var automationName = FormFieldHelpers.GetAutomationName(label); diff --git a/src/Reactor/Hosting/ReactorHost.cs b/src/Reactor/Hosting/ReactorHost.cs index 75adfe021..44754face 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; @@ -555,7 +556,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 +579,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) { diff --git a/src/Reactor/Hosting/ReactorHostControl.cs b/src/Reactor/Hosting/ReactorHostControl.cs index 965e7b6af..2f5d3024b 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; @@ -375,7 +376,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 +398,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) { diff --git a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs index 3e0b44079..aeb372342 100644 --- a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs +++ b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs @@ -440,4 +440,234 @@ 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()); + 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()); + 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(); + } + } } diff --git a/tests/Reactor.AppTests.Host/SelfTest/SelfTestFixtureRegistry.cs b/tests/Reactor.AppTests.Host/SelfTest/SelfTestFixtureRegistry.cs index 7dc7eccf3..aac4a8235 100644 --- a/tests/Reactor.AppTests.Host/SelfTest/SelfTestFixtureRegistry.cs +++ b/tests/Reactor.AppTests.Host/SelfTest/SelfTestFixtureRegistry.cs @@ -806,6 +806,10 @@ internal static class SelfTestFixtureRegistry "ValCov_ValidationCompositeLifecycleUpdateAndCleanup", "ValCov_ValidationRule", "ValCov_FormFieldHelpers", + "ValCov_Issue1262_BareValidate", + "ValCov_Issue1262_FormFieldAutoProvide", + "ValCov_Issue1262_ExplicitProvideWins", + "ValCov_Issue1262_FormFieldTouchedOnBlur", // Controls coverage — MaskEngine, InputFormatter, AutoSuggest "ControlsCov_MaskEngineBasic", "ControlsCov_MaskEngineNavigation", @@ -2706,6 +2710,10 @@ 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), // Controls coverage "ControlsCov_MaskEngineBasic" => new ControlsCoverageFixtures.MaskEngineBasic(harness), "ControlsCov_MaskEngineNavigation" => new ControlsCoverageFixtures.MaskEngineNavigation(harness), diff --git a/tests/Reactor.Tests/ValidationRenderScopeTests.cs b/tests/Reactor.Tests/ValidationRenderScopeTests.cs new file mode 100644 index 000000000..1b5de2f40 --- /dev/null +++ b/tests/Reactor.Tests/ValidationRenderScopeTests.cs @@ -0,0 +1,414 @@ +using Microsoft.UI.Reactor.Core; +using Microsoft.UI.Reactor.Controls.Validation; +using static Microsoft.UI.Reactor.Factories; +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; + + // 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(0, notifications); // suppressed during render anyway + 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 Changed_Is_Suppressed_While_A_Render_Pass_Is_In_Flight() + { + var ctx = new ValidationContext(); + var notifications = 0; + ctx.Changed += () => notifications++; + + using (ValidationRenderScope.Begin(ctx)) + { + ctx.Add("email", "boom"); + ctx.MarkTouched("email"); + } + + // The rendering component reads the new state later in the same pass, so + // notifying would only re-enter requestRerender from inside Render(). + Assert.Equal(0, notifications); + + ctx.MarkTouched("password"); + Assert.Equal(1, notifications); + } + + // ════════════════════════════════════════════════════════════════ + // 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_Is_Silent() + { + var ctx = new ValidationContext(); + var notifications = 0; + ctx.Changed += () => notifications++; + + ctx.MarkAllTouched(); + + Assert.Equal(0, notifications); + Assert.Equal(0, ctx.Version); + } + + [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_Wins_Over_The_Automatic_One() + { + var render = new RenderContext(); + var mine = new ValidationContext(); + Element rendered; + ValidationContext resolved; + + using (ValidationRenderScope.Begin(null)) + { + render.BeginRender(() => { }); + resolved = render.UseValidationContext(); + rendered = ValidationRenderScope.ApplyProvide( + TextBlock("body").Provide(ValidationContexts.Current, mine)); + } + + Assert.NotSame(resolved, mine); + Assert.Same(mine, rendered.ContextValues![ValidationContexts.Current]); + } + + [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); + } +} From 0e2b35c351aaeac528b7cc2d0eb27a76836f93fb Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Wed, 23 Sep 2026 13:17:36 -0700 Subject: [PATCH 02/67] Address code-quality finding: drop implicit filter in MarkAllTouched Add every registered field unconditionally and compare the set size instead of branching on HashSet.Add's return inside the loop. The suggested .Where(...) rewrite would reach the same answer with a second hash lookup per field plus a LINQ allocation, all inside the lock. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 373c7212-34f6-4ddf-8ae9-59553c77cf48 --- .../Controls/Validation/ValidationContext.cs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/Reactor/Controls/Validation/ValidationContext.cs b/src/Reactor/Controls/Validation/ValidationContext.cs index da7f4786e..4ee5a4052 100644 --- a/src/Reactor/Controls/Validation/ValidationContext.cs +++ b/src/Reactor/Controls/Validation/ValidationContext.cs @@ -394,13 +394,18 @@ public void MarkTouched(string field) /// public void MarkAllTouched() { - bool changed = false; + 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) - { - if (_touchedFields.Add(field)) changed = true; - } + _touchedFields.Add(field); + + changed = _touchedFields.Count != touchedBefore; if (changed) _version++; } if (changed) RaiseChanged(); From a96d70784f458b0a87d4cfb53c665b91668cfe00 Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Wed, 23 Sep 2026 13:59:13 -0700 Subject: [PATCH 03/67] Address Copilot review: rule render loop, atomic apply, binding lifetime Four findings from the first review round, all reproduced before fixing: - A failing ValidationRule drove the reconciler into its re-render re-entrancy limit. Rules evaluate during reconcile, outside the render scope, and cleared-then-re-added their message every pass, so each pass looked like a change and requested another render. Applying the verdict as one diffed ReplaceInternal makes re-evaluation silent. Restoring the old code in the new regression drives 1604 renders and a version climbing 3207 -> 4763 in a single pass, so the test is not vacuous. - ValidateField/ValidateAttached registered the field, recorded the value and installed results as three calls, so a subscriber woke mid-update and re-rendered against the previous pass's messages. Replaced with a single atomic ValidationContext.ApplyValidation: one lock, one version bump, one notification raised only once everything is in place. - The FormField blur binding outlived its field. TextBox is poolable, so a control rented back out elsewhere kept marking the old context. It is now cleared on unmount and whenever an update has no context or field. The lookup is keyed by FormField root rather than walking the panel's children, so unmount never reads the visual tree mid-teardown. - The explicit-provide test was vacuous: FormField re-validated during reconcile and masked where eager validation actually landed. Rewritten against a bare control, and the docs now state the real semantics -- an explicit .Provide is what descendants resolve, and does not redirect the providing component's own .Validate() calls, which already ran while the tree was built. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 373c7212-34f6-4ddf-8ae9-59553c77cf48 --- CHANGELOG.md | 5 + docs/_pipeline/templates/forms.md.dt | 13 +- docs/guide/forms.md | 13 +- plugins/reactor/skills/reactor-forms/SKILL.md | 8 +- skills/forms.md | 8 +- .../Controls/Validation/ValidationContext.cs | 45 ++++++ .../Validation/ValidationReconciler.cs | 8 +- .../Controls/Validation/ValidationRule.cs | 28 ++-- .../Core/V1Protocol/CompositeLifecycle.cs | 56 ++++++- .../V1Protocol/Handlers/CompositeHandlers.cs | 6 +- .../Fixtures/ValidationCoverageFixtures.cs | 139 ++++++++++++++++++ .../SelfTest/SelfTestFixtureRegistry.cs | 4 + .../ValidationRenderScopeTests.cs | 114 +++++++++++++- 13 files changed, 407 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6feaf4b61..e762973e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,6 +61,11 @@ Conventions for contributors: control was wrapped in a `FormField`, so a form built from the documented "Validation Context" example registered no fields, produced no messages, reported `IsValid() == true`, and submitted while empty (issue #1262). +- A failing `ValidationRule` could drive the reconciler into its re-render + re-entrancy limit: rules evaluate during reconcile, outside the render scope, + and cleared-then-re-added their message every pass, so each pass looked like a + change and requested another render. Rule results are now applied as one diffed + replacement (issue #1262). - `ValidationContext.NotifyValueChanged` discarded external messages on every call rather than only when the value actually changed, so a server-side error added with `AddExternal` was wiped by the next render (issue #1262). diff --git a/docs/_pipeline/templates/forms.md.dt b/docs/_pipeline/templates/forms.md.dt index f118d7d56..f9ad48fc7 100644 --- a/docs/_pipeline/templates/forms.md.dt +++ b/docs/_pipeline/templates/forms.md.dt @@ -199,10 +199,15 @@ 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 wins over -the automatic one — reach for it when you want a context to span siblings that -each create their own. +`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 diff --git a/docs/guide/forms.md b/docs/guide/forms.md index 07ef16335..00a705b4b 100644 --- a/docs/guide/forms.md +++ b/docs/guide/forms.md @@ -362,10 +362,15 @@ 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 wins over -the automatic one — reach for it when you want a context to span siblings that -each create their own. +`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 diff --git a/plugins/reactor/skills/reactor-forms/SKILL.md b/plugins/reactor/skills/reactor-forms/SKILL.md index 2f189b37a..b2e02ee7f 100644 --- a/plugins/reactor/skills/reactor-forms/SKILL.md +++ b/plugins/reactor/skills/reactor-forms/SKILL.md @@ -134,9 +134,11 @@ only attaches — `FormField` runs those when it mounts. 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. Write -`.Provide(...)` explicitly only to share one context across sibling components -that would otherwise each create their own — an explicit provide always wins. +`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 diff --git a/skills/forms.md b/skills/forms.md index 081a24203..f5fd82e2e 100644 --- a/skills/forms.md +++ b/skills/forms.md @@ -114,9 +114,11 @@ result in the same pass. The validator-only overload 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. Write `.Provide(...)` explicitly only -to share one context across sibling components that would otherwise each make -their own — an explicit provide always wins. +`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 diff --git a/src/Reactor/Controls/Validation/ValidationContext.cs b/src/Reactor/Controls/Validation/ValidationContext.cs index 4ee5a4052..ddd2fdda4 100644 --- a/src/Reactor/Controls/Validation/ValidationContext.cs +++ b/src/Reactor/Controls/Validation/ValidationContext.cs @@ -215,6 +215,51 @@ private static bool SameMessages(List? a, List + /// 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; + lock (_lock) + { + _registeredFields.Add(field); + + var known = _currentValues.TryGetValue(field, out var previous); + var valueChanged = !known || !Equals(previous, value); + if (valueChanged) + { + _currentValues[field] = value; + // A server verdict about the old value says nothing about the new one. + _externalMessages.Remove(field); + } + + _messages.TryGetValue(field, out var existing); + var messagesChanged = !SameMessages(existing, messages); + if (messagesChanged) + { + if (messages.Count == 0) + _messages.Remove(field); + else + _messages[field] = messages; + } + + changed = valueChanged || messagesChanged; + if (changed) _version++; + } + if (changed) RaiseChanged(); + } + /// /// Clears only external messages for the specified field. /// diff --git a/src/Reactor/Controls/Validation/ValidationReconciler.cs b/src/Reactor/Controls/Validation/ValidationReconciler.cs index f3112cf6f..27d9aec64 100644 --- a/src/Reactor/Controls/Validation/ValidationReconciler.cs +++ b/src/Reactor/Controls/Validation/ValidationReconciler.cs @@ -25,9 +25,7 @@ public static void ValidateField( object? value, params IValidator[] validators) { - ctx.RegisterField(fieldName); - ctx.NotifyValueChanged(fieldName, value); - ctx.ReplaceInternal(fieldName, Run(validators, value, fieldName)); + ctx.ApplyValidation(fieldName, value, Run(validators, value, fieldName)); } /// @@ -38,9 +36,7 @@ public static void ValidateAttached( ValidationAttached attached, object? value) { - ctx.RegisterField(attached.FieldName); - ctx.NotifyValueChanged(attached.FieldName, value); - ctx.ReplaceInternal(attached.FieldName, Run(attached.Validators, value, attached.FieldName)); + ctx.ApplyValidation(attached.FieldName, value, Run(attached.Validators, value, attached.FieldName)); } private static List Run(IValidator[] validators, object? value, string fieldName) diff --git a/src/Reactor/Controls/Validation/ValidationRule.cs b/src/Reactor/Controls/Validation/ValidationRule.cs index 9b3b01199..d0511ef17 100644 --- a/src/Reactor/Controls/Validation/ValidationRule.cs +++ b/src/Reactor/Controls/Validation/ValidationRule.cs @@ -62,16 +62,18 @@ 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); - - if (!rule.Predicate()) - { - ctx.Add(new ValidationMessage(rule.Field, rule.Message, rule.Severity)); - } + ctx.ReplaceInternal(rule.Field, BuildMessages(rule, rule.Predicate())); } /// @@ -86,13 +88,15 @@ public static async Task EvaluateAsync(this ValidationRuleElement rule, Validati return; } - ctx.ClearInternal(rule.Field); + // Drop the previous verdict while the check is in flight, then install the new + // one — both diffed, so an unchanged outcome stays silent. + ctx.ReplaceInternal(rule.Field, []); var result = await rule.AsyncPredicate(); cancellationToken.ThrowIfCancellationRequested(); - if (!result) - { - ctx.Add(new ValidationMessage(rule.Field, rule.Message, rule.Severity)); - } + ctx.ReplaceInternal(rule.Field, BuildMessages(rule, result)); } + + private static List BuildMessages(ValidationRuleElement rule, bool passed) => + passed ? [] : [new ValidationMessage(rule.Field, rule.Message, rule.Severity)]; } diff --git a/src/Reactor/Core/V1Protocol/CompositeLifecycle.cs b/src/Reactor/Core/V1Protocol/CompositeLifecycle.cs index f3e9813c3..377e59d20 100644 --- a/src/Reactor/Core/V1Protocol/CompositeLifecycle.cs +++ b/src/Reactor/Core/V1Protocol/CompositeLifecycle.cs @@ -85,7 +85,7 @@ internal static WinUI.StackPanel MountFormField(Reconciler reconciler, FormField { ApplyFormFieldAutomation(contentControl, ff.Label); ApplyFormFieldErrorStyling(contentControl, valCtx, fieldName, ff.ShowWhen); - WireTouchedOnBlur(contentControl, valCtx, fieldName); + WireTouchedOnBlur(panel, contentControl, valCtx, fieldName); panel.Children.Add(contentControl); } else @@ -157,7 +157,7 @@ internal static WinUI.StackPanel MountFormField(Reconciler reconciler, FormField ApplyFormFieldAutomation(existingContent, newFf.Label); ApplyFormFieldErrorStyling(existingContent, valCtx, fieldName, newFf.ShowWhen); - WireTouchedOnBlur(existingContent, valCtx, fieldName); + WireTouchedOnBlur(panel, existingContent, valCtx, fieldName); // [2] Update description/error text if (panel.Children[2] is TextBlock descTb) @@ -323,20 +323,31 @@ internal static UIElement MountValidationRule(Reconciler reconciler, ValidationR /// whatever field it currently hosts rather than the one it was mounted with. /// /// - private static void WireTouchedOnBlur(UIElement contentControl, ValidationContext? valCtx, string? fieldName) + private static void WireTouchedOnBlur(UIElement formFieldRoot, UIElement contentControl, ValidationContext? valCtx, string? fieldName) { - if (valCtx is null || string.IsNullOrEmpty(fieldName)) return; if (contentControl is not FrameworkElement fe) 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)) + { + ClearTouchBinding(fe); + return; + } + if (_touchBindings.TryGetValue(fe, out var existing)) { existing.Context = valCtx; existing.FieldName = fieldName; + _rootBindings.Remove(formFieldRoot); + _rootBindings.Add(formFieldRoot, existing); return; } var binding = new TouchBinding { Context = valCtx, FieldName = fieldName }; _touchBindings.Add(fe, binding); + _rootBindings.Remove(formFieldRoot); + _rootBindings.Add(formFieldRoot, binding); fe.LostFocus += (_, _) => { if (binding.Context is { } ctx && binding.FieldName is { Length: > 0 } field) @@ -344,6 +355,39 @@ private static void WireTouchedOnBlur(UIElement contentControl, ValidationContex }; } + /// + /// 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 (_rootBindings.TryGetValue(formFieldRoot, out var binding)) + { + binding.Context = null; + binding.FieldName = null; + } + } + + private static void ClearTouchBinding(UIElement contentControl) + { + if (contentControl is FrameworkElement fe && _touchBindings.TryGetValue(fe, out var binding)) + { + binding.Context = null; + binding.FieldName = null; + } + } + private sealed class TouchBinding { internal ValidationContext? Context; @@ -352,6 +396,10 @@ private sealed class TouchBinding private static readonly global::System.Runtime.CompilerServices.ConditionalWeakTable _touchBindings = new(); + // FormField root -> the binding of its current content control, so unmount can + // neutralize it without touching the visual tree mid-teardown. + private static readonly global::System.Runtime.CompilerServices.ConditionalWeakTable _rootBindings = new(); + private static void ApplyFormFieldAutomation(UIElement contentControl, string? label) { var automationName = FormFieldHelpers.GetAutomationName(label); diff --git a/src/Reactor/Core/V1Protocol/Handlers/CompositeHandlers.cs b/src/Reactor/Core/V1Protocol/Handlers/CompositeHandlers.cs index 51ac8fa9a..6a746ac0d 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). diff --git a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs index aeb372342..e0c6e0160 100644 --- a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs +++ b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs @@ -670,4 +670,143 @@ public override async Task RunAsync() 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 static ValidationContext? s_captured; + private static int s_renders; + + // 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(); + s_captured = valCtx; + s_renders++; + + return VStack(12, + ValidationRule(() => false, "Passwords must match", "confirm"), + TextBlock($"valid:{valCtx.IsValid()}")); + } + } + + public override async Task RunAsync() + { + s_captured = null; + s_renders = 0; + + var host = H.CreateHost(); + host.Mount(_ => Component()); + + // If the loop were still present this throws "Render loop detected". + await Harness.Render(); + await Harness.Render(); + + H.Check("Issue1262_Rule_NoLoopThrown", s_captured is not null); + H.Check("Issue1262_Rule_MessageRecorded", + s_captured!.GetMessages("confirm").Count == 1); + H.Check("Issue1262_Rule_NoAccumulation", + s_captured.GetAllMessages().Count == 1); + H.Check("Issue1262_Rule_RenderCountBounded", s_renders < 10, $"renders={s_renders}"); + + var settledVersion = s_captured.Version; + var settledRenders = s_renders; + await Harness.Render(); + + H.Check("Issue1262_Rule_VersionStableOnReRender", s_captured.Version == settledVersion, + $"before={settledVersion} after={s_captured.Version}"); + H.Check("Issue1262_Rule_RendersSettle", s_renders - settledRenders <= 2, + $"delta={s_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. + // ════════════════════════════════════════════════════════════════════════ + + 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("").Validate("name", "", Validate.Required("Name is required")), + label: "Full Name", + showWhen: ShowWhen.Always), + Button("Elsewhere", () => { })); + + return provided ? tree.Provide(ValidationContexts.Current, ctx) : tree; + }); + + await Harness.Render(); + var box = H.FindControl(_ => true); + var elsewhere = H.FindButton("Elsewhere"); + H.Check("Issue1262_Binding_ControlsFound", box is not null && elsewhere is not null); + + // 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(_ => true)); + setProvide!(false); + await Harness.Render(); + H.Check("Issue1262_Binding_ControlPreserved", + stillSameControl && ReferenceEquals(box, H.FindControl(_ => true))); + + var freshCtx = new ValidationContext(); + freshCtx.RegisterField("name"); + 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(); + } + } } diff --git a/tests/Reactor.AppTests.Host/SelfTest/SelfTestFixtureRegistry.cs b/tests/Reactor.AppTests.Host/SelfTest/SelfTestFixtureRegistry.cs index aac4a8235..364826b9e 100644 --- a/tests/Reactor.AppTests.Host/SelfTest/SelfTestFixtureRegistry.cs +++ b/tests/Reactor.AppTests.Host/SelfTest/SelfTestFixtureRegistry.cs @@ -810,6 +810,8 @@ internal static class SelfTestFixtureRegistry "ValCov_Issue1262_FormFieldAutoProvide", "ValCov_Issue1262_ExplicitProvideWins", "ValCov_Issue1262_FormFieldTouchedOnBlur", + "ValCov_Issue1262_FailingRuleNoLoop", + "ValCov_Issue1262_TouchBindingCleared", // Controls coverage — MaskEngine, InputFormatter, AutoSuggest "ControlsCov_MaskEngineBasic", "ControlsCov_MaskEngineNavigation", @@ -2714,6 +2716,8 @@ internal static string[] StaleTierDeclarations() => "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), // Controls coverage "ControlsCov_MaskEngineBasic" => new ControlsCoverageFixtures.MaskEngineBasic(harness), "ControlsCov_MaskEngineNavigation" => new ControlsCoverageFixtures.MaskEngineNavigation(harness), diff --git a/tests/Reactor.Tests/ValidationRenderScopeTests.cs b/tests/Reactor.Tests/ValidationRenderScopeTests.cs index 1b5de2f40..246f8b582 100644 --- a/tests/Reactor.Tests/ValidationRenderScopeTests.cs +++ b/tests/Reactor.Tests/ValidationRenderScopeTests.cs @@ -1,6 +1,7 @@ using Microsoft.UI.Reactor.Core; using Microsoft.UI.Reactor.Controls.Validation; using static Microsoft.UI.Reactor.Factories; +using static Microsoft.UI.Reactor.Controls.Validation.ValidationRuleDsl; using Xunit; namespace Microsoft.UI.Reactor.Tests; @@ -176,6 +177,79 @@ public void Revalidating_Outside_A_Render_Pass_Notifies_Only_On_A_Real_Change() 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 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); + Assert.Equal(0, notifications); // passing, nothing to record + Assert.True(ctx.IsValid()); + + passing = false; + rule.Evaluate(ctx); + Assert.Equal(1, notifications); + Assert.False(ctx.IsValid()); + + passing = true; + rule.Evaluate(ctx); + Assert.Equal(2, notifications); + Assert.True(ctx.IsValid()); + } + [Fact] public void Changed_Is_Suppressed_While_A_Render_Pass_Is_In_Flight() { @@ -311,7 +385,7 @@ public void A_Local_Context_Is_Published_To_The_Rendered_Subtree() } [Fact] - public void An_Explicit_Provide_Wins_Over_The_Automatic_One() + public void An_Explicit_Provide_Reaches_Descendants_Not_The_Providers_Own_Eager_Validation() { var render = new RenderContext(); var mine = new ValidationContext(); @@ -322,14 +396,48 @@ public void An_Explicit_Provide_Wins_Over_The_Automatic_One() { 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( - TextBlock("body").Provide(ValidationContexts.Current, mine)); + body.Provide(ValidationContexts.Current, mine)); } - Assert.NotSame(resolved, 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() { From 92645843fc50d44ccb56f62c3b2f9c6fad3e44fe Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Wed, 23 Sep 2026 14:26:00 -0700 Subject: [PATCH 04/67] Address Copilot review round 2: deferred notifications, Reset delta, binding swap - Changed notifications raised mid-render are now deferred to the end of the outermost render frame instead of dropped. Dropping them kept the rendering component correct but starved every other subscriber: a parent that renders ctx.IsValid() and provides the context never learned that a child's eager .Validate() had invalidated it. Delivery is posted through the UI dispatcher when one exists so it lands after the in-flight reconcile, and falls back to an inline raise in headless hosts. - Reset/ResetAll bumped Version and notified even with nothing to reset, so an effect that reset on each render could repaint forever. Both now compute a real state delta, matching ClearAll and MarkAllTouched. Reset no longer creates a _currentValues entry for a field the context has never seen. - The re-render subscription used UseState(threadSafe: true), whose setter invokes the re-render callback on the calling thread; only the default setter marshals. An async validator raising Changed from a worker could therefore re-enter the reconciler off-thread. Interlocked already guards the ticket, so the default marshaling setter is used instead. - Re-pointing a FormField root at a new content control left the displaced binding live, so a swapped-out editor could still mark the old field once pooled and rented elsewhere. ReplaceRootBinding clears the previous binding first. ValidationContextTests.Version_Increments_On_Touch_And_Reset encoded the old unconditional-bump contract and now asserts both halves of the new one. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 373c7212-34f6-4ddf-8ae9-59553c77cf48 --- .../Validation/UseValidationContext.cs | 7 +- .../Controls/Validation/ValidationContext.cs | 69 +++++++++-- .../Validation/ValidationRenderScope.cs | 36 +++++- .../Core/V1Protocol/CompositeLifecycle.cs | 30 ++++- tests/Reactor.Tests/ValidationContextTests.cs | 7 ++ .../ValidationRenderScopeTests.cs | 117 +++++++++++++++++- 6 files changed, 241 insertions(+), 25 deletions(-) diff --git a/src/Reactor/Controls/Validation/UseValidationContext.cs b/src/Reactor/Controls/Validation/UseValidationContext.cs index caf26f21b..380689965 100644 --- a/src/Reactor/Controls/Validation/UseValidationContext.cs +++ b/src/Reactor/Controls/Validation/UseValidationContext.cs @@ -82,8 +82,13 @@ private static void SubscribeForRerender(RenderContext ctx, ValidationContext co // 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, threadSafe: true); + var (revision, setRevision) = ctx.UseState(0); _ = revision; ctx.UseEffect(() => diff --git a/src/Reactor/Controls/Validation/ValidationContext.cs b/src/Reactor/Controls/Validation/ValidationContext.cs index ddd2fdda4..1affc05d8 100644 --- a/src/Reactor/Controls/Validation/ValidationContext.cs +++ b/src/Reactor/Controls/Validation/ValidationContext.cs @@ -49,13 +49,43 @@ public int 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() { - if (ValidationRenderScope.InRender) return; + if (ValidationRenderScope.InRender) + { + ValidationRenderScope.DeferNotification(this); + return; + } 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. + /// + internal void NotifyDeferred() + { + var handler = Changed; + if (handler is null) return; + + var dispatcher = global::Microsoft.UI.Reactor.ReactorApp.UIDispatcher; + if (dispatcher is not null && dispatcher.TryEnqueue(() => handler.Invoke())) + return; + + handler.Invoke(); + } + // ════════════════════════════════════════════════════════════════ // Field registration // ════════════════════════════════════════════════════════════════ @@ -544,16 +574,27 @@ public bool IsDirty() public object? Reset(string field) { object? initial; + bool changed; lock (_lock) { - _touchedFields.Remove(field); - _messages.Remove(field); - _externalMessages.Remove(field); + changed = _touchedFields.Remove(field); + if (_messages.Remove(field)) changed = true; + if (_externalMessages.Remove(field)) changed = true; + _initialValues.TryGetValue(field, out initial); - _currentValues[field] = initial; - _version++; + // 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) _version++; } - RaiseChanged(); + if (changed) RaiseChanged(); return initial; } @@ -564,20 +605,28 @@ public bool IsDirty() public IReadOnlyDictionary ResetAll() { Dictionary result; + bool changed; lock (_lock) { + changed = _touchedFields.Count > 0 || _messages.Count > 0 || _externalMessages.Count > 0; _touchedFields.Clear(); _messages.Clear(); _externalMessages.Clear(); + 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++; + + if (changed) _version++; } - RaiseChanged(); + if (changed) RaiseChanged(); return result; } diff --git a/src/Reactor/Controls/Validation/ValidationRenderScope.cs b/src/Reactor/Controls/Validation/ValidationRenderScope.cs index cbb4223b1..62035b0d7 100644 --- a/src/Reactor/Controls/Validation/ValidationRenderScope.cs +++ b/src/Reactor/Controls/Validation/ValidationRenderScope.cs @@ -33,6 +33,7 @@ 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; /// /// The context .Validate() should push results into, or null when no @@ -43,15 +44,39 @@ internal static class ValidationRenderScope /// /// True while a component render is in flight on this thread. /// - /// uses this to suppress its change notification: - /// a mutation made *during* a render needs no re-render, because the component doing - /// the rendering observes the new value later in the very same pass. Notifying - /// anyway would re-enter requestRerender from inside Render(), which - /// the reconciler treats as a render loop. + /// 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 @@ -121,6 +146,7 @@ public void Dispose() t_context = _previousContext; t_pendingProvide = _previousPendingProvide; if (t_depth > 0) t_depth--; + if (t_depth == 0) FlushDeferredNotifications(); } } } diff --git a/src/Reactor/Core/V1Protocol/CompositeLifecycle.cs b/src/Reactor/Core/V1Protocol/CompositeLifecycle.cs index 377e59d20..b6497a98b 100644 --- a/src/Reactor/Core/V1Protocol/CompositeLifecycle.cs +++ b/src/Reactor/Core/V1Protocol/CompositeLifecycle.cs @@ -339,15 +339,13 @@ private static void WireTouchedOnBlur(UIElement formFieldRoot, UIElement content { existing.Context = valCtx; existing.FieldName = fieldName; - _rootBindings.Remove(formFieldRoot); - _rootBindings.Add(formFieldRoot, existing); + ReplaceRootBinding(formFieldRoot, existing); return; } var binding = new TouchBinding { Context = valCtx, FieldName = fieldName }; _touchBindings.Add(fe, binding); - _rootBindings.Remove(formFieldRoot); - _rootBindings.Add(formFieldRoot, binding); + ReplaceRootBinding(formFieldRoot, binding); fe.LostFocus += (_, _) => { if (binding.Context is { } ctx && binding.FieldName is { Length: > 0 } field) @@ -355,6 +353,30 @@ private static void WireTouchedOnBlur(UIElement formFieldRoot, UIElement content }; } + /// + /// 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 (_rootBindings.TryGetValue(formFieldRoot, out var previous)) + { + if (ReferenceEquals(previous, binding)) return; + + previous.Context = null; + previous.FieldName = null; + _rootBindings.Remove(formFieldRoot); + } + + _rootBindings.Add(formFieldRoot, binding); + } + /// /// Neutralizes the blur binding on a FormField's content control when the /// field unmounts. 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 index 246f8b582..42dff1592 100644 --- a/tests/Reactor.Tests/ValidationRenderScopeTests.cs +++ b/tests/Reactor.Tests/ValidationRenderScopeTests.cs @@ -144,6 +144,7 @@ public void Revalidating_An_Unchanged_Value_Neither_Bumps_Version_Nor_Notifies() _ = 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 @@ -155,7 +156,7 @@ public void Revalidating_An_Unchanged_Value_Neither_Bumps_Version_Nor_Notifies() } Assert.Equal(versionAfterFirstPass, ctx.Version); - Assert.Equal(0, notifications); // suppressed during render anyway + Assert.Equal(notificationsAfterFirstPass, notifications); Assert.Single(ctx.GetMessages("email")); // and not accumulated } @@ -251,7 +252,7 @@ public void A_Rule_Flipping_Verdict_Notifies_Each_Way() } [Fact] - public void Changed_Is_Suppressed_While_A_Render_Pass_Is_In_Flight() + public void Changed_Is_Deferred_Until_The_Render_Pass_Ends() { var ctx = new ValidationContext(); var notifications = 0; @@ -261,16 +262,122 @@ public void Changed_Is_Suppressed_While_A_Render_Pass_Is_In_Flight() { 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); } - // The rendering component reads the new state later in the same pass, so - // notifying would only 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 // ════════════════════════════════════════════════════════════════ From 09548a91f1e670919911af65204ed42efc6fbcf5 Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Wed, 23 Sep 2026 14:35:20 -0700 Subject: [PATCH 05/67] Address code-quality findings on the new validation fixtures - Issue1262_FailingRuleDoesNotLoop kept its probe state in static fields written from RuleOwner.Render(). The probe is now an instance object passed through Component, so nothing static is mutated and the fixture carries no cross-run state. - Both new fixtures asserted a control was non-null through H.Check and then dereferenced it with the null-forgiving operator. They now bail out after the check, so a missing control fails the check instead of throwing later. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 373c7212-34f6-4ddf-8ae9-59553c77cf48 --- .../Fixtures/ValidationCoverageFixtures.cs | 53 ++++++++++--------- 1 file changed, 28 insertions(+), 25 deletions(-) diff --git a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs index e0c6e0160..2e346bbda 100644 --- a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs +++ b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs @@ -682,20 +682,25 @@ public override async Task RunAsync() internal class Issue1262_FailingRuleDoesNotLoop(Harness h) : SelfTestFixtureBase(h) { - private static ValidationContext? s_captured; - private static int s_renders; + 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 + private sealed class RuleOwner : Component { public override Element Render() { var valCtx = this.UseValidationContext(); - s_captured = valCtx; - s_renders++; + Props.Probe.Context = valCtx; + Props.Probe.Renders++; return VStack(12, ValidationRule(() => false, "Passwords must match", "confirm"), @@ -705,31 +710,30 @@ public override Element Render() public override async Task RunAsync() { - s_captured = null; - s_renders = 0; - + var probe = new RuleProbe(); var host = H.CreateHost(); - host.Mount(_ => Component()); + host.Mount(_ => Component(new RuleOwnerProps(probe))); // If the loop were still present this throws "Render loop detected". await Harness.Render(); await Harness.Render(); - H.Check("Issue1262_Rule_NoLoopThrown", s_captured is not null); - H.Check("Issue1262_Rule_MessageRecorded", - s_captured!.GetMessages("confirm").Count == 1); - H.Check("Issue1262_Rule_NoAccumulation", - s_captured.GetAllMessages().Count == 1); - H.Check("Issue1262_Rule_RenderCountBounded", s_renders < 10, $"renders={s_renders}"); + 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 = s_captured.Version; - var settledRenders = s_renders; + var settledVersion = captured.Version; + var settledRenders = probe.Renders; await Harness.Render(); - H.Check("Issue1262_Rule_VersionStableOnReRender", s_captured.Version == settledVersion, - $"before={settledVersion} after={s_captured.Version}"); - H.Check("Issue1262_Rule_RendersSettle", s_renders - settledRenders <= 2, - $"delta={s_renders - settledRenders}"); + 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")); @@ -773,11 +777,12 @@ public override async Task RunAsync() var box = H.FindControl(_ => true); 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); + box.Focus(Microsoft.UI.Xaml.FocusState.Programmatic); await Harness.Render(); - elsewhere!.Focus(Microsoft.UI.Xaml.FocusState.Programmatic); + elsewhere.Focus(Microsoft.UI.Xaml.FocusState.Programmatic); await Harness.Render(); H.Check("Issue1262_Binding_MarksWhileBound", ctx.IsTouched("name")); @@ -789,8 +794,6 @@ public override async Task RunAsync() H.Check("Issue1262_Binding_ControlPreserved", stillSameControl && ReferenceEquals(box, H.FindControl(_ => true))); - var freshCtx = new ValidationContext(); - freshCtx.RegisterField("name"); var touchedBefore = ctx.IsTouched("name"); // Re-blur now that nothing provides a context. From 0c98d766926b5db2ec740cf856f4685100032204 Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Wed, 23 Sep 2026 15:00:15 -0700 Subject: [PATCH 06/67] Address Copilot review round 3: seed-then-notify loop, async rule double-raise - SetInitialValue rewound _currentValues on every call. Components commonly run RegisterField / SetInitialValue / NotifyValueChanged together on each render (the repo's own DirtyResetDemo does), so once the user had typed, the rewind and the re-notify took turns and the new change notification repainted forever. The current value is now seeded only the first time a field is seen; Reset remains the way to deliberately return a field to its baseline. - ValidationRule.EvaluateAsync cleared the previous verdict before awaiting, so an already-failing async rule raised Changed twice per evaluation and briefly reported the field valid in between. The verdict is now kept in flight and replaced once, which also makes the "unchanged outcome stays silent" property actually true for the async path. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 373c7212-34f6-4ddf-8ae9-59553c77cf48 --- .../Controls/Validation/ValidationContext.cs | 12 +++- .../Controls/Validation/ValidationRule.cs | 9 ++- .../ValidationRenderScopeTests.cs | 70 +++++++++++++++++++ 3 files changed, 87 insertions(+), 4 deletions(-) diff --git a/src/Reactor/Controls/Validation/ValidationContext.cs b/src/Reactor/Controls/Validation/ValidationContext.cs index 1affc05d8..7dcfae788 100644 --- a/src/Reactor/Controls/Validation/ValidationContext.cs +++ b/src/Reactor/Controls/Validation/ValidationContext.cs @@ -492,13 +492,23 @@ 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) { lock (_lock) { _initialValues[field] = value; - _currentValues[field] = value; + if (!_currentValues.ContainsKey(field)) + _currentValues[field] = value; } } diff --git a/src/Reactor/Controls/Validation/ValidationRule.cs b/src/Reactor/Controls/Validation/ValidationRule.cs index d0511ef17..932e75594 100644 --- a/src/Reactor/Controls/Validation/ValidationRule.cs +++ b/src/Reactor/Controls/Validation/ValidationRule.cs @@ -78,6 +78,12 @@ public static void Evaluate(this ValidationRuleElement rule, ValidationContext c /// /// 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. + /// /// public static async Task EvaluateAsync(this ValidationRuleElement rule, ValidationContext ctx, CancellationToken cancellationToken = default) @@ -88,9 +94,6 @@ public static async Task EvaluateAsync(this ValidationRuleElement rule, Validati return; } - // Drop the previous verdict while the check is in flight, then install the new - // one — both diffed, so an unchanged outcome stays silent. - ctx.ReplaceInternal(rule.Field, []); var result = await rule.AsyncPredicate(); cancellationToken.ThrowIfCancellationRequested(); diff --git a/tests/Reactor.Tests/ValidationRenderScopeTests.cs b/tests/Reactor.Tests/ValidationRenderScopeTests.cs index 42dff1592..1bce5d14d 100644 --- a/tests/Reactor.Tests/ValidationRenderScopeTests.cs +++ b/tests/Reactor.Tests/ValidationRenderScopeTests.cs @@ -226,6 +226,76 @@ public void A_Failing_Rule_Re_Evaluated_Is_Silent() 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"); + Assert.Equal(0, notifications); // nothing moved on first paint + Assert.False(ctx.IsDirty("name")); + + RenderPass("John Doex"); // user typed + var afterEdit = notifications; + Assert.Equal(1, 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 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() { From e93c21c98fcf5e77e76a723eb8bce6e143ed0ee6 Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Wed, 23 Sep 2026 15:22:25 -0700 Subject: [PATCH 07/67] Address Copilot review round 4: standalone host dispatcher, async atomicity, re-baseline - ReactorHostControl never seeded ReactorApp.UIDispatcher, which ReactorHost does precisely for embedded hosts. Cross-thread setState resolves its marshal target from that static, so a background async validator raising Changed made the re-render subscription throw instead of repainting in a standalone host. - ValidationReconciler.ValidateFieldAsync added each result as it resolved: a field with two failing async validators exposed a partial verdict, repainted between messages, and appended duplicates on every re-run because nothing retracted the previous pass. The whole result is now installed in one atomic ApplyAsyncValidation, which retracts the exact instances the last async pass contributed so synchronous validator messages survive. - SetInitialValue could flip IsDirty without notifying, leaving subscribers stale after a re-baseline. It is now delta-gated on the dirty result, so adopting an edited value as the new baseline notifies once and an unchanged re-seed stays silent. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 373c7212-34f6-4ddf-8ae9-59553c77cf48 --- .../Controls/Validation/ValidationContext.cs | 77 +++++++++++++ .../Validation/ValidationReconciler.cs | 8 +- src/Reactor/Hosting/ReactorHostControl.cs | 7 ++ .../ValidationRenderScopeTests.cs | 102 ++++++++++++++++++ 4 files changed, 193 insertions(+), 1 deletion(-) diff --git a/src/Reactor/Controls/Validation/ValidationContext.cs b/src/Reactor/Controls/Validation/ValidationContext.cs index 7dcfae788..5a38a8857 100644 --- a/src/Reactor/Controls/Validation/ValidationContext.cs +++ b/src/Reactor/Controls/Validation/ValidationContext.cs @@ -9,6 +9,9 @@ public sealed class ValidationContext private readonly object _lock = new(); private readonly Dictionary> _messages = new(); private readonly Dictionary> _externalMessages = new(); + // Tracks the exact message instances the last async pass contributed per field, so a + // later pass can retract them without clearing synchronous results too. + private readonly Dictionary> _asyncOwned = new(); private readonly HashSet _registeredFields = new(); private readonly HashSet _touchedFields = new(); private readonly Dictionary _initialValues = new(); @@ -245,6 +248,65 @@ private static bool SameMessages(List? a, List + /// Installs the complete result of an async validation pass for a field in one step, + /// replacing whatever the previous async pass contributed while leaving synchronous + /// validator messages in place. + /// + /// The async path used to Add each result as it resolved, which exposed a + /// partial verdict, repainted between messages, and appended duplicates on every + /// re-run because nothing retracted the previous pass. The instances contributed by + /// the last pass are tracked so they can be removed precisely, rather than by + /// clearing the field — which would also discard sync results. + /// + /// + internal void ApplyAsyncValidation(string field, List messages) + { + bool changed; + lock (_lock) + { + _messages.TryGetValue(field, out var current); + _asyncOwned.TryGetValue(field, out var previouslyOwned); + + var next = new List(); + if (current is not null) + { + foreach (var message in current) + { + if (previouslyOwned is not null && ContainsReference(previouslyOwned, message)) + continue; + next.Add(message); + } + } + next.AddRange(messages); + + changed = !SameMessages(current, next); + if (changed) + { + if (next.Count == 0) + _messages.Remove(field); + else + _messages[field] = next; + _version++; + } + + if (messages.Count == 0) + _asyncOwned.Remove(field); + else + _asyncOwned[field] = messages; + } + if (changed) RaiseChanged(); + } + + private static bool ContainsReference(List list, ValidationMessage message) + { + foreach (var candidate in list) + { + if (ReferenceEquals(candidate, message)) return true; + } + return false; + } + /// /// 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 @@ -504,12 +566,27 @@ public void MarkAllTouched() /// public void SetInitialValue(string field, object? value) { + bool changed; lock (_lock) { + var wasDirty = IsDirtyLocked(field); _initialValues[field] = value; if (!_currentValues.ContainsKey(field)) _currentValues[field] = value; + + // Re-baselining an edited field flips IsDirty without touching messages or + // touched state, so subscribers have to hear about it too. + changed = IsDirtyLocked(field) != wasDirty; + if (changed) _version++; } + 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); } /// diff --git a/src/Reactor/Controls/Validation/ValidationReconciler.cs b/src/Reactor/Controls/Validation/ValidationReconciler.cs index 27d9aec64..9a3d69841 100644 --- a/src/Reactor/Controls/Validation/ValidationReconciler.cs +++ b/src/Reactor/Controls/Validation/ValidationReconciler.cs @@ -61,12 +61,18 @@ public static async Task ValidateFieldAsync( IAsyncValidator[] asyncValidators, CancellationToken cancellationToken = default) { + 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 the previous async result instead of + // appending a duplicate. + ctx.ApplyAsyncValidation(fieldName, messages); } /// diff --git a/src/Reactor/Hosting/ReactorHostControl.cs b/src/Reactor/Hosting/ReactorHostControl.cs index 2f5d3024b..32edb9abd 100644 --- a/src/Reactor/Hosting/ReactorHostControl.cs +++ b/src/Reactor/Hosting/ReactorHostControl.cs @@ -145,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 diff --git a/tests/Reactor.Tests/ValidationRenderScopeTests.cs b/tests/Reactor.Tests/ValidationRenderScopeTests.cs index 1bce5d14d..b2d5891bf 100644 --- a/tests/Reactor.Tests/ValidationRenderScopeTests.cs +++ b/tests/Reactor.Tests/ValidationRenderScopeTests.cs @@ -272,6 +272,108 @@ public void Re_Seeding_An_Initial_Value_Does_Not_Rewind_The_Current_One() 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. + Assert.Single(seen); + Assert.Equal(2, seen[0]); + 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 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() { From 56f0ca34fb711ed861fce43ad24b643fd6e5868c Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Wed, 23 Sep 2026 16:03:11 -0700 Subject: [PATCH 08/67] Address Copilot review round 5: per-producer message ownership A field's internal messages are written by several independent producers: the synchronous .Validate() chain, each cross-field ValidationRule, and the async validator pass. Every one of them replaced the whole field, so the last writer won -- a passing rule could erase a required-field error and report the form valid. Messages are now owned per producer: each retracts exactly the instances it contributed last time and leaves the rest alone. - Replacement happens in place rather than remove-then-append. Appending would let two producers on one field swap positions every render, and an order change is a structural change, so it would notify on every pass -- the very loop this design exists to prevent. Interleaved_Producers_On_One_Field_Settle pins it. - Ownership is now only repointed when the diff actually installed the new instances. Overwriting it after an unchanged diff left nothing to retract, and the pass after that appended a duplicate -- a bug introduced in the previous round and caught here on the third identical async pass. - Async passes carry a per-field generation token, so an older value's check that resolves after a newer one's is discarded instead of overwriting the current verdict with a stale error. ValRule_AsyncPass asserted the old behaviour: it cleared a failing rule's error by evaluating a *different*, passing rule on the same field. Rewritten to re-evaluate the same rule, plus a new check that an unrelated passing rule leaves another producer's error in place. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 373c7212-34f6-4ddf-8ae9-59553c77cf48 --- .../Controls/Validation/ValidationContext.cs | 193 +++++++++++++----- .../Validation/ValidationReconciler.cs | 9 +- .../Controls/Validation/ValidationRule.cs | 12 +- .../Fixtures/ValidationCoverageFixtures.cs | 19 +- .../ValidationRenderScopeTests.cs | 112 ++++++++++ 5 files changed, 289 insertions(+), 56 deletions(-) diff --git a/src/Reactor/Controls/Validation/ValidationContext.cs b/src/Reactor/Controls/Validation/ValidationContext.cs index 5a38a8857..0556e1f4a 100644 --- a/src/Reactor/Controls/Validation/ValidationContext.cs +++ b/src/Reactor/Controls/Validation/ValidationContext.cs @@ -9,9 +9,11 @@ public sealed class ValidationContext private readonly object _lock = new(); private readonly Dictionary> _messages = new(); private readonly Dictionary> _externalMessages = new(); - // Tracks the exact message instances the last async pass contributed per field, so a - // later pass can retract them without clearing synchronous results too. - private readonly Dictionary> _asyncOwned = 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. + private readonly Dictionary>> _owned = new(); + // field -> newest async pass token; older passes that resolve late are discarded. + private readonly Dictionary _asyncGeneration = new(); private readonly HashSet _registeredFields = new(); private readonly HashSet _touchedFields = new(); private readonly Dictionary _initialValues = new(); @@ -185,6 +187,7 @@ public void Clear(string field) changed = false; if (_messages.Remove(field)) changed = true; if (_externalMessages.Remove(field)) changed = true; + _owned.Remove(field); if (changed) _version++; } if (changed) RaiseChanged(); @@ -200,6 +203,7 @@ internal void ClearInternal(string field) lock (_lock) { changed = _messages.Remove(field); + _owned.Remove(field); if (changed) _version++; } if (changed) RaiseChanged(); @@ -225,6 +229,8 @@ internal void ReplaceInternal(string field, List messages) { _messages.TryGetValue(field, out var existing); changed = !SameMessages(existing, messages); + // Wholesale replacement invalidates every producer's claim on this field. + _owned.Remove(field); if (changed) { if (messages.Count == 0) @@ -237,6 +243,104 @@ internal void ReplaceInternal(string field, List messages) if (changed) RaiseChanged(); } + /// + /// 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 both the original + /// clear-then-add and a plain do — 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) _version++; + } + if (changed) RaiseChanged(); + } + + private bool ApplyOwnedLocked(string field, string producer, List messages) + { + _messages.TryGetValue(field, out var current); + List? previous = null; + if (_owned.TryGetValue(field, out var byProducer)) + byProducer.TryGetValue(producer, out previous); + + var next = new List( + (current?.Count ?? 0) + messages.Count); + var taken = 0; + + if (current is not null) + { + foreach (var message in current) + { + if (previous is not null && ContainsReference(previous, message)) + { + // Substitute this producer's next message at the same position. + if (taken < messages.Count) next.Add(messages[taken++]); + continue; + } + next.Add(message); + } + } + + for (; taken < messages.Count; taken++) + next.Add(messages[taken]); + + var changed = !SameMessages(current, next); + if (changed) + { + if (next.Count == 0) + _messages.Remove(field); + else + _messages[field] = next; + } + + // Ownership must name instances that are actually installed. When the diff came + // back unchanged the stored list is still in _messages, so keeping it is not a + // micro-optimisation: overwriting it with the freshly allocated (equal but + // distinct) instances would leave nothing to retract next time, and the pass + // after that would append a duplicate. + if (changed) + { + if (messages.Count == 0) + { + if (byProducer is not null) + { + byProducer.Remove(producer); + if (byProducer.Count == 0) _owned.Remove(field); + } + } + else + { + byProducer ??= _owned[field] = new Dictionary>(StringComparer.Ordinal); + byProducer[producer] = messages; + } + } + else if (messages.Count == 0 && byProducer is not null) + { + byProducer.Remove(producer); + if (byProducer.Count == 0) _owned.Remove(field); + } + + return changed; + } + private static bool SameMessages(List? a, List b) { var countA = a?.Count ?? 0; @@ -249,55 +353,47 @@ private static bool SameMessages(List? a, List - /// Installs the complete result of an async validation pass for a field in one step, - /// replacing whatever the previous async pass contributed while leaving synchronous - /// validator messages in place. + /// Installs the complete result of an async validation pass for a field in one step. /// - /// The async path used to Add each result as it resolved, which exposed a - /// partial verdict, repainted between messages, and appended duplicates on every - /// re-run because nothing retracted the previous pass. The instances contributed by - /// the last pass are tracked so they can be removed precisely, rather than by - /// clearing the field — which would also discard sync results. + /// 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, List messages) + internal void ApplyAsyncValidation(string field, int generation, List messages) { bool changed; lock (_lock) { - _messages.TryGetValue(field, out var current); - _asyncOwned.TryGetValue(field, out var previouslyOwned); - - var next = new List(); - if (current is not null) - { - foreach (var message in current) - { - if (previouslyOwned is not null && ContainsReference(previouslyOwned, message)) - continue; - next.Add(message); - } - } - next.AddRange(messages); - - changed = !SameMessages(current, next); - if (changed) - { - if (next.Count == 0) - _messages.Remove(field); - else - _messages[field] = next; - _version++; - } + if (!_asyncGeneration.TryGetValue(field, out var newest) || newest != generation) + return; - if (messages.Count == 0) - _asyncOwned.Remove(field); - else - _asyncOwned[field] = messages; + changed = ApplyOwnedLocked(field, AsyncProducer, messages); + if (changed) _version++; } if (changed) RaiseChanged(); } + /// + /// 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. + /// + internal int BeginAsyncValidation(string field) + { + lock (_lock) + { + _asyncGeneration.TryGetValue(field, out var current); + var next = unchecked(current + 1); + _asyncGeneration[field] = next; + return next; + } + } + + internal const string SyncProducer = "sync"; + internal const string AsyncProducer = "async"; + private static bool ContainsReference(List list, ValidationMessage message) { foreach (var candidate in list) @@ -336,15 +432,9 @@ internal void ApplyValidation(string field, object? value, List 0 || _externalMessages.Count > 0; _messages.Clear(); _externalMessages.Clear(); + _owned.Clear(); if (changed) _version++; } if (changed) RaiseChanged(); @@ -667,6 +758,8 @@ public bool IsDirty() changed = _touchedFields.Remove(field); if (_messages.Remove(field)) changed = true; if (_externalMessages.Remove(field)) changed = true; + _owned.Remove(field); + _asyncGeneration.Remove(field); _initialValues.TryGetValue(field, out initial); // Only rewind a value the context is actually tracking. Creating an entry @@ -699,6 +792,8 @@ public bool IsDirty() _touchedFields.Clear(); _messages.Clear(); _externalMessages.Clear(); + _owned.Clear(); + _asyncGeneration.Clear(); result = new Dictionary(); foreach (var (field, initial) in _initialValues) diff --git a/src/Reactor/Controls/Validation/ValidationReconciler.cs b/src/Reactor/Controls/Validation/ValidationReconciler.cs index 9a3d69841..f998b96e5 100644 --- a/src/Reactor/Controls/Validation/ValidationReconciler.cs +++ b/src/Reactor/Controls/Validation/ValidationReconciler.cs @@ -61,6 +61,8 @@ public static async Task ValidateFieldAsync( IAsyncValidator[] asyncValidators, CancellationToken cancellationToken = default) { + var generation = ctx.BeginAsyncValidation(fieldName); + var messages = new List(asyncValidators.Length); foreach (var validator in asyncValidators) { @@ -70,9 +72,10 @@ public static async Task ValidateFieldAsync( } // One atomic install once every validator has resolved: no partial verdict, one - // notification, and re-running replaces the previous async result instead of - // appending a duplicate. - ctx.ApplyAsyncValidation(fieldName, messages); + // 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); } /// diff --git a/src/Reactor/Controls/Validation/ValidationRule.cs b/src/Reactor/Controls/Validation/ValidationRule.cs index 932e75594..c9d044c90 100644 --- a/src/Reactor/Controls/Validation/ValidationRule.cs +++ b/src/Reactor/Controls/Validation/ValidationRule.cs @@ -73,7 +73,7 @@ public static ValidationRuleElement ValidationRuleAsync( /// public static void Evaluate(this ValidationRuleElement rule, ValidationContext ctx) { - ctx.ReplaceInternal(rule.Field, BuildMessages(rule, rule.Predicate())); + ctx.ApplyOwned(rule.Field, ProducerKey(rule), BuildMessages(rule, rule.Predicate())); } /// @@ -97,9 +97,17 @@ public static async Task EvaluateAsync(this ValidationRuleElement rule, Validati var result = await rule.AsyncPredicate(); cancellationToken.ThrowIfCancellationRequested(); - ctx.ReplaceInternal(rule.Field, BuildMessages(rule, result)); + ctx.ApplyOwned(rule.Field, ProducerKey(rule), BuildMessages(rule, result)); } + /// + /// Identifies a rule as a message producer on its field. Rule elements are records + /// rebuilt every render, so identity has to come from what the rule contributes — + /// its message and severity — rather than from the instance. + /// + private static string ProducerKey(ValidationRuleElement rule) => + $"rule:{(int)rule.Severity}:{rule.Message}"; + private static List BuildMessages(ValidationRuleElement rule, bool passed) => passed ? [] : [new ValidationMessage(rule.Field, rule.Message, rule.Severity)]; } diff --git a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs index 2e346bbda..3c58340ba 100644 --- a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs +++ b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs @@ -371,13 +371,28 @@ public override async Task RunAsync() await asyncRule.EvaluateAsync(ctx); H.Check("ValRule_AsyncFail", ctx.HasError("field2")); - // Async rule — passes + // Async rule — the same rule (same message, so the same producer) now + // passes and retracts its own message. Using a *different* rule here would + // assert the old whole-field-replace behaviour, where any passing rule + // erased every other producer's errors on the field (issue #1262 review). var asyncRule2 = ValidationRuleAsync( async () => { await Task.Delay(1); return true; }, - "Async pass", "field2"); + "Async fail", "field2"); await asyncRule2.EvaluateAsync(ctx); 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); diff --git a/tests/Reactor.Tests/ValidationRenderScopeTests.cs b/tests/Reactor.Tests/ValidationRenderScopeTests.cs index b2d5891bf..1c273d979 100644 --- a/tests/Reactor.Tests/ValidationRenderScopeTests.cs +++ b/tests/Reactor.Tests/ValidationRenderScopeTests.cs @@ -345,6 +345,118 @@ await ValidationReconciler.ValidateFieldAsync( 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 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")); + ValidationRule(() => false, "Passwords must match", "confirm").Evaluate(ctx); + + // Whole-field replacement made a passing rule wipe the required error and report + // the form valid. + ValidationRule(() => true, "Passwords must match", "confirm").Evaluate(ctx); + + 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() { From 2a2ed359b5f2337b3f77b189a5d7a6f966e050ff Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Wed, 23 Sep 2026 16:35:17 -0700 Subject: [PATCH 09/67] Address Copilot review round 6: async generation on clear, per-rule identity - Clear/ClearAll/ClearInternal dropped a field's messages and ownership but left its async generation token intact, so an in-flight ValidateFieldAsync could resolve afterwards and repopulate a field that had just been cleared. The token is now invalidated alongside the messages (Reset/ResetAll already did). - A rule's producer identity came from its message and severity. An interpolated message therefore changed identity every render, orphaning the previous message and appending a new one, while two independent rules that shared a message shared an identity and could retract each other. Rules mounted through the element tree are now keyed on their collapsed placeholder, which the reconciler keeps across re-renders, and EvaluateRules keys by position. The message-derived key remains only as the last-resort identity for a rule evaluated outside the reconciler, and says so. - Removed ReplaceInternal, which the per-producer model left with no callers. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 373c7212-34f6-4ddf-8ae9-59553c77cf48 --- .../Controls/Validation/ValidationContext.cs | 45 ++++--------------- .../Validation/ValidationReconciler.cs | 9 +++- .../Controls/Validation/ValidationRule.cs | 38 ++++++++++++---- .../Core/V1Protocol/CompositeLifecycle.cs | 31 ++++++++++--- .../V1Protocol/Handlers/CompositeHandlers.cs | 2 +- 5 files changed, 72 insertions(+), 53 deletions(-) diff --git a/src/Reactor/Controls/Validation/ValidationContext.cs b/src/Reactor/Controls/Validation/ValidationContext.cs index 0556e1f4a..4d5fb501f 100644 --- a/src/Reactor/Controls/Validation/ValidationContext.cs +++ b/src/Reactor/Controls/Validation/ValidationContext.cs @@ -188,6 +188,9 @@ public void Clear(string field) if (_messages.Remove(field)) changed = true; if (_externalMessages.Remove(field)) changed = true; _owned.Remove(field); + // An async pass still in flight would otherwise repopulate what this just + // cleared: dropping the token makes its result stale on arrival. + _asyncGeneration.Remove(field); if (changed) _version++; } if (changed) RaiseChanged(); @@ -204,44 +207,13 @@ internal void ClearInternal(string field) { changed = _messages.Remove(field); _owned.Remove(field); + // As in Clear: a pending async pass must not repopulate what this dropped. + _asyncGeneration.Remove(field); if (changed) _version++; } if (changed) RaiseChanged(); } - /// - /// Replaces the internal (validator-produced) messages for a field in one step, - /// bumping and raising only when the new - /// set actually differs from the old one. - /// - /// This is what makes per-render validation safe. The previous - /// ClearInternal + N×Add sequence bumped the version two or more - /// times on *every* pass even when the value and its verdict were unchanged, so once - /// validators run on every render a change-notification built on that signal would - /// re-render forever. is a record, so the comparison - /// below is ordinary structural equality. - /// - /// - internal void ReplaceInternal(string field, List messages) - { - bool changed; - lock (_lock) - { - _messages.TryGetValue(field, out var existing); - changed = !SameMessages(existing, messages); - // Wholesale replacement invalidates every producer's claim on this field. - _owned.Remove(field); - if (changed) - { - if (messages.Count == 0) - _messages.Remove(field); - else - _messages[field] = messages; - _version++; - } - } - if (changed) RaiseChanged(); - } /// /// Installs one producer's contribution to a field's internal messages, leaving @@ -249,9 +221,9 @@ internal void ReplaceInternal(string field, List messages) /// /// 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 both the original - /// clear-then-add and a plain do — means the last - /// writer wins, so a passing rule could erase a required-field error and make + /// 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. /// @@ -468,6 +440,7 @@ public void ClearAll() _messages.Clear(); _externalMessages.Clear(); _owned.Clear(); + _asyncGeneration.Clear(); if (changed) _version++; } if (changed) RaiseChanged(); diff --git a/src/Reactor/Controls/Validation/ValidationReconciler.cs b/src/Reactor/Controls/Validation/ValidationReconciler.cs index f998b96e5..03a5bfb0a 100644 --- a/src/Reactor/Controls/Validation/ValidationReconciler.cs +++ b/src/Reactor/Controls/Validation/ValidationReconciler.cs @@ -80,14 +80,19 @@ public static async Task ValidateFieldAsync( /// /// Evaluates all ValidationRuleElements in a list and pushes results to the context. + /// + /// Each rule is keyed by its position in the list, so re-running the same set + /// replaces each rule's previous verdict rather than accumulating — and two rules on + /// one field stay independent even if they carry the same message. + /// /// public static void EvaluateRules( ValidationContext ctx, params ValidationRuleElement[] rules) { - foreach (var rule in rules) + for (var i = 0; i < rules.Length; i++) { - rule.Evaluate(ctx); + rules[i].Evaluate(ctx, "rules[" + i.ToString(global::System.Globalization.CultureInfo.InvariantCulture) + "]"); } } } diff --git a/src/Reactor/Controls/Validation/ValidationRule.cs b/src/Reactor/Controls/Validation/ValidationRule.cs index c9d044c90..a7ea4f256 100644 --- a/src/Reactor/Controls/Validation/ValidationRule.cs +++ b/src/Reactor/Controls/Validation/ValidationRule.cs @@ -73,7 +73,17 @@ public static ValidationRuleElement ValidationRuleAsync( /// public static void Evaluate(this ValidationRuleElement rule, ValidationContext ctx) { - ctx.ApplyOwned(rule.Field, ProducerKey(rule), BuildMessages(rule, rule.Predicate())); + rule.Evaluate(ctx, FallbackProducerKey(rule)); + } + + /// + /// 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. + /// + internal static void Evaluate(this ValidationRuleElement rule, ValidationContext ctx, string producer) + { + ctx.ApplyOwned(rule.Field, producer, BuildMessages(rule, rule.Predicate())); } /// @@ -85,27 +95,39 @@ public static void Evaluate(this ValidationRuleElement rule, ValidationContext c /// field as valid in between. /// /// - 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; } var result = await rule.AsyncPredicate(); cancellationToken.ThrowIfCancellationRequested(); - ctx.ApplyOwned(rule.Field, ProducerKey(rule), BuildMessages(rule, result)); + ctx.ApplyOwned(rule.Field, producer, BuildMessages(rule, result)); } /// - /// Identifies a rule as a message producer on its field. Rule elements are records - /// rebuilt every render, so identity has to come from what the rule contributes — - /// its message and severity — rather than from the instance. + /// Identity of last resort for a rule evaluated outside the reconciler, where there + /// is no mounted instance to key on. + /// + /// It is derived from the message and severity, which is stable for the overwhelmingly + /// common case of a fixed message, but not for an interpolated one + /// ($"Must be after {start}") — a changed message reads as a different + /// producer, orphaning the previous one. Rules mounted through the element tree get + /// a real per-instance identity instead and are unaffected; + /// keys by position. Prefer either + /// over calling Evaluate directly in a render loop. + /// /// - private static string ProducerKey(ValidationRuleElement rule) => + internal static string FallbackProducerKey(ValidationRuleElement rule) => $"rule:{(int)rule.Severity}:{rule.Message}"; private static List BuildMessages(ValidationRuleElement rule, bool passed) => diff --git a/src/Reactor/Core/V1Protocol/CompositeLifecycle.cs b/src/Reactor/Core/V1Protocol/CompositeLifecycle.cs index b6497a98b..cdad92da8 100644 --- a/src/Reactor/Core/V1Protocol/CompositeLifecycle.cs +++ b/src/Reactor/Core/V1Protocol/CompositeLifecycle.cs @@ -286,25 +286,44 @@ 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 producer = RuleProducerKey(placeholder); + var valCtx = reconciler.ReadContext(ValidationContexts.Current); if (valCtx is not null) - rule.Evaluate(valCtx); + rule.Evaluate(valCtx, producer); - // 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 valCtx = reconciler.ReadContext(ValidationContexts.Current); if (valCtx is not null) - rule.Evaluate(valCtx); + rule.Evaluate(valCtx, RuleProducerKey(control)); return null; // keep existing collapsed placeholder } + private static long s_ruleProducerSeed; + private static readonly global::System.Runtime.CompilerServices.ConditionalWeakTable _ruleProducers = new(); + + private static string RuleProducerKey(UIElement placeholder) + { + if (_ruleProducers.TryGetValue(placeholder, out var existing)) return existing; + + var key = "rule#" + global::System.Threading.Interlocked + .Increment(ref s_ruleProducerSeed) + .ToString(global::System.Globalization.CultureInfo.InvariantCulture); + _ruleProducers.Add(placeholder, key); + return key; + } + /// /// Marks a field touched when its editor loses focus. /// diff --git a/src/Reactor/Core/V1Protocol/Handlers/CompositeHandlers.cs b/src/Reactor/Core/V1Protocol/Handlers/CompositeHandlers.cs index 6a746ac0d..97b05a739 100644 --- a/src/Reactor/Core/V1Protocol/Handlers/CompositeHandlers.cs +++ b/src/Reactor/Core/V1Protocol/Handlers/CompositeHandlers.cs @@ -75,7 +75,7 @@ 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; From a690695250074944d01601c6d54d9e612ee42fa0 Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Wed, 23 Sep 2026 16:40:28 -0700 Subject: [PATCH 10/67] Make ApplyOwnedLocked's producer-map nullability explicit The map is genuinely null when a field has no owners yet; hoisting the TryGetValue out of the condition and null-checking it once makes that visible to flow analysis and reads better than relying on the enclosing if. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 373c7212-34f6-4ddf-8ae9-59553c77cf48 --- src/Reactor/Controls/Validation/ValidationContext.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Reactor/Controls/Validation/ValidationContext.cs b/src/Reactor/Controls/Validation/ValidationContext.cs index 4d5fb501f..ff16c8833 100644 --- a/src/Reactor/Controls/Validation/ValidationContext.cs +++ b/src/Reactor/Controls/Validation/ValidationContext.cs @@ -249,8 +249,10 @@ internal void ApplyOwned(string field, string producer, List private bool ApplyOwnedLocked(string field, string producer, List messages) { _messages.TryGetValue(field, out var current); + _owned.TryGetValue(field, out var byProducer); + List? previous = null; - if (_owned.TryGetValue(field, out var byProducer)) + if (byProducer is not null) byProducer.TryGetValue(producer, out previous); var next = new List( From 36f0701340d96fc9daa32e87e7fdd98c3a324bc7 Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Wed, 23 Sep 2026 17:36:53 -0700 Subject: [PATCH 11/67] Make async validation tokens context-wide so a clear cannot reuse one Round 6 made the clearing operations drop a field's async generation entry so a pending pass could not repopulate what they had just cleared. That also reset the per-field sequence: a pass opened after a clear was handed the same number an older in-flight pass was still holding, and the stale result then passed the equality check and could overwrite the newer verdict. Tokens now come from a context-wide counter that is never reset, so a retired token can never be issued again. Clearing still drops the field entry, which keeps the original property that a pending result is discarded on arrival. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 373c7212-34f6-4ddf-8ae9-59553c77cf48 --- .../Controls/Validation/ValidationContext.cs | 17 +++++++--- .../ValidationRenderScopeTests.cs | 34 +++++++++++++++++++ 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/src/Reactor/Controls/Validation/ValidationContext.cs b/src/Reactor/Controls/Validation/ValidationContext.cs index ff16c8833..5ebedf775 100644 --- a/src/Reactor/Controls/Validation/ValidationContext.cs +++ b/src/Reactor/Controls/Validation/ValidationContext.cs @@ -14,6 +14,9 @@ public sealed class ValidationContext private readonly Dictionary>> _owned = new(); // field -> newest async pass token; older passes that resolve late are discarded. private readonly Dictionary _asyncGeneration = new(); + // 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(); @@ -353,15 +356,21 @@ internal void ApplyAsyncValidation(string field, int generation, List /// 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) { lock (_lock) { - _asyncGeneration.TryGetValue(field, out var current); - var next = unchecked(current + 1); - _asyncGeneration[field] = next; - return next; + var token = unchecked(++_asyncTicket); + _asyncGeneration[field] = token; + return token; } } diff --git a/tests/Reactor.Tests/ValidationRenderScopeTests.cs b/tests/Reactor.Tests/ValidationRenderScopeTests.cs index 1c273d979..755b011b0 100644 --- a/tests/Reactor.Tests/ValidationRenderScopeTests.cs +++ b/tests/Reactor.Tests/ValidationRenderScopeTests.cs @@ -405,6 +405,40 @@ public async Task A_Late_Async_Result_For_An_Older_Value_Is_Discarded() // 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_Cross_Field_Rule_Does_Not_Erase_Field_Level_Errors() { From 60fd57f6b8012d86d4b7f7211f4f5c8fa2bcc190 Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Wed, 23 Sep 2026 18:09:58 -0700 Subject: [PATCH 12/67] Retract rule verdicts when a rule is removed, moved, or unmounted Per-producer ownership gave each rule a durable claim on a field, but nothing ever released it. A rule that disappeared left its message behind and kept the form invalid forever. - ValidationRuleHandler.Unmount now withdraws the rule's contribution, so a conditionally rendered rule leaving the tree clears its verdict. - UpdateValidationRule tracks the context and field a mounted rule last wrote to, and withdraws from the old target first when either changes -- a rule can move between fields or into a different provider's context. - EvaluateRules owns the whole set for a context: any producer present last call and absent this one is withdrawn, so a shrinking list or a rule that changed field no longer strands a message. Documented, since positional keys mean one rule set per context; mount rules into the tree for independent sets. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 373c7212-34f6-4ddf-8ae9-59553c77cf48 --- .../Validation/ValidationReconciler.cs | 37 ++++++++-- .../Core/V1Protocol/CompositeLifecycle.cs | 70 ++++++++++++++++--- .../V1Protocol/Handlers/CompositeHandlers.cs | 7 +- .../Fixtures/ValidationCoverageFixtures.cs | 46 ++++++++++++ .../SelfTest/SelfTestFixtureRegistry.cs | 2 + .../ValidationRenderScopeTests.cs | 69 ++++++++++++++++++ 6 files changed, 214 insertions(+), 17 deletions(-) diff --git a/src/Reactor/Controls/Validation/ValidationReconciler.cs b/src/Reactor/Controls/Validation/ValidationReconciler.cs index 03a5bfb0a..0aa814dc0 100644 --- a/src/Reactor/Controls/Validation/ValidationReconciler.cs +++ b/src/Reactor/Controls/Validation/ValidationReconciler.cs @@ -79,20 +79,47 @@ public static async Task ValidateFieldAsync( } /// - /// Evaluates all ValidationRuleElements in a list and pushes results to the context. + /// Evaluates a complete set of ValidationRuleElements and pushes results to the + /// context. /// - /// Each rule is keyed by its position in the list, so re-running the same set - /// replaces each rule's previous verdict rather than accumulating — and two rules on - /// one field stay independent even if they carry the same message. + /// Each rule is keyed by its position, so re-running the same set replaces each + /// rule's previous verdict rather than accumulating, and two rules on one field stay + /// independent even if they carry the same message. + /// + /// + /// The call owns the whole set for that context: any producer from the previous call + /// that is absent this time — because the list shrank, or a rule moved to another + /// field — has its contribution withdrawn. Without that, a removed rule's message + /// would keep the form invalid forever. Mount rules into the element tree instead if + /// you need several independent rule sets against one context. /// /// public static void EvaluateRules( ValidationContext ctx, params ValidationRuleElement[] rules) { + var applied = new List<(string Field, string Producer)>(rules.Length); for (var i = 0; i < rules.Length; i++) { - rules[i].Evaluate(ctx, "rules[" + i.ToString(global::System.Globalization.CultureInfo.InvariantCulture) + "]"); + var producer = "rules[" + i.ToString(global::System.Globalization.CultureInfo.InvariantCulture) + "]"; + rules[i].Evaluate(ctx, producer); + applied.Add((rules[i].Field, producer)); } + + if (_ruleSets.TryGetValue(ctx, out var previous)) + { + foreach (var entry in previous) + { + if (!applied.Contains(entry)) + ctx.ApplyOwned(entry.Field, entry.Producer, []); + } + _ruleSets.Remove(ctx); + } + + _ruleSets.Add(ctx, applied); } + + // The rule set most recently evaluated against each context, so the next call can + // withdraw whatever disappeared. Weak on the context so it holds nothing alive. + private static readonly global::System.Runtime.CompilerServices.ConditionalWeakTable> _ruleSets = new(); } diff --git a/src/Reactor/Core/V1Protocol/CompositeLifecycle.cs b/src/Reactor/Core/V1Protocol/CompositeLifecycle.cs index cdad92da8..162772396 100644 --- a/src/Reactor/Core/V1Protocol/CompositeLifecycle.cs +++ b/src/Reactor/Core/V1Protocol/CompositeLifecycle.cs @@ -292,11 +292,15 @@ internal static UIElement MountValidationRule(Reconciler reconciler, ValidationR // 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 producer = RuleProducerKey(placeholder); + var binding = GetOrCreateRuleBinding(placeholder); var valCtx = reconciler.ReadContext(ValidationContexts.Current); if (valCtx is not null) - rule.Evaluate(valCtx, producer); + { + rule.Evaluate(valCtx, binding.Producer); + binding.Context = valCtx; + binding.Field = rule.Field; + } Reconciler.SetElementTag(placeholder, rule); return placeholder; @@ -304,24 +308,68 @@ internal static UIElement MountValidationRule(Reconciler reconciler, ValidationR 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))) + { + previousCtx.ApplyOwned(previousField, binding.Producer, []); + binding.Context = null; + binding.Field = null; + } + if (valCtx is not null) - rule.Evaluate(valCtx, RuleProducerKey(control)); + { + rule.Evaluate(valCtx, binding.Producer); + 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 (!_ruleBindings.TryGetValue(placeholder, out var binding)) return; + + if (binding.Context is { } ctx && binding.Field is { } field) + ctx.ApplyOwned(field, binding.Producer, []); + + binding.Context = null; + binding.Field = null; + } + + private sealed class RuleBinding + { + internal string Producer = ""; + internal ValidationContext? Context; + internal string? Field; + } + private static long s_ruleProducerSeed; - private static readonly global::System.Runtime.CompilerServices.ConditionalWeakTable _ruleProducers = new(); + private static readonly global::System.Runtime.CompilerServices.ConditionalWeakTable _ruleBindings = new(); - private static string RuleProducerKey(UIElement placeholder) + private static RuleBinding GetOrCreateRuleBinding(UIElement placeholder) { - if (_ruleProducers.TryGetValue(placeholder, out var existing)) return existing; + if (_ruleBindings.TryGetValue(placeholder, out var existing)) return existing; - var key = "rule#" + global::System.Threading.Interlocked - .Increment(ref s_ruleProducerSeed) - .ToString(global::System.Globalization.CultureInfo.InvariantCulture); - _ruleProducers.Add(placeholder, key); - return key; + var binding = new RuleBinding + { + Producer = "rule#" + global::System.Threading.Interlocked + .Increment(ref s_ruleProducerSeed) + .ToString(global::System.Globalization.CultureInfo.InvariantCulture), + }; + _ruleBindings.Add(placeholder, binding); + return binding; } /// diff --git a/src/Reactor/Core/V1Protocol/Handlers/CompositeHandlers.cs b/src/Reactor/Core/V1Protocol/Handlers/CompositeHandlers.cs index 97b05a739..3149db004 100644 --- a/src/Reactor/Core/V1Protocol/Handlers/CompositeHandlers.cs +++ b/src/Reactor/Core/V1Protocol/Handlers/CompositeHandlers.cs @@ -78,5 +78,10 @@ public UIElement Update(UpdateContext ctx, ValidationRuleElement oldEl, Validati => 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/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs index 3c58340ba..e3369dcc6 100644 --- a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs +++ b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs @@ -764,6 +764,52 @@ public override async Task RunAsync() // 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() diff --git a/tests/Reactor.AppTests.Host/SelfTest/SelfTestFixtureRegistry.cs b/tests/Reactor.AppTests.Host/SelfTest/SelfTestFixtureRegistry.cs index 364826b9e..1753424ba 100644 --- a/tests/Reactor.AppTests.Host/SelfTest/SelfTestFixtureRegistry.cs +++ b/tests/Reactor.AppTests.Host/SelfTest/SelfTestFixtureRegistry.cs @@ -812,6 +812,7 @@ internal static class SelfTestFixtureRegistry "ValCov_Issue1262_FormFieldTouchedOnBlur", "ValCov_Issue1262_FailingRuleNoLoop", "ValCov_Issue1262_TouchBindingCleared", + "ValCov_Issue1262_RuleRetractsOnUnmount", // Controls coverage — MaskEngine, InputFormatter, AutoSuggest "ControlsCov_MaskEngineBasic", "ControlsCov_MaskEngineNavigation", @@ -2718,6 +2719,7 @@ internal static string[] StaleTierDeclarations() => "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), // Controls coverage "ControlsCov_MaskEngineBasic" => new ControlsCoverageFixtures.MaskEngineBasic(harness), "ControlsCov_MaskEngineNavigation" => new ControlsCoverageFixtures.MaskEngineNavigation(harness), diff --git a/tests/Reactor.Tests/ValidationRenderScopeTests.cs b/tests/Reactor.Tests/ValidationRenderScopeTests.cs index 755b011b0..4290cff42 100644 --- a/tests/Reactor.Tests/ValidationRenderScopeTests.cs +++ b/tests/Reactor.Tests/ValidationRenderScopeTests.cs @@ -439,6 +439,75 @@ await ValidationReconciler.ValidateFieldAsync( Assert.Empty(ctx.GetMessages("username")); } + [Fact] + public void A_Shrinking_Rule_Set_Withdraws_The_Rules_That_Disappeared() + { + var ctx = new ValidationContext(); + + ValidationReconciler.EvaluateRules(ctx, + 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, + 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, + ValidationRule(() => false, "Rule failed", "form")); + Assert.False(ctx.IsValid()); + + ValidationReconciler.EvaluateRules(ctx); + + 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, + ValidationRule(() => false, "Rule failed", "start")); + Assert.Single(ctx.GetMessages("start")); + + ValidationReconciler.EvaluateRules(ctx, + 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, + 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); + + var texts = ctx.GetMessages("form").Select(m => m.Text).ToList(); + Assert.Single(texts); + Assert.Contains("Field is required", texts); + } + [Fact] public void A_Cross_Field_Rule_Does_Not_Erase_Field_Level_Errors() { From 87860d42dc949e45e0fa20da1971bb8ff2afc62d Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Wed, 23 Sep 2026 18:32:17 -0700 Subject: [PATCH 13/67] Invalidate async verdicts on a synchronous value change; document ValidateAsync - The generation token only superseded another async pass. A sync value change left it intact, so an async check opened for the old value could resolve afterwards and install an error for a value no longer on screen -- and an already-installed async verdict for the old value simply stayed visible. ApplyValidation now retires the field's async generation and withdraws the async producer's contribution whenever the value actually changes. Re-running validation over an unchanged value is not a value change, so a legitimately current async verdict survives. - .ValidateAsync(field, value, ...) promised "automatic validation" that nothing delivers: neither the render scope (which must stay synchronous) nor FormField consumes AsyncValidators, so a form using only that overload was touched but always valid. Documented as attach-only and pointed at ValidationReconciler.ValidateFieldAsync, which carries the generation guard. Scheduling async validation from render would need cancellation, debouncing and lifetime management, which is a feature rather than a fix for this bug. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 373c7212-34f6-4ddf-8ae9-59553c77cf48 --- .../Controls/Validation/ValidateExtensions.cs | 11 +++- .../Controls/Validation/ValidationContext.cs | 11 +++- .../ValidationRenderScopeTests.cs | 63 +++++++++++++++++++ 3 files changed, 83 insertions(+), 2 deletions(-) diff --git a/src/Reactor/Controls/Validation/ValidateExtensions.cs b/src/Reactor/Controls/Validation/ValidateExtensions.cs index db7749dc9..586824738 100644 --- a/src/Reactor/Controls/Validation/ValidateExtensions.cs +++ b/src/Reactor/Controls/Validation/ValidateExtensions.cs @@ -94,7 +94,16 @@ public static T ValidateAsync(this T el, string fieldName, params IAsyncValid } /// - /// 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. + /// /// public static T ValidateAsync(this T el, string fieldName, object? value, params IAsyncValidator[] asyncValidators) where T : Element { diff --git a/src/Reactor/Controls/Validation/ValidationContext.cs b/src/Reactor/Controls/Validation/ValidationContext.cs index 5ebedf775..57ff9e77d 100644 --- a/src/Reactor/Controls/Validation/ValidationContext.cs +++ b/src/Reactor/Controls/Validation/ValidationContext.cs @@ -408,16 +408,25 @@ internal void ApplyValidation(string field, object? value, List(); + 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() { From e96b12339ed9747807eaef5ff0a5a697d305635c Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Wed, 23 Sep 2026 18:37:23 -0700 Subject: [PATCH 14/67] Use LINQ Where for retracted-producer filter Addresses GitHub code-quality feedback on ValidationReconciler.EvaluateRules: the previous-instance retraction loop is a pure filter, so express it with Where(...) rather than an if-continue loop. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 373c7212-34f6-4ddf-8ae9-59553c77cf48 --- src/Reactor/Controls/Validation/ValidationReconciler.cs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/Reactor/Controls/Validation/ValidationReconciler.cs b/src/Reactor/Controls/Validation/ValidationReconciler.cs index 0aa814dc0..333b66d2f 100644 --- a/src/Reactor/Controls/Validation/ValidationReconciler.cs +++ b/src/Reactor/Controls/Validation/ValidationReconciler.cs @@ -108,11 +108,8 @@ public static void EvaluateRules( if (_ruleSets.TryGetValue(ctx, out var previous)) { - foreach (var entry in previous) - { - if (!applied.Contains(entry)) - ctx.ApplyOwned(entry.Field, entry.Producer, []); - } + foreach (var entry in previous.Where(entry => !applied.Contains(entry))) + ctx.ApplyOwned(entry.Field, entry.Producer, []); _ruleSets.Remove(ctx); } From b5347f76e6d5126911c90f6f3bfe63a7d13b3b19 Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Wed, 23 Sep 2026 18:58:59 -0700 Subject: [PATCH 15/67] Retire async validation when the value changes via NotifyValueChanged ApplyValidation already retracted the async producer and dropped the in-flight generation on a value change, but the public NotifyValueChanged did not. A field validated only through .ValidateAsync(...) never reaches ApplyValidation, so an error computed for a value the user had already replaced stayed on screen, and a pass opened against the old value could still install its verdict afterwards. Adds two mutation-checked regressions plus a guard that the sync verdict survives the retraction. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 373c7212-34f6-4ddf-8ae9-59553c77cf48 --- CHANGELOG.md | 6 +++ .../Controls/Validation/ValidationContext.cs | 9 ++++ .../ValidationRenderScopeTests.cs | 52 +++++++++++++++++++ 3 files changed, 67 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e762973e4..2accdbdd7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,6 +69,12 @@ Conventions for contributors: - `ValidationContext.NotifyValueChanged` discarded external messages on every call rather than only when the value actually changed, so a server-side error added with `AddExternal` was wiped by the next render (issue #1262). +- `ValidationContext.NotifyValueChanged` left an async verdict about the previous + value installed, and let an already-running async pass install its result after + the value had moved on. A field validated only with `.ValidateAsync(...)` never + reaches the synchronous path that retires those, so the stale error stayed on + screen. The async producer is now retracted and its generation retired whenever + the value changes (issue #1262). - `ValidationContext.MarkAllTouched` bumped `Version` even when every registered field was already touched, and re-running validators over an unchanged value bumped it twice per pass; both are now silent when nothing changed (issue #1262). diff --git a/src/Reactor/Controls/Validation/ValidationContext.cs b/src/Reactor/Controls/Validation/ValidationContext.cs index 57ff9e77d..4ff75e84f 100644 --- a/src/Reactor/Controls/Validation/ValidationContext.cs +++ b/src/Reactor/Controls/Validation/ValidationContext.cs @@ -684,6 +684,13 @@ private bool IsDirtyLocked(string field) /// 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) { @@ -696,6 +703,8 @@ public void NotifyValueChanged(string field, object? value) _currentValues[field] = value; _externalMessages.Remove(field); + _asyncGeneration.Remove(field); + ApplyOwnedLocked(field, AsyncProducer, []); _version++; } RaiseChanged(); diff --git a/tests/Reactor.Tests/ValidationRenderScopeTests.cs b/tests/Reactor.Tests/ValidationRenderScopeTests.cs index 4048c60d6..a4294e241 100644 --- a/tests/Reactor.Tests/ValidationRenderScopeTests.cs +++ b/tests/Reactor.Tests/ValidationRenderScopeTests.cs @@ -1076,4 +1076,56 @@ public void The_Rerender_Subscription_Is_Released_When_The_Component_Unmounts() 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 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); + } } From 1fe7557d6190add0d116031a805c0849f574e587 Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Wed, 23 Sep 2026 19:48:22 -0700 Subject: [PATCH 16/67] Order async rules per producer, defer reconcile-time validation changes Addresses four findings from the Copilot review round on b5347f76. - Overlapping EvaluateAsync runs of one async ValidationRule applied in completion order, so a slow failing check resolving after a fast passing one reinstated a cleared error. Async generations are now tracked per (field, producer) instead of per field, which also lets two rules writing the same field stop cancelling each other. - Mount, update and unmount of rules and form fields sit between renders and so fell outside every validation frame; their changes were announced in the middle of the pass that caused them. Reconcile now holds a deferral frame. - A deferred notification snapshotted its handlers when queued and gave up if there were none. Hosts flush root effects after reconciliation, so a parent that had not yet subscribed lost the notification permanently. Handlers are resolved at delivery, and an undeliverable notification is held for the first subscriber. - A FormField losing its context while swapping its content control cleared only the incoming control's blur binding, leaving the displaced editor's binding live on its way to the pool. Each fix has a mutation-checked regression; the two lifecycle ones are live selftest fixtures, including rule removal from a child component. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 373c7212-34f6-4ddf-8ae9-59553c77cf48 --- .../Controls/Validation/ValidationContext.cs | 93 +++++++-- .../Validation/ValidationRenderScope.cs | 18 ++ .../Controls/Validation/ValidationRule.cs | 10 +- src/Reactor/Core/Reconciler.cs | 3 + .../Core/V1Protocol/CompositeLifecycle.cs | 17 ++ .../Fixtures/ValidationCoverageFixtures.cs | 187 ++++++++++++++++++ .../SelfTest/SelfTestFixtureRegistry.cs | 4 + .../ValidationRenderScopeTests.cs | 113 +++++++++++ 8 files changed, 433 insertions(+), 12 deletions(-) diff --git a/src/Reactor/Controls/Validation/ValidationContext.cs b/src/Reactor/Controls/Validation/ValidationContext.cs index 4ff75e84f..6721e3e28 100644 --- a/src/Reactor/Controls/Validation/ValidationContext.cs +++ b/src/Reactor/Controls/Validation/ValidationContext.cs @@ -13,7 +13,7 @@ public sealed class ValidationContext // producer can retract its own messages without disturbing the others on that field. private readonly Dictionary>> _owned = new(); // field -> newest async pass token; older passes that resolve late are discarded. - private readonly Dictionary _asyncGeneration = new(); + private readonly Dictionary> _asyncGeneration = new(); // 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; @@ -42,7 +42,35 @@ public sealed class ValidationContext /// re-enter the reconciler's re-render path from inside Render(). /// /// - public event Action? Changed; + 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. @@ -73,7 +101,7 @@ private void RaiseChanged() ValidationRenderScope.DeferNotification(this); return; } - Changed?.Invoke(); + _changed?.Invoke(); } /// @@ -81,16 +109,37 @@ private void RaiseChanged() /// 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 handler = Changed; - if (handler is null) return; - var dispatcher = global::Microsoft.UI.Reactor.ReactorApp.UIDispatcher; - if (dispatcher is not null && dispatcher.TryEnqueue(() => handler.Invoke())) + if (dispatcher is not null && dispatcher.TryEnqueue(DeliverDeferred)) return; + DeliverDeferred(); + } + + private void DeliverDeferred() + { + Action? handler; + lock (_lock) + { + handler = _changed; + if (handler is null) + { + _notificationPending = true; + return; + } + _notificationPending = false; + } handler.Invoke(); } @@ -340,14 +389,26 @@ private static bool SameMessages(List? a, List /// 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 newest) || newest != generation) + if (!_asyncGeneration.TryGetValue(field, out var byProducer) + || !byProducer.TryGetValue(producer, out var newest) + || newest != generation) return; - changed = ApplyOwnedLocked(field, AsyncProducer, messages); + changed = ApplyOwnedLocked(field, producer, messages); if (changed) _version++; } if (changed) RaiseChanged(); @@ -364,12 +425,22 @@ internal void ApplyAsyncValidation(string field, int generation, List /// - internal int BeginAsyncValidation(string field) + internal int BeginAsyncValidation(string field) => BeginAsyncProducer(field, AsyncProducer); + + /// + /// 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); - _asyncGeneration[field] = token; + if (!_asyncGeneration.TryGetValue(field, out var byProducer)) + _asyncGeneration[field] = byProducer = new Dictionary(StringComparer.Ordinal); + byProducer[producer] = token; return token; } } diff --git a/src/Reactor/Controls/Validation/ValidationRenderScope.cs b/src/Reactor/Controls/Validation/ValidationRenderScope.cs index 62035b0d7..3be0562e4 100644 --- a/src/Reactor/Controls/Validation/ValidationRenderScope.cs +++ b/src/Reactor/Controls/Validation/ValidationRenderScope.cs @@ -92,6 +92,24 @@ internal static Frame Begin(ValidationContext? inherited) 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() => Begin(null); + /// /// Called by UseValidationContext() with the context it resolved. /// is true when the hook fell back to a diff --git a/src/Reactor/Controls/Validation/ValidationRule.cs b/src/Reactor/Controls/Validation/ValidationRule.cs index a7ea4f256..c2d6762d8 100644 --- a/src/Reactor/Controls/Validation/ValidationRule.cs +++ b/src/Reactor/Controls/Validation/ValidationRule.cs @@ -94,6 +94,12 @@ internal static void Evaluate(this ValidationRuleElement rule, ValidationContext /// 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 Task EvaluateAsync(this ValidationRuleElement rule, ValidationContext ctx, CancellationToken cancellationToken = default) @@ -108,10 +114,12 @@ internal static async Task EvaluateAsync(this ValidationRuleElement rule, Valida return; } + var generation = ctx.BeginAsyncProducer(rule.Field, producer); + var result = await rule.AsyncPredicate(); cancellationToken.ThrowIfCancellationRequested(); - ctx.ApplyOwned(rule.Field, producer, BuildMessages(rule, result)); + ctx.ApplyAsyncOwned(rule.Field, producer, generation, BuildMessages(rule, result)); } /// diff --git a/src/Reactor/Core/Reconciler.cs b/src/Reactor/Core/Reconciler.cs index 75fc65681..313e44a98 100644 --- a/src/Reactor/Core/Reconciler.cs +++ b/src/Reactor/Core/Reconciler.cs @@ -1479,6 +1479,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 { diff --git a/src/Reactor/Core/V1Protocol/CompositeLifecycle.cs b/src/Reactor/Core/V1Protocol/CompositeLifecycle.cs index 162772396..aa4ff63f7 100644 --- a/src/Reactor/Core/V1Protocol/CompositeLifecycle.cs +++ b/src/Reactor/Core/V1Protocol/CompositeLifecycle.cs @@ -398,6 +398,12 @@ private static void WireTouchedOnBlur(UIElement formFieldRoot, UIElement content // 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. + ClearFormFieldTouchBinding(formFieldRoot); + _rootBindings.Remove(formFieldRoot); ClearTouchBinding(fe); return; } @@ -477,6 +483,17 @@ private static void ClearTouchBinding(UIElement contentControl) } } + // 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 + && _touchBindings.TryGetValue(fe, out var binding) + && binding.Context is not null + && !string.IsNullOrEmpty(binding.FieldName); + private sealed class TouchBinding { internal ValidationContext? Context; diff --git a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs index e3369dcc6..6dcf7226b 100644 --- a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs +++ b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs @@ -873,4 +873,191 @@ public override async Task RunAsync() 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("").Validate("name", "", Validate.Required("Name is required")), + 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(""), + Button("Away", () => { })); + }); + + await Harness.Render(); + var original = H.FindControl(_ => true); + 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); + await Harness.Render(); + + // 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); + await Harness.Render(); + + var rented = H.FindControl(_ => true); + 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(); + } + } } diff --git a/tests/Reactor.AppTests.Host/SelfTest/SelfTestFixtureRegistry.cs b/tests/Reactor.AppTests.Host/SelfTest/SelfTestFixtureRegistry.cs index 1753424ba..d2c7fa879 100644 --- a/tests/Reactor.AppTests.Host/SelfTest/SelfTestFixtureRegistry.cs +++ b/tests/Reactor.AppTests.Host/SelfTest/SelfTestFixtureRegistry.cs @@ -813,6 +813,8 @@ internal static class SelfTestFixtureRegistry "ValCov_Issue1262_FailingRuleNoLoop", "ValCov_Issue1262_TouchBindingCleared", "ValCov_Issue1262_RuleRetractsOnUnmount", + "ValCov_Issue1262_ChildRuleRetracts", + "ValCov_Issue1262_DisplacedRootBinding", // Controls coverage — MaskEngine, InputFormatter, AutoSuggest "ControlsCov_MaskEngineBasic", "ControlsCov_MaskEngineNavigation", @@ -2720,6 +2722,8 @@ internal static string[] StaleTierDeclarations() => "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), // Controls coverage "ControlsCov_MaskEngineBasic" => new ControlsCoverageFixtures.MaskEngineBasic(harness), "ControlsCov_MaskEngineNavigation" => new ControlsCoverageFixtures.MaskEngineNavigation(harness), diff --git a/tests/Reactor.Tests/ValidationRenderScopeTests.cs b/tests/Reactor.Tests/ValidationRenderScopeTests.cs index a4294e241..932d77c1d 100644 --- a/tests/Reactor.Tests/ValidationRenderScopeTests.cs +++ b/tests/Reactor.Tests/ValidationRenderScopeTests.cs @@ -1,5 +1,6 @@ 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; @@ -1128,4 +1129,116 @@ public void A_Value_Change_Leaves_Sync_Messages_For_The_New_Value_Intact() 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 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); + } } From 0b48e005f5af8f3cc2d7c9f95bb288221e931307 Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Wed, 23 Sep 2026 20:22:31 -0700 Subject: [PATCH 17/67] Register async-only validated fields when FormField mounts them .ValidateAsync(field, value, ...) registers its field through the render scope, which an element assembled outside a render pass never enters -- one cached in a field, memoized, or built in an event handler. FormField then gated its own registration on sync validators being present, so an async-only field existed nowhere: MarkAllTouched() and the validity summary both skipped it. Mount and update now share one helper that runs the sync validators when there are any and otherwise registers the field. Async validators are still never run automatically. Covered by ValCov_Issue1262_AsyncOnlyCachedField, which reddens on all three of its oracles when the registration is removed. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 373c7212-34f6-4ddf-8ae9-59553c77cf48 --- CHANGELOG.md | 5 +++ .../Controls/Validation/ValidateExtensions.cs | 5 +++ .../Core/V1Protocol/CompositeLifecycle.cs | 36 +++++++++++---- .../Fixtures/ValidationCoverageFixtures.cs | 45 +++++++++++++++++++ .../SelfTest/SelfTestFixtureRegistry.cs | 2 + 5 files changed, 85 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6764b3ff6..8c76f7c21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -108,6 +108,11 @@ Conventions for contributors: - A `FormField` that lost its context and swapped its content control in the same update left the displaced editor's blur binding live, so the pooled control kept marking the old field and kept its `ValidationContext` alive (issue #1262). +- A field carrying only `.ValidateAsync(field, value, …)` on an element built + outside a render pass — cached, memoized, or assembled in an event handler — was + never registered, so `MarkAllTouched()` and the validity summary skipped it. + `FormField` now registers the field when it mounts or updates such an element + (issue #1262). - `ValidationContext.MarkAllTouched` bumped `Version` even when every registered field was already touched, and re-running validators over an unchanged value bumped it twice per pass; both are now silent when nothing changed (issue #1262). diff --git a/src/Reactor/Controls/Validation/ValidateExtensions.cs b/src/Reactor/Controls/Validation/ValidateExtensions.cs index 586824738..893a3cca4 100644 --- a/src/Reactor/Controls/Validation/ValidateExtensions.cs +++ b/src/Reactor/Controls/Validation/ValidateExtensions.cs @@ -104,6 +104,11 @@ public static T ValidateAsync(this T el, string fieldName, params IAsyncValid /// , 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 { diff --git a/src/Reactor/Core/V1Protocol/CompositeLifecycle.cs b/src/Reactor/Core/V1Protocol/CompositeLifecycle.cs index aa4ff63f7..dd8e1b07b 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(valCtx, attached); // [0] Label — always present, collapsed when empty var displayLabel = FormFieldHelpers.GetDisplayLabel(ff.Label, ff.Required); @@ -103,6 +100,32 @@ internal static WinUI.StackPanel MountFormField(Reconciler reconciler, FormField return panel; } + /// + /// Runs a content element's attached synchronous validators against the context a + /// FormField resolved, and makes sure the field exists either way. + /// + /// Registration cannot be left to the sync 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 an async-only 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(ValidationContext? valCtx, ValidationAttached? attached) + { + if (valCtx is null || attached is null) return; + + if (attached.Validators.Length > 0) + { + ValidationReconciler.ValidateAttached(valCtx, attached, attached.Value); + return; + } + + if (attached.AsyncValidators.Length > 0 && !string.IsNullOrEmpty(attached.FieldName)) + valCtx.RegisterField(attached.FieldName); + } + internal static UIElement? UpdateFormField( Reconciler reconciler, FormFieldElement oldFf, FormFieldElement newFf, WinUI.StackPanel panel, Action requestRerender) @@ -116,10 +139,7 @@ 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(valCtx, attached); // [0] Update label if (panel.Children[0] is TextBlock labelTb) diff --git a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs index 6dcf7226b..03c2eb594 100644 --- a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs +++ b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs @@ -1060,4 +1060,49 @@ public override async Task RunAsync() 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(); + } + } } diff --git a/tests/Reactor.AppTests.Host/SelfTest/SelfTestFixtureRegistry.cs b/tests/Reactor.AppTests.Host/SelfTest/SelfTestFixtureRegistry.cs index d2c7fa879..1ac2414d9 100644 --- a/tests/Reactor.AppTests.Host/SelfTest/SelfTestFixtureRegistry.cs +++ b/tests/Reactor.AppTests.Host/SelfTest/SelfTestFixtureRegistry.cs @@ -815,6 +815,7 @@ internal static class SelfTestFixtureRegistry "ValCov_Issue1262_RuleRetractsOnUnmount", "ValCov_Issue1262_ChildRuleRetracts", "ValCov_Issue1262_DisplacedRootBinding", + "ValCov_Issue1262_AsyncOnlyCachedField", // Controls coverage — MaskEngine, InputFormatter, AutoSuggest "ControlsCov_MaskEngineBasic", "ControlsCov_MaskEngineNavigation", @@ -2724,6 +2725,7 @@ internal static string[] StaleTierDeclarations() => "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), // Controls coverage "ControlsCov_MaskEngineBasic" => new ControlsCoverageFixtures.MaskEngineBasic(harness), "ControlsCov_MaskEngineNavigation" => new ControlsCoverageFixtures.MaskEngineNavigation(harness), From 798d67191bd9e747db96f0ec271f059f5a5957cf Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Wed, 23 Sep 2026 21:07:20 -0700 Subject: [PATCH 18/67] Stop announcing a render pass that churns messages back to where it started Chaining two value overloads on one element repainted forever. Each call eagerly applies its own intermediate validator set under the same sync producer, so every pass strips the later validator's message and puts it straight back. Both writes are real changes, so the frame deferred a notification, which repainted a component that churned identically. A deferred notification is now compared against the messages subscribers were last told about, and suppressed when the pass was net-zero. Any non-message change during the frame (a value, a touch, a reset) opts the frame out of suppression, so only message churn can be swallowed. Also switched two fixtures from null-forgiving to flow-checked access, as the surrounding fixtures already do. ValCov_Issue1262_ChainedValueOverloads times out at 30s when the suppression is removed -- the loop, reproduced live. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 373c7212-34f6-4ddf-8ae9-59553c77cf48 --- CHANGELOG.md | 8 +- .../Controls/Validation/ValidationContext.cs | 88 ++++++++++++++++--- .../Fixtures/ValidationCoverageFixtures.cs | 65 +++++++++++++- .../SelfTest/SelfTestFixtureRegistry.cs | 2 + .../ValidationRenderScopeTests.cs | 75 ++++++++++++++++ 5 files changed, 223 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c76f7c21..b4692fae9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -108,7 +108,13 @@ Conventions for contributors: - A `FormField` that lost its context and swapped its content control in the same update left the displaced editor's blur binding live, so the pooled control kept marking the old field and kept its `ValidationContext` alive (issue #1262). -- A field carrying only `.ValidateAsync(field, value, …)` on an element built +- Chaining two value overloads on one element — + `.Validate(f, v, Email()).Validate(f, v, MinLength(10))` — repainted forever. + Each call eagerly applied its own intermediate validator set under the same + producer, so every pass stripped the later message and put it straight back; + the net state never moved, but each write announced a change that scheduled + another identical pass. A render that ends with the same messages it started + with is now silent (issue #1262). outside a render pass — cached, memoized, or assembled in an event handler — was never registered, so `MarkAllTouched()` and the validity summary skipped it. `FormField` now registers the field when it mounts or updates such an element diff --git a/src/Reactor/Controls/Validation/ValidationContext.cs b/src/Reactor/Controls/Validation/ValidationContext.cs index 6721e3e28..a5b04a595 100644 --- a/src/Reactor/Controls/Validation/ValidationContext.cs +++ b/src/Reactor/Controls/Validation/ValidationContext.cs @@ -94,13 +94,21 @@ public int Version /// state stale. /// /// - private void RaiseChanged() + 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; _changed?.Invoke(); } @@ -132,6 +140,24 @@ 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 + && _lastNotifiedMessages is not null + && string.Equals(_lastNotifiedMessages, snapshot, StringComparison.Ordinal)) + { + _frameTouchedNonMessageState = false; + return; + } + + _lastNotifiedMessages = snapshot; + _frameTouchedNonMessageState = false; + handler = _changed; if (handler is null) { @@ -143,6 +169,44 @@ private void DeliverDeferred() handler.Invoke(); } + /// + /// A deterministic rendering of every message the context currently holds, used to + /// tell a net-zero render pass from a real one. Ordered so two equal message sets + /// always produce the same text regardless of dictionary iteration order. + /// + private string MessageSnapshotLocked() + { + var fields = new List(_messages.Keys); + foreach (var field in _externalMessages.Keys) + { + if (!_messages.ContainsKey(field)) fields.Add(field); + } + fields.Sort(StringComparer.Ordinal); + + var sb = new global::System.Text.StringBuilder(); + foreach (var field in fields) + { + var rendered = new List(); + if (_messages.TryGetValue(field, out var owned)) + { + foreach (var m in owned) rendered.Add($"i\u0001{m.Severity}\u0001{m.Code}\u0001{m.Text}"); + } + if (_externalMessages.TryGetValue(field, out var external)) + { + foreach (var m in external) rendered.Add($"e\u0001{m.Severity}\u0001{m.Code}\u0001{m.Text}"); + } + rendered.Sort(StringComparer.Ordinal); + + sb.Append(field).Append('\u0002'); + foreach (var entry in rendered) sb.Append(entry).Append('\u0003'); + sb.Append('\u0004'); + } + return sb.ToString(); + } + + private string? _lastNotifiedMessages; + private bool _frameTouchedNonMessageState; + // ════════════════════════════════════════════════════════════════ // Field registration // ════════════════════════════════════════════════════════════════ @@ -194,7 +258,7 @@ public void Add(ValidationMessage message) list.Add(message); _version++; } - RaiseChanged(); + RaiseChanged(messagesOnly: true); } /// @@ -221,7 +285,7 @@ public void AddExternal(ValidationMessage message) list.Add(message); _version++; } - RaiseChanged(); + RaiseChanged(messagesOnly: true); } // ════════════════════════════════════════════════════════════════ @@ -245,7 +309,7 @@ public void Clear(string field) _asyncGeneration.Remove(field); if (changed) _version++; } - if (changed) RaiseChanged(); + if (changed) RaiseChanged(messagesOnly: true); } /// @@ -263,7 +327,7 @@ internal void ClearInternal(string field) _asyncGeneration.Remove(field); if (changed) _version++; } - if (changed) RaiseChanged(); + if (changed) RaiseChanged(messagesOnly: true); } @@ -295,7 +359,7 @@ internal void ApplyOwned(string field, string producer, List changed = ApplyOwnedLocked(field, producer, messages); if (changed) _version++; } - if (changed) RaiseChanged(); + if (changed) RaiseChanged(messagesOnly: true); } private bool ApplyOwnedLocked(string field, string producer, List messages) @@ -411,7 +475,7 @@ internal void ApplyAsyncOwned(string field, string producer, int generation, Lis changed = ApplyOwnedLocked(field, producer, messages); if (changed) _version++; } - if (changed) RaiseChanged(); + if (changed) RaiseChanged(messagesOnly: true); } /// @@ -473,9 +537,10 @@ private static bool ContainsReference(List list, ValidationMe internal void ApplyValidation(string field, object? value, List messages) { bool changed; + bool valueChangedForNotify; lock (_lock) { - _registeredFields.Add(field); + var newField = _registeredFields.Add(field); var known = _currentValues.TryGetValue(field, out var previous); var valueChanged = !known || !Equals(previous, value); @@ -500,9 +565,10 @@ internal void ApplyValidation(string field, object? value, List @@ -516,7 +582,7 @@ public void ClearExternal(string field) changed = _externalMessages.Remove(field); if (changed) _version++; } - if (changed) RaiseChanged(); + if (changed) RaiseChanged(messagesOnly: true); } /// @@ -534,7 +600,7 @@ public void ClearAll() _asyncGeneration.Clear(); if (changed) _version++; } - if (changed) RaiseChanged(); + if (changed) RaiseChanged(messagesOnly: true); } // ════════════════════════════════════════════════════════════════ diff --git a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs index 03c2eb594..f813aafe6 100644 --- a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs +++ b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs @@ -507,8 +507,10 @@ public override async Task RunAsync() // 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")); + 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); @@ -660,7 +662,9 @@ public override async Task RunAsync() // Invalid from the first pass, but untouched — so the description shows. H.Check("Issue1262_Blur_InvalidButQuiet", captured is not null && !captured.IsValid()); - H.Check("Issue1262_Blur_NotTouchedInitially", !captured!.IsTouched("name")); + 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); @@ -1100,8 +1104,63 @@ public override async Task RunAsync() 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 async-only field done")); + done.Mount(c => TextBlock("Issue1262 chained validate done")); await Harness.Render(); } } diff --git a/tests/Reactor.AppTests.Host/SelfTest/SelfTestFixtureRegistry.cs b/tests/Reactor.AppTests.Host/SelfTest/SelfTestFixtureRegistry.cs index 1ac2414d9..2f0f3531e 100644 --- a/tests/Reactor.AppTests.Host/SelfTest/SelfTestFixtureRegistry.cs +++ b/tests/Reactor.AppTests.Host/SelfTest/SelfTestFixtureRegistry.cs @@ -816,6 +816,7 @@ internal static class SelfTestFixtureRegistry "ValCov_Issue1262_ChildRuleRetracts", "ValCov_Issue1262_DisplacedRootBinding", "ValCov_Issue1262_AsyncOnlyCachedField", + "ValCov_Issue1262_ChainedValueOverloads", // Controls coverage — MaskEngine, InputFormatter, AutoSuggest "ControlsCov_MaskEngineBasic", "ControlsCov_MaskEngineNavigation", @@ -2726,6 +2727,7 @@ internal static string[] StaleTierDeclarations() => "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), // Controls coverage "ControlsCov_MaskEngineBasic" => new ControlsCoverageFixtures.MaskEngineBasic(harness), "ControlsCov_MaskEngineNavigation" => new ControlsCoverageFixtures.MaskEngineNavigation(harness), diff --git a/tests/Reactor.Tests/ValidationRenderScopeTests.cs b/tests/Reactor.Tests/ValidationRenderScopeTests.cs index 932d77c1d..c3a55812e 100644 --- a/tests/Reactor.Tests/ValidationRenderScopeTests.cs +++ b/tests/Reactor.Tests/ValidationRenderScopeTests.cs @@ -1219,6 +1219,81 @@ public async Task An_Async_Rule_Still_Applies_Its_Own_Newest_Result() 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_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() { From bf24393d207879234c05549fc05eeaf1d74af2f4 Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Wed, 23 Sep 2026 21:20:03 -0700 Subject: [PATCH 19/67] Regenerate docs for the Reconcile snippet The reconcile-wide validation deferral frame sits inside a region that architecture-overview and reconciliation embed as a source snippet, so both compiled pages needed recompiling. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 373c7212-34f6-4ddf-8ae9-59553c77cf48 --- docs/guide/architecture-overview.md | 3 +++ docs/guide/reconciliation.md | 3 +++ 2 files changed, 6 insertions(+) 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/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 { From 68e362341a3941199368efcc645a817c73a45d0a Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Wed, 23 Sep 2026 21:44:21 -0700 Subject: [PATCH 20/67] Keep Version stable across a net-zero pass; keep message order significant Three review findings plus one previously-missed contract error. - The chained-overload suppression stopped the notification but both intermediate ApplyValidation calls still bumped the public Version, so a UseMemo or UseEffect keyed on it re-ran forever for a context that had not moved. A message-only change made during a render now holds its bump and applies it at frame close, only when the frame really changed something. - The deferred-change snapshot sorted each field's messages, so swapping [A, B] for [B, A] looked unchanged and Changed was suppressed while GetMessages reported a new first message. Field names stay sorted; each field's list keeps its order. - .Validate's doc claimed the visualizers run attached validators for elements built outside a render pass. They only display what a context already holds; narrowed the contract to FormField rather than widening the visualizer lifecycle. - Restored the changelog bullet subject lost when the chained-overload entry was inserted. Also made the two blur-binding fixtures match their probe by text instead of "the first TextBox", which other fixtures in the same run can supply. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 373c7212-34f6-4ddf-8ae9-59553c77cf48 --- CHANGELOG.md | 1 + .../Controls/Validation/ValidateExtensions.cs | 8 ++- .../Controls/Validation/ValidationContext.cs | 66 +++++++++++++----- .../Fixtures/ValidationCoverageFixtures.cs | 21 +++--- .../ValidationRenderScopeTests.cs | 68 +++++++++++++++++++ 5 files changed, 135 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b4692fae9..a116ddf25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -115,6 +115,7 @@ Conventions for contributors: the net state never moved, but each write announced a change that scheduled another identical pass. A render that ends with the same messages it started with is now silent (issue #1262). +- A field carrying only `.ValidateAsync(field, value, …)` on an element built outside a render pass — cached, memoized, or assembled in an event handler — was never registered, so `MarkAllTouched()` and the validity summary skipped it. `FormField` now registers the field when it mounts or updates such an element diff --git a/src/Reactor/Controls/Validation/ValidateExtensions.cs b/src/Reactor/Controls/Validation/ValidateExtensions.cs index 893a3cca4..3043b00da 100644 --- a/src/Reactor/Controls/Validation/ValidateExtensions.cs +++ b/src/Reactor/Controls/Validation/ValidateExtensions.cs @@ -56,9 +56,11 @@ public static T Validate(this T el, string fieldName, params IValidator[] val /// sees the current verdict instead of the previous pass's. /// /// - /// The validators are still attached, so FormField and the visualizers keep - /// working for elements built outside a render pass. Re-running them is harmless: - /// results are applied with a structural diff. + /// 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 diff --git a/src/Reactor/Controls/Validation/ValidationContext.cs b/src/Reactor/Controls/Validation/ValidationContext.cs index a5b04a595..32d6cd93e 100644 --- a/src/Reactor/Controls/Validation/ValidationContext.cs +++ b/src/Reactor/Controls/Validation/ValidationContext.cs @@ -152,9 +152,16 @@ private void DeliverDeferred() && string.Equals(_lastNotifiedMessages, snapshot, StringComparison.Ordinal)) { _frameTouchedNonMessageState = false; + _frameVersionPending = false; return; } + if (_frameVersionPending) + { + _version++; + _frameVersionPending = false; + } + _lastNotifiedMessages = snapshot; _frameTouchedNonMessageState = false; @@ -171,8 +178,10 @@ private void DeliverDeferred() /// /// A deterministic rendering of every message the context currently holds, used to - /// tell a net-zero render pass from a real one. Ordered so two equal message sets - /// always produce the same text regardless of dictionary iteration order. + /// 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. /// private string MessageSnapshotLocked() { @@ -186,19 +195,19 @@ private string MessageSnapshotLocked() var sb = new global::System.Text.StringBuilder(); foreach (var field in fields) { - var rendered = new List(); + sb.Append(field).Append('\u0002'); if (_messages.TryGetValue(field, out var owned)) { - foreach (var m in owned) rendered.Add($"i\u0001{m.Severity}\u0001{m.Code}\u0001{m.Text}"); + foreach (var m in owned) + sb.Append('i').Append('\u0001').Append(m.Severity).Append('\u0001') + .Append(m.Code).Append('\u0001').Append(m.Text).Append('\u0003'); } if (_externalMessages.TryGetValue(field, out var external)) { - foreach (var m in external) rendered.Add($"e\u0001{m.Severity}\u0001{m.Code}\u0001{m.Text}"); + foreach (var m in external) + sb.Append('e').Append('\u0001').Append(m.Severity).Append('\u0001') + .Append(m.Code).Append('\u0001').Append(m.Text).Append('\u0003'); } - rendered.Sort(StringComparer.Ordinal); - - sb.Append(field).Append('\u0002'); - foreach (var entry in rendered) sb.Append(entry).Append('\u0003'); sb.Append('\u0004'); } return sb.ToString(); @@ -206,6 +215,27 @@ private string MessageSnapshotLocked() private string? _lastNotifiedMessages; private bool _frameTouchedNonMessageState; + private bool _frameVersionPending; + + /// + /// Bumps , except for a message-only change made while a render + /// is in flight: those are held until the frame closes and bumped once, and only if + /// the frame ended with different messages 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. + /// + /// + private void BumpVersionLocked(bool messagesOnly) + { + if (messagesOnly && ValidationRenderScope.InRender) + { + _frameVersionPending = true; + return; + } + _version++; + } // ════════════════════════════════════════════════════════════════ // Field registration @@ -256,7 +286,7 @@ public void Add(ValidationMessage message) _messages[message.Field] = list; } list.Add(message); - _version++; + BumpVersionLocked(messagesOnly: true); } RaiseChanged(messagesOnly: true); } @@ -283,7 +313,7 @@ public void AddExternal(ValidationMessage message) _externalMessages[message.Field] = list; } list.Add(message); - _version++; + BumpVersionLocked(messagesOnly: true); } RaiseChanged(messagesOnly: true); } @@ -307,7 +337,7 @@ public void Clear(string field) // An async pass still in flight would otherwise repopulate what this just // cleared: dropping the token makes its result stale on arrival. _asyncGeneration.Remove(field); - if (changed) _version++; + if (changed) BumpVersionLocked(messagesOnly: true); } if (changed) RaiseChanged(messagesOnly: true); } @@ -325,7 +355,7 @@ internal void ClearInternal(string field) _owned.Remove(field); // As in Clear: a pending async pass must not repopulate what this dropped. _asyncGeneration.Remove(field); - if (changed) _version++; + if (changed) BumpVersionLocked(messagesOnly: true); } if (changed) RaiseChanged(messagesOnly: true); } @@ -357,7 +387,7 @@ internal void ApplyOwned(string field, string producer, List lock (_lock) { changed = ApplyOwnedLocked(field, producer, messages); - if (changed) _version++; + if (changed) BumpVersionLocked(messagesOnly: true); } if (changed) RaiseChanged(messagesOnly: true); } @@ -473,7 +503,7 @@ internal void ApplyAsyncOwned(string field, string producer, int generation, Lis return; changed = ApplyOwnedLocked(field, producer, messages); - if (changed) _version++; + if (changed) BumpVersionLocked(messagesOnly: true); } if (changed) RaiseChanged(messagesOnly: true); } @@ -566,7 +596,7 @@ internal void ApplyValidation(string field, object? value, List { })); @@ -839,7 +839,9 @@ public override async Task RunAsync() }); await Harness.Render(); - var box = H.FindControl(_ => true); + // 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; @@ -853,11 +855,11 @@ public override async Task RunAsync() // 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(_ => true)); + 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(_ => true))); + stillSameControl && ReferenceEquals(box, H.FindControl(tb => tb.Text == "binding-probe"))); var touchedBefore = ctx.IsTouched("name"); @@ -990,7 +992,7 @@ public override async Task RunAsync() { return VStack(12, FormField( - TextBox("").Validate("name", "", Validate.Required("Name is required")), + TextBox("displaced-probe").Validate("name", "displaced-probe", Validate.MinLength(50)), label: "Full Name", showWhen: ShowWhen.Always), Button("Away", () => { })) @@ -1010,12 +1012,15 @@ public override async Task RunAsync() // The displaced editor is rented back for a plain, non-FormField use. return VStack(12, - TextBox(""), + TextBox("plain-probe"), Button("Away", () => { })); }); await Harness.Render(); - var original = H.FindControl(_ => true); + // 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; @@ -1043,7 +1048,7 @@ public override async Task RunAsync() setMode!(2); await Harness.Render(); - var rented = H.FindControl(_ => true); + 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 diff --git a/tests/Reactor.Tests/ValidationRenderScopeTests.cs b/tests/Reactor.Tests/ValidationRenderScopeTests.cs index c3a55812e..255d30b56 100644 --- a/tests/Reactor.Tests/ValidationRenderScopeTests.cs +++ b/tests/Reactor.Tests/ValidationRenderScopeTests.cs @@ -1244,6 +1244,74 @@ public void Chained_Value_Overloads_Settle_Instead_Of_Repainting_Forever() 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() { From af3a83323ca6266ddd5f42a08063339c351c6399 Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Wed, 23 Sep 2026 21:48:36 -0700 Subject: [PATCH 21/67] Use Where for the external-only field collection Addresses GitHub code-quality feedback on MessageSnapshotLocked: the loop over _externalMessages.Keys is a pure filter. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 373c7212-34f6-4ddf-8ae9-59553c77cf48 --- src/Reactor/Controls/Validation/ValidationContext.cs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/Reactor/Controls/Validation/ValidationContext.cs b/src/Reactor/Controls/Validation/ValidationContext.cs index 32d6cd93e..2dc01a096 100644 --- a/src/Reactor/Controls/Validation/ValidationContext.cs +++ b/src/Reactor/Controls/Validation/ValidationContext.cs @@ -186,10 +186,7 @@ private void DeliverDeferred() private string MessageSnapshotLocked() { var fields = new List(_messages.Keys); - foreach (var field in _externalMessages.Keys) - { - if (!_messages.ContainsKey(field)) fields.Add(field); - } + fields.AddRange(_externalMessages.Keys.Where(field => !_messages.ContainsKey(field))); fields.Sort(StringComparer.Ordinal); var sb = new global::System.Text.StringBuilder(); From a8402236e889b57d6dd5c11fb258a84514d91b23 Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Wed, 23 Sep 2026 22:08:12 -0700 Subject: [PATCH 22/67] Retract every async producer on a value change, not just the field slot Moving async generations from per-field to per-(field, producer) left the retraction paths behind: both ApplyValidation and NotifyValueChanged dropped every generation entry for the field but withdrew only AsyncProducer's messages. An async ValidationRule installs under its own key -- rule#N when mounted, the message-derived fallback otherwise -- so its verdict about the previous value survived the change and kept IsValid() false for a value it had never examined. Both paths now go through RetractAsyncProducersLocked, which withdraws every producer named in the field's generation map before dropping it, and still clears the plain async slot for a verdict that outlived its entry. Sync and external messages are untouched. Also clears the FormField root binding when content mounts to a non-FrameworkElement, the one early return that still leaked it. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 373c7212-34f6-4ddf-8ae9-59553c77cf48 --- .../Controls/Validation/ValidationContext.cs | 47 ++++++++++++++---- .../Core/V1Protocol/CompositeLifecycle.cs | 21 ++++++-- .../ValidationRenderScopeTests.cs | 48 +++++++++++++++++++ 3 files changed, 104 insertions(+), 12 deletions(-) diff --git a/src/Reactor/Controls/Validation/ValidationContext.cs b/src/Reactor/Controls/Validation/ValidationContext.cs index 2dc01a096..368a1242e 100644 --- a/src/Reactor/Controls/Validation/ValidationContext.cs +++ b/src/Reactor/Controls/Validation/ValidationContext.cs @@ -579,12 +579,11 @@ internal void ApplyValidation(string field, object? value, List + /// 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; + } + /// /// Returns true if the field's current value differs from its initial value. /// diff --git a/src/Reactor/Core/V1Protocol/CompositeLifecycle.cs b/src/Reactor/Core/V1Protocol/CompositeLifecycle.cs index dd8e1b07b..0889e1bba 100644 --- a/src/Reactor/Core/V1Protocol/CompositeLifecycle.cs +++ b/src/Reactor/Core/V1Protocol/CompositeLifecycle.cs @@ -412,7 +412,13 @@ private static RuleBinding GetOrCreateRuleBinding(UIElement placeholder) /// private static void WireTouchedOnBlur(UIElement formFieldRoot, UIElement contentControl, ValidationContext? valCtx, string? fieldName) { - if (contentControl is not FrameworkElement fe) return; + 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. @@ -422,8 +428,7 @@ private static void WireTouchedOnBlur(UIElement formFieldRoot, UIElement content // 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. - ClearFormFieldTouchBinding(formFieldRoot); - _rootBindings.Remove(formFieldRoot); + ReleaseRootBinding(formFieldRoot); ClearTouchBinding(fe); return; } @@ -503,6 +508,16 @@ private static void ClearTouchBinding(UIElement contentControl) } } + /// + /// 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); + _rootBindings.Remove(formFieldRoot); + } + // 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 diff --git a/tests/Reactor.Tests/ValidationRenderScopeTests.cs b/tests/Reactor.Tests/ValidationRenderScopeTests.cs index 255d30b56..d82bee3ab 100644 --- a/tests/Reactor.Tests/ValidationRenderScopeTests.cs +++ b/tests/Reactor.Tests/ValidationRenderScopeTests.cs @@ -1112,6 +1112,54 @@ public void A_Value_Change_Retires_An_In_Flight_Async_Pass() 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_Value_Change_Leaves_Sync_Messages_For_The_New_Value_Intact() { From 62b81b8ae689949909ac6e8ce6e8103afdddb210 Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Wed, 23 Sep 2026 22:34:00 -0700 Subject: [PATCH 23/67] Run mounted async rules; identify direct rules by call site Two review findings. ValidationRuleAsync builds an element whose synchronous predicate is a constant true, and both lifecycle paths called Evaluate -- so an async rule placed in the tree never ran its predicate and silently recorded a passing verdict. Mounted rules now dispatch through the generation-guarded async path. Each pass cancels the previous one, unmount cancels the outstanding pass, and a predicate that throws is reported through DiagnosticLog rather than surfacing later as an unobserved task exception. FallbackProducerKey identified a directly-evaluated rule by its message and severity, so an interpolated message orphaned the previous verdict on every change -- errors accumulated and a now-passing rule could not retract its own. That was a regression from replacing the old whole-field clear. Identity is now the field plus the predicate's method, which for a lambda is its call site, so the same line of code owns the same slot across evaluations. Three existing assertions modelled "the same rule again" as a second literal lambda, which is two call sites under the new identity; they now re-run one call site with a flipping result, which is what the behaviour they guard actually means. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 373c7212-34f6-4ddf-8ae9-59553c77cf48 --- CHANGELOG.md | 11 ++ .../Controls/Validation/ValidationRule.cs | 28 +++-- .../Core/V1Protocol/CompositeLifecycle.cs | 78 ++++++++++++- .../Fixtures/ValidationCoverageFixtures.cs | 104 ++++++++++++++---- .../SelfTest/SelfTestFixtureRegistry.cs | 2 + .../ValidationRenderScopeTests.cs | 73 +++++++++++- 6 files changed, 264 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a116ddf25..fbd59a61d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -115,6 +115,17 @@ Conventions for contributors: the net state never moved, but each write announced a change that scheduled another identical pass. A render that ends with the same messages it started with is now silent (issue #1262). +- An async `ValidationRule` placed in the element tree never ran its predicate. + `ValidationRuleAsync` builds an element whose synchronous predicate is a + constant `true`, and the mount/update lifecycle evaluated that, so the rule + silently recorded a passing verdict. Mounted async rules now run through the + generation-guarded async path, with the in-flight pass cancelled on update and + on unmount (issue #1262). +- A rule evaluated directly with `.Evaluate(ctx)` was identified by its message + text, so an interpolated message such as `$"Must be after {start}"` orphaned + the previous verdict on every change: errors accumulated and a now-passing + rule could not retract the one it had installed. Identity is now the field plus + the predicate's call site (issue #1262). - A field carrying only `.ValidateAsync(field, value, …)` on an element built outside a render pass — cached, memoized, or assembled in an event handler — was never registered, so `MarkAllTouched()` and the validity summary skipped it. diff --git a/src/Reactor/Controls/Validation/ValidationRule.cs b/src/Reactor/Controls/Validation/ValidationRule.cs index c2d6762d8..0457fc39d 100644 --- a/src/Reactor/Controls/Validation/ValidationRule.cs +++ b/src/Reactor/Controls/Validation/ValidationRule.cs @@ -126,17 +126,27 @@ internal static async Task EvaluateAsync(this ValidationRuleElement rule, Valida /// Identity of last resort for a rule evaluated outside the reconciler, where there /// is no mounted instance to key on. /// - /// It is derived from the message and severity, which is stable for the overwhelmingly - /// common case of a fixed message, but not for an interpolated one - /// ($"Must be after {start}") — a changed message reads as a different - /// producer, orphaning the previous one. Rules mounted through the element tree get - /// a real per-instance identity instead and are unaffected; - /// keys by position. Prefer either - /// over calling Evaluate directly in a render loop. + /// Derived from the field plus the predicate's *method* — for a lambda, the + /// compiler-generated method for that call site — so the same line of code owns the + /// same slot on every evaluation. 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. + /// + /// + /// Two rules built at the same call site for the same field still share a slot and + /// overwrite each other. Mounting them through the element tree gives each a real + /// per-instance identity; prefer that over calling Evaluate in a loop. /// /// - internal static string FallbackProducerKey(ValidationRuleElement rule) => - $"rule:{(int)rule.Severity}:{rule.Message}"; + internal static string FallbackProducerKey(ValidationRuleElement rule) + { + // 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}"; + } private static List BuildMessages(ValidationRuleElement rule, bool passed) => passed ? [] : [new ValidationMessage(rule.Field, rule.Message, rule.Severity)]; diff --git a/src/Reactor/Core/V1Protocol/CompositeLifecycle.cs b/src/Reactor/Core/V1Protocol/CompositeLifecycle.cs index 0889e1bba..d4fcb50fb 100644 --- a/src/Reactor/Core/V1Protocol/CompositeLifecycle.cs +++ b/src/Reactor/Core/V1Protocol/CompositeLifecycle.cs @@ -317,7 +317,7 @@ internal static UIElement MountValidationRule(Reconciler reconciler, ValidationR var valCtx = reconciler.ReadContext(ValidationContexts.Current); if (valCtx is not null) { - rule.Evaluate(valCtx, binding.Producer); + EvaluateRuleForBinding(rule, valCtx, binding); binding.Context = valCtx; binding.Field = rule.Field; } @@ -345,7 +345,7 @@ internal static UIElement MountValidationRule(Reconciler reconciler, ValidationR if (valCtx is not null) { - rule.Evaluate(valCtx, binding.Producer); + EvaluateRuleForBinding(rule, valCtx, binding); binding.Context = valCtx; binding.Field = rule.Field; } @@ -361,6 +361,10 @@ internal static void RetractValidationRule(UIElement placeholder) { if (!_ruleBindings.TryGetValue(placeholder, out var 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.ApplyOwned(field, binding.Producer, []); @@ -373,6 +377,76 @@ private sealed class RuleBinding internal string Producer = ""; internal ValidationContext? Context; internal string? Field; + internal global::System.Threading.CancellationTokenSource? Pending; + } + + /// + /// 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); + 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) + { + try + { + await rule.EvaluateAsync(valCtx, binding.Producer, cts.Token); + } + catch (global::System.OperationCanceledException) + { + // Superseded by a newer pass, or the rule left the tree. + } + catch (global::System.Exception ex) + { + // 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); + } + finally + { + if (ReferenceEquals(binding.Pending, cts)) binding.Pending = null; + cts.Dispose(); + } + } + + private static void CancelPendingRule(RuleBinding binding) + { + var pending = binding.Pending; + if (pending is null) return; + + binding.Pending = null; + try { pending.Cancel(); } + catch (global::System.ObjectDisposedException) { } } private static long s_ruleProducerSeed; diff --git a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs index a24503fd1..04f67f2f4 100644 --- a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs +++ b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs @@ -354,31 +354,33 @@ 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 — the same rule (same message, so the same producer) now - // passes and retracts its own message. Using a *different* rule here would - // assert the old whole-field-replace behaviour, where any passing rule - // erased every other producer's errors on the field (issue #1262 review). - var asyncRule2 = ValidationRuleAsync( - async () => { await Task.Delay(1); return true; }, - "Async fail", "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. @@ -1169,4 +1171,68 @@ public override async Task RunAsync() 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(); + } + } } diff --git a/tests/Reactor.AppTests.Host/SelfTest/SelfTestFixtureRegistry.cs b/tests/Reactor.AppTests.Host/SelfTest/SelfTestFixtureRegistry.cs index 2f0f3531e..841e5c7a0 100644 --- a/tests/Reactor.AppTests.Host/SelfTest/SelfTestFixtureRegistry.cs +++ b/tests/Reactor.AppTests.Host/SelfTest/SelfTestFixtureRegistry.cs @@ -817,6 +817,7 @@ internal static class SelfTestFixtureRegistry "ValCov_Issue1262_DisplacedRootBinding", "ValCov_Issue1262_AsyncOnlyCachedField", "ValCov_Issue1262_ChainedValueOverloads", + "ValCov_Issue1262_MountedAsyncRule", // Controls coverage — MaskEngine, InputFormatter, AutoSuggest "ControlsCov_MaskEngineBasic", "ControlsCov_MaskEngineNavigation", @@ -2728,6 +2729,7 @@ internal static string[] StaleTierDeclarations() => "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), // Controls coverage "ControlsCov_MaskEngineBasic" => new ControlsCoverageFixtures.MaskEngineBasic(harness), "ControlsCov_MaskEngineNavigation" => new ControlsCoverageFixtures.MaskEngineNavigation(harness), diff --git a/tests/Reactor.Tests/ValidationRenderScopeTests.cs b/tests/Reactor.Tests/ValidationRenderScopeTests.cs index d82bee3ab..e16b6b725 100644 --- a/tests/Reactor.Tests/ValidationRenderScopeTests.cs +++ b/tests/Reactor.Tests/ValidationRenderScopeTests.cs @@ -592,11 +592,19 @@ public void A_Passing_Rule_Retracts_Only_Its_Own_Message() { var ctx = new ValidationContext(); ValidationReconciler.ValidateField(ctx, "confirm", "", Validate.Required("Confirmation is required")); - ValidationRule(() => false, "Passwords must match", "confirm").Evaluate(ctx); + + // 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. - ValidationRule(() => true, "Passwords must match", "confirm").Evaluate(ctx); + matches = true; + RunRule(); var texts = ctx.GetMessages("confirm").Select(m => m.Text).ToList(); Assert.Single(texts); @@ -1160,6 +1168,67 @@ public async Task A_Value_Change_Retires_An_In_Flight_Async_Rule_Pass() 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 A_Value_Change_Leaves_Sync_Messages_For_The_New_Value_Intact() { From 6bf1fd519543144aa6a6e3b2acb3320e01c386fe Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Wed, 23 Sep 2026 23:06:25 -0700 Subject: [PATCH 24/67] Reject sync evaluation of async rules; retire rule producers properly Three review findings, all fallout from dispatching mounted async rules. - ValidationRuleAsync stores a constant-true synchronous Predicate, so Evaluate(ctx) and EvaluateRules(...) still reported an invalid async rule as valid without running it. Both now throw with an actionable message, and EvaluateRulesAsync is the batch path that runs them. Rules run in order there, so the resulting message order matches the order given. - A mounted rule that lost its provider had its message withdrawn while its pass kept running, so the pass could reinstall the error into a context the rule no longer belonged to. The pass is cancelled before the withdrawal. - Withdrawing a rule's messages left its entry in the context's async generation map. Every mounted rule gets a fresh rule#N identity, so a long-lived context accumulated one stale entry per mount. Retraction now goes through RetireProducer, which drops the generation and the messages together. Also clears the blur binding of a displaced FormField editor directly rather than through the root mapping. A remount replaces the root, and the mapping then names a control other than the one that actually left -- which is what made the displaced-binding fixture fail intermittently in group runs. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 373c7212-34f6-4ddf-8ae9-59553c77cf48 --- CHANGELOG.md | 19 ++++- .../reactor-dsl/references/reactor.api.txt | 1 + skills/reactor.api.txt | 1 + .../Controls/Validation/ValidationContext.cs | 28 +++++++ .../Validation/ValidationReconciler.cs | 39 +++++++++- .../Controls/Validation/ValidationRule.cs | 14 ++++ .../Core/V1Protocol/CompositeLifecycle.cs | 16 +++- .../Fixtures/ValidationCoverageFixtures.cs | 63 +++++++++++++++- .../SelfTest/SelfTestFixtureRegistry.cs | 2 + .../ValidationRenderScopeTests.cs | 74 +++++++++++++++++++ 10 files changed, 251 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fbd59a61d..ab6355005 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,10 @@ Conventions for contributors: ### Added +- `ValidationReconciler.EvaluateRulesAsync(...)` — the asynchronous counterpart to + `EvaluateRules`, for rule sets containing rules built with `ValidationRuleAsync`. + Rules run in order, so the resulting message order matches the order given + (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 @@ -107,7 +111,10 @@ Conventions for contributors: invalidated the shared context (issue #1262). - A `FormField` that lost its context and swapped its content control in the same update left the displaced editor's blur binding live, so the pooled control kept - marking the old field and kept its `ValidationContext` alive (issue #1262). + marking the old field and kept its `ValidationContext` alive (issue #1262). The + displaced editor is now neutralized directly rather than through the root + mapping, which a remount can replace — leaving the mapping naming a control that + is no longer the one that left. - Chaining two value overloads on one element — `.Validate(f, v, Email()).Validate(f, v, MinLength(10))` — repainted forever. Each call eagerly applied its own intermediate validator set under the same @@ -115,12 +122,22 @@ Conventions for contributors: the net state never moved, but each write announced a change that scheduled another identical pass. A render that ends with the same messages it started with is now silent (issue #1262). +- An async `ValidationRule` evaluated synchronously — `.Evaluate(ctx)` or + `EvaluateRules(...)` — recorded a passing verdict without ever running its + predicate, reporting an invalid field as valid. Those paths now throw, and + `EvaluateRulesAsync` is the batch path that runs them (issue #1262). - An async `ValidationRule` placed in the element tree never ran its predicate. `ValidationRuleAsync` builds an element whose synchronous predicate is a constant `true`, and the mount/update lifecycle evaluated that, so the rule silently recorded a passing verdict. Mounted async rules now run through the generation-guarded async path, with the in-flight pass cancelled on update and on unmount (issue #1262). +- A mounted rule that left the tree, or moved to another context, had its messages + withdrawn but not its async generation entry, so a pass already running could + reinstall the verdict into a context the rule no longer belonged to — and a + long-lived context accumulated a stale entry per mount, since every mounted rule + gets a fresh identity. Both paths now retire the producer: generation and + messages together (issue #1262). - A rule evaluated directly with `.Evaluate(ctx)` was identified by its message text, so an interpolated message such as `$"Must be after {start}"` orphaned the previous verdict on every change: errors accumulated and a now-passing diff --git a/plugins/reactor/skills/reactor-dsl/references/reactor.api.txt b/plugins/reactor/skills/reactor-dsl/references/reactor.api.txt index bb0205566..51b6f7276 100644 --- a/plugins/reactor/skills/reactor-dsl/references/reactor.api.txt +++ b/plugins/reactor/skills/reactor-dsl/references/reactor.api.txt @@ -6143,6 +6143,7 @@ ToString() → string ### static class ValidationReconciler EvaluateRules(ValidationContext ctx, ValidationRuleElement[] rules) → void +EvaluateRulesAsync(ValidationContext ctx, 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/skills/reactor.api.txt b/skills/reactor.api.txt index bb0205566..51b6f7276 100644 --- a/skills/reactor.api.txt +++ b/skills/reactor.api.txt @@ -6143,6 +6143,7 @@ ToString() → string ### static class ValidationReconciler EvaluateRules(ValidationContext ctx, ValidationRuleElement[] rules) → void +EvaluateRulesAsync(ValidationContext ctx, 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/ValidationContext.cs b/src/Reactor/Controls/Validation/ValidationContext.cs index 368a1242e..def824522 100644 --- a/src/Reactor/Controls/Validation/ValidationContext.cs +++ b/src/Reactor/Controls/Validation/ValidationContext.cs @@ -872,6 +872,34 @@ public void NotifyValueChanged(string field, object? value) RaiseChanged(); } + /// + /// 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) + { + if (_asyncGeneration.TryGetValue(field, out var byProducer)) + { + byProducer.Remove(producer); + if (byProducer.Count == 0) _asyncGeneration.Remove(field); + } + + changed = ApplyOwnedLocked(field, producer, []); + if (changed) BumpVersionLocked(messagesOnly: true); + } + if (changed) RaiseChanged(messagesOnly: true); + } + /// /// Withdraws every async contribution to a field and retires its in-flight passes. /// diff --git a/src/Reactor/Controls/Validation/ValidationReconciler.cs b/src/Reactor/Controls/Validation/ValidationReconciler.cs index 333b66d2f..6395cbc5a 100644 --- a/src/Reactor/Controls/Validation/ValidationReconciler.cs +++ b/src/Reactor/Controls/Validation/ValidationReconciler.cs @@ -109,7 +109,44 @@ public static void EvaluateRules( if (_ruleSets.TryGetValue(ctx, out var previous)) { foreach (var entry in previous.Where(entry => !applied.Contains(entry))) - ctx.ApplyOwned(entry.Field, entry.Producer, []); + ctx.RetireProducer(entry.Field, entry.Producer); + _ruleSets.Remove(ctx); + } + + _ruleSets.Add(ctx, applied); + } + + /// + /// The asynchronous counterpart to , for rule sets that + /// contain rules built by ValidationRuleAsync. Synchronous rules in the set + /// 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. + /// + /// + /// Ownership works exactly as in the synchronous overload: each rule is keyed by its + /// position, and a producer from the previous call that is absent this time has its + /// contribution withdrawn. + /// + /// + public static async Task EvaluateRulesAsync( + ValidationContext ctx, + params ValidationRuleElement[] rules) + { + var applied = new List<(string Field, string Producer)>(rules.Length); + for (var i = 0; i < rules.Length; i++) + { + var producer = "rules[" + i.ToString(global::System.Globalization.CultureInfo.InvariantCulture) + "]"; + await rules[i].EvaluateAsync(ctx, producer); + applied.Add((rules[i].Field, producer)); + } + + if (_ruleSets.TryGetValue(ctx, out var previous)) + { + foreach (var entry in previous.Where(entry => !applied.Contains(entry))) + ctx.RetireProducer(entry.Field, entry.Producer); _ruleSets.Remove(ctx); } diff --git a/src/Reactor/Controls/Validation/ValidationRule.cs b/src/Reactor/Controls/Validation/ValidationRule.cs index 0457fc39d..d2b2ebcde 100644 --- a/src/Reactor/Controls/Validation/ValidationRule.cs +++ b/src/Reactor/Controls/Validation/ValidationRule.cs @@ -80,9 +80,23 @@ public static void Evaluate(this ValidationRuleElement rule, ValidationContext c /// 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). + /// /// internal static void Evaluate(this ValidationRuleElement rule, ValidationContext ctx, string producer) { + if (rule.AsyncPredicate is not null) + { + 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."); + } + ctx.ApplyOwned(rule.Field, producer, BuildMessages(rule, rule.Predicate())); } diff --git a/src/Reactor/Core/V1Protocol/CompositeLifecycle.cs b/src/Reactor/Core/V1Protocol/CompositeLifecycle.cs index d4fcb50fb..098087fb5 100644 --- a/src/Reactor/Core/V1Protocol/CompositeLifecycle.cs +++ b/src/Reactor/Core/V1Protocol/CompositeLifecycle.cs @@ -151,6 +151,7 @@ private static void ApplyAttachedValidation(ValidationContext? valCtx, Validatio // [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); @@ -175,6 +176,14 @@ private static void ApplyAttachedValidation(ValidationContext? valCtx, Validatio 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); @@ -338,7 +347,10 @@ internal static UIElement MountValidationRule(Reconciler reconciler, ValidationR && (!ReferenceEquals(previousCtx, valCtx) || !string.Equals(previousField, rule.Field, StringComparison.Ordinal))) { - previousCtx.ApplyOwned(previousField, binding.Producer, []); + // 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; } @@ -366,7 +378,7 @@ internal static void RetractValidationRule(UIElement placeholder) CancelPendingRule(binding); if (binding.Context is { } ctx && binding.Field is { } field) - ctx.ApplyOwned(field, binding.Producer, []); + ctx.RetireProducer(field, binding.Producer); binding.Context = null; binding.Field = null; diff --git a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs index 04f67f2f4..052cc5226 100644 --- a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs +++ b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs @@ -1037,7 +1037,15 @@ public override async Task RunAsync() H.Check("Issue1262_Displaced_ResetClearedTouched", !ctx.IsTouched("name")); setMode!(1); - await Harness.Render(); + // 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 @@ -1048,7 +1056,8 @@ public override async Task RunAsync() "the displaced editor still carries a live blur binding"); setMode!(2); - await Harness.Render(); + 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; } @@ -1235,4 +1244,54 @@ public override async Task RunAsync() 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(); + } + } } diff --git a/tests/Reactor.AppTests.Host/SelfTest/SelfTestFixtureRegistry.cs b/tests/Reactor.AppTests.Host/SelfTest/SelfTestFixtureRegistry.cs index 841e5c7a0..bc1196233 100644 --- a/tests/Reactor.AppTests.Host/SelfTest/SelfTestFixtureRegistry.cs +++ b/tests/Reactor.AppTests.Host/SelfTest/SelfTestFixtureRegistry.cs @@ -818,6 +818,7 @@ internal static class SelfTestFixtureRegistry "ValCov_Issue1262_AsyncOnlyCachedField", "ValCov_Issue1262_ChainedValueOverloads", "ValCov_Issue1262_MountedAsyncRule", + "ValCov_Issue1262_AsyncRuleProviderRemoved", // Controls coverage — MaskEngine, InputFormatter, AutoSuggest "ControlsCov_MaskEngineBasic", "ControlsCov_MaskEngineNavigation", @@ -2730,6 +2731,7 @@ internal static string[] StaleTierDeclarations() => "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), // Controls coverage "ControlsCov_MaskEngineBasic" => new ControlsCoverageFixtures.MaskEngineBasic(harness), "ControlsCov_MaskEngineNavigation" => new ControlsCoverageFixtures.MaskEngineNavigation(harness), diff --git a/tests/Reactor.Tests/ValidationRenderScopeTests.cs b/tests/Reactor.Tests/ValidationRenderScopeTests.cs index e16b6b725..ef6853716 100644 --- a/tests/Reactor.Tests/ValidationRenderScopeTests.cs +++ b/tests/Reactor.Tests/ValidationRenderScopeTests.cs @@ -1229,6 +1229,80 @@ public void A_Directly_Evaluated_Rule_Leaves_Sync_Field_Messages_Alone() 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_Value_Change_Leaves_Sync_Messages_For_The_New_Value_Intact() { From dbefbd9916b636f26e5adf6323aa2616917487fc Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Wed, 23 Sep 2026 23:11:49 -0700 Subject: [PATCH 25/67] Address code-quality feedback on the async rule dispatch - Narrow the fire-and-forget catch so OutOfMemory and StackOverflow are not absorbed, matching the convention used elsewhere in the reconciler. - Dispose the pass token source with `using` rather than a finally block. - Report the ObjectDisposedException race in CancelPendingRule instead of swallowing it in an empty catch. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 373c7212-34f6-4ddf-8ae9-59553c77cf48 --- .../Core/V1Protocol/CompositeLifecycle.cs | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/src/Reactor/Core/V1Protocol/CompositeLifecycle.cs b/src/Reactor/Core/V1Protocol/CompositeLifecycle.cs index 098087fb5..37ccc8cb8 100644 --- a/src/Reactor/Core/V1Protocol/CompositeLifecycle.cs +++ b/src/Reactor/Core/V1Protocol/CompositeLifecycle.cs @@ -427,6 +427,7 @@ 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); @@ -436,6 +437,8 @@ private static async Task RunAsyncRuleAsync( // Superseded by a newer pass, or the rule left the tree. } 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 @@ -444,11 +447,8 @@ private static async Task RunAsyncRuleAsync( Diagnostics.DiagnosticLog.SwallowedError( Diagnostics.LogCategory.Reactor, "ValidationRuleAsync.Evaluate", ex); } - finally - { - if (ReferenceEquals(binding.Pending, cts)) binding.Pending = null; - cts.Dispose(); - } + + if (ReferenceEquals(binding.Pending, cts)) binding.Pending = null; } private static void CancelPendingRule(RuleBinding binding) @@ -457,8 +457,17 @@ private static void CancelPendingRule(RuleBinding binding) if (pending is null) return; binding.Pending = null; - try { pending.Cancel(); } - catch (global::System.ObjectDisposedException) { } + 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; From 96992f7583b76ad0b92f3193421b843baa1ad50b Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Wed, 23 Sep 2026 23:42:14 -0700 Subject: [PATCH 26/67] Length-prefix the validation snapshot; order async rule batches Four review findings. - The net-zero snapshot used bare separators, so a message whose text contained them could serialize identically to two messages and a real change would be suppressed. Every value is now length-prefixed, and the per-field counts are part of the encoding. - ValidateFieldAsync and both rule batch paths never registered the fields they validated, so MarkAllTouched() and the validity summary skipped a field that only ever had async validators or rules. - Overlapping EvaluateRulesAsync calls were not ordered as batches: two calls whose rules target different fields cannot be ordered by the per-producer generation, so whichever finished last owned the bookkeeping for both. Batches against one context are now serialized, and one that finds a newer batch already requested stands down without writing. - A mounted rule swapped from ValidationRuleAsync to ValidationRule keeps its placeholder, and therefore its producer identity. The stale async generation entry made the next value change retract a synchronous verdict that was still current; the entry is now cleared on the sync path. Each has a mutation-checked regression. The snapshot one derives its collider string from the live message metadata so it cannot drift. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 373c7212-34f6-4ddf-8ae9-59553c77cf48 --- CHANGELOG.md | 6 ++ .../Controls/Validation/ValidationContext.cs | 62 +++++++++++--- .../Validation/ValidationReconciler.cs | 60 ++++++++++--- .../Core/V1Protocol/CompositeLifecycle.cs | 6 ++ .../Fixtures/ValidationCoverageFixtures.cs | 57 +++++++++++++ .../SelfTest/SelfTestFixtureRegistry.cs | 2 + .../ValidationRenderScopeTests.cs | 84 +++++++++++++++++++ 7 files changed, 255 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ab6355005..77b4ed961 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -122,6 +122,12 @@ Conventions for contributors: the net state never moved, but each write announced a change that scheduled another identical pass. A render that ends with the same messages it started with is now silent (issue #1262). +- `ValidationReconciler.ValidateFieldAsync` and the rule batch paths did not register + the fields they validated, so `MarkAllTouched()` and the validity summary skipped a + field validated only through them (issue #1262). +- A mounted rule swapped from `ValidationRuleAsync` to `ValidationRule` kept its async + generation entry, so the next value change treated it as async and retracted a + synchronous verdict that was still current (issue #1262). - An async `ValidationRule` evaluated synchronously — `.Evaluate(ctx)` or `EvaluateRules(...)` — recorded a passing verdict without ever running its predicate, reporting an invalid field as valid. Those paths now throw, and diff --git a/src/Reactor/Controls/Validation/ValidationContext.cs b/src/Reactor/Controls/Validation/ValidationContext.cs index def824522..cf199f85b 100644 --- a/src/Reactor/Controls/Validation/ValidationContext.cs +++ b/src/Reactor/Controls/Validation/ValidationContext.cs @@ -182,6 +182,12 @@ private void DeliverDeferred() /// 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() { @@ -192,24 +198,40 @@ private string MessageSnapshotLocked() var sb = new global::System.Text.StringBuilder(); foreach (var field in fields) { - sb.Append(field).Append('\u0002'); - if (_messages.TryGetValue(field, out var owned)) + 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) - sb.Append('i').Append('\u0001').Append(m.Severity).Append('\u0001') - .Append(m.Code).Append('\u0001').Append(m.Text).Append('\u0003'); + foreach (var m in owned) AppendMessage(sb, 'i', m); } - if (_externalMessages.TryGetValue(field, out var external)) + if (external is not null) { - foreach (var m in external) - sb.Append('e').Append('\u0001').Append(m.Severity).Append('\u0001') - .Append(m.Code).Append('\u0001').Append(m.Text).Append('\u0003'); + foreach (var m in external) AppendMessage(sb, 'e', m); } - sb.Append('\u0004'); } 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 bool _frameTouchedNonMessageState; private bool _frameVersionPending; @@ -872,6 +894,26 @@ public void NotifyValueChanged(string field, object? value) RaiseChanged(); } + /// + /// 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) + { + 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. diff --git a/src/Reactor/Controls/Validation/ValidationReconciler.cs b/src/Reactor/Controls/Validation/ValidationReconciler.cs index 6395cbc5a..12ae54a63 100644 --- a/src/Reactor/Controls/Validation/ValidationReconciler.cs +++ b/src/Reactor/Controls/Validation/ValidationReconciler.cs @@ -61,6 +61,11 @@ public static async Task ValidateFieldAsync( IAsyncValidator[] asyncValidators, CancellationToken cancellationToken = default) { + // The synchronous path registers through ApplyValidation; this one has to do it + // explicitly, or MarkAllTouched() and the validity summary would skip a field + // that only ever had async validators (issue #1262 review). + ctx.RegisterField(fieldName); + var generation = ctx.BeginAsyncValidation(fieldName); var messages = new List(asyncValidators.Length); @@ -102,6 +107,7 @@ public static void EvaluateRules( for (var i = 0; i < rules.Length; i++) { var producer = "rules[" + i.ToString(global::System.Globalization.CultureInfo.InvariantCulture) + "]"; + ctx.RegisterField(rules[i].Field); rules[i].Evaluate(ctx, producer); applied.Add((rules[i].Field, producer)); } @@ -130,29 +136,59 @@ public static void EvaluateRules( /// position, and a producer from the previous call that is absent this time has its /// contribution withdrawn. /// + /// + /// Batches against one context are serialized, and a batch that finds a newer one + /// already requested stands down without writing. Overlapping batches would + /// otherwise interleave: an older call's rules[0] on field A and a newer + /// call's rules[0] on field B are tracked under different fields, so the + /// per-producer generation cannot order them, and whichever finished last would own + /// the bookkeeping for both. + /// /// public static async Task EvaluateRulesAsync( ValidationContext ctx, params ValidationRuleElement[] rules) { - var applied = new List<(string Field, string Producer)>(rules.Length); - for (var i = 0; i < rules.Length; i++) + var ticket = _ruleSetTickets.GetValue(ctx, static _ => new global::System.Runtime.CompilerServices.StrongBox(0)); + var generation = global::System.Threading.Interlocked.Increment(ref ticket.Value); + + var gate = _ruleSetGates.GetValue(ctx, static _ => new global::System.Threading.SemaphoreSlim(1, 1)); + await gate.WaitAsync().ConfigureAwait(true); + try { - var producer = "rules[" + i.ToString(global::System.Globalization.CultureInfo.InvariantCulture) + "]"; - await rules[i].EvaluateAsync(ctx, producer); - applied.Add((rules[i].Field, producer)); - } + // A newer batch was requested while this one waited; it supersedes this set + // wholesale, so writing anything here would be stale by definition. + if (global::System.Threading.Volatile.Read(ref ticket.Value) != generation) return; - if (_ruleSets.TryGetValue(ctx, out var previous)) + var applied = new List<(string Field, string Producer)>(rules.Length); + for (var i = 0; i < rules.Length; i++) + { + var producer = "rules[" + i.ToString(global::System.Globalization.CultureInfo.InvariantCulture) + "]"; + ctx.RegisterField(rules[i].Field); + await rules[i].EvaluateAsync(ctx, producer); + applied.Add((rules[i].Field, producer)); + } + + if (_ruleSets.TryGetValue(ctx, out var previous)) + { + foreach (var entry in previous.Where(entry => !applied.Contains(entry))) + ctx.RetireProducer(entry.Field, entry.Producer); + _ruleSets.Remove(ctx); + } + + _ruleSets.Add(ctx, applied); + } + finally { - foreach (var entry in previous.Where(entry => !applied.Contains(entry))) - ctx.RetireProducer(entry.Field, entry.Producer); - _ruleSets.Remove(ctx); + gate.Release(); } - - _ruleSets.Add(ctx, applied); } + // Per-context batch ordering: the ticket names the most recently requested batch, the + // gate keeps two from running at once. Weak on the context so neither holds it alive. + private static readonly global::System.Runtime.CompilerServices.ConditionalWeakTable> _ruleSetTickets = new(); + private static readonly global::System.Runtime.CompilerServices.ConditionalWeakTable _ruleSetGates = new(); + // The rule set most recently evaluated against each context, so the next call can // withdraw whatever disappeared. Weak on the context so it holds nothing alive. private static readonly global::System.Runtime.CompilerServices.ConditionalWeakTable> _ruleSets = new(); diff --git a/src/Reactor/Core/V1Protocol/CompositeLifecycle.cs b/src/Reactor/Core/V1Protocol/CompositeLifecycle.cs index 37ccc8cb8..4b6ef0d12 100644 --- a/src/Reactor/Core/V1Protocol/CompositeLifecycle.cs +++ b/src/Reactor/Core/V1Protocol/CompositeLifecycle.cs @@ -413,6 +413,12 @@ private static void EvaluateRuleForBinding(ValidationRuleElement rule, Validatio if (rule.AsyncPredicate is null) { CancelPendingRule(binding); + + // The rule may have just stopped being async on this same placeholder. Its + // generation entry has to go, or the next value change would treat this + // producer as async and retract the synchronous verdict written below. + valCtx.ClearAsyncGeneration(rule.Field, binding.Producer); + rule.Evaluate(valCtx, binding.Producer); return; } diff --git a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs index 052cc5226..56a98222e 100644 --- a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs +++ b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs @@ -1294,4 +1294,61 @@ public override async Task RunAsync() 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(); + } + } } diff --git a/tests/Reactor.AppTests.Host/SelfTest/SelfTestFixtureRegistry.cs b/tests/Reactor.AppTests.Host/SelfTest/SelfTestFixtureRegistry.cs index bc1196233..5925dfe1e 100644 --- a/tests/Reactor.AppTests.Host/SelfTest/SelfTestFixtureRegistry.cs +++ b/tests/Reactor.AppTests.Host/SelfTest/SelfTestFixtureRegistry.cs @@ -819,6 +819,7 @@ internal static class SelfTestFixtureRegistry "ValCov_Issue1262_ChainedValueOverloads", "ValCov_Issue1262_MountedAsyncRule", "ValCov_Issue1262_AsyncRuleProviderRemoved", + "ValCov_Issue1262_AsyncRuleBecomesSync", // Controls coverage — MaskEngine, InputFormatter, AutoSuggest "ControlsCov_MaskEngineBasic", "ControlsCov_MaskEngineNavigation", @@ -2732,6 +2733,7 @@ internal static string[] StaleTierDeclarations() => "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), // Controls coverage "ControlsCov_MaskEngineBasic" => new ControlsCoverageFixtures.MaskEngineBasic(harness), "ControlsCov_MaskEngineNavigation" => new ControlsCoverageFixtures.MaskEngineNavigation(harness), diff --git a/tests/Reactor.Tests/ValidationRenderScopeTests.cs b/tests/Reactor.Tests/ValidationRenderScopeTests.cs index ef6853716..c72ba105b 100644 --- a/tests/Reactor.Tests/ValidationRenderScopeTests.cs +++ b/tests/Reactor.Tests/ValidationRenderScopeTests.cs @@ -1303,6 +1303,90 @@ public async Task Retiring_A_Producer_Withdraws_What_It_Already_Installed() 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 batch: blocks on field "a". + var older = ValidationReconciler.EvaluateRulesAsync( + ctx, ValidationRuleAsync(() => slow.Task, "Stale verdict", "a")); + + // Newer batch, requested while the older one is still out. + var newer = ValidationReconciler.EvaluateRulesAsync( + ctx, ValidationRuleAsync(() => Task.FromResult(false), "Current verdict", "b")); + + slow.SetResult(false); + await older; + await newer; + + // The older batch stood down: it neither installed its own verdict nor retired + // the newer batch's producer. + Assert.Empty(ctx.GetMessages("a")); + Assert.Single(ctx.GetMessages("b")); + Assert.Equal("Current verdict", ctx.GetMessages("b")[0].Text); + } + [Fact] public void A_Value_Change_Leaves_Sync_Messages_For_The_New_Value_Intact() { From a3cac22a631ea596cc6e8d1567f1e2b5e4217a5f Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Thu, 24 Sep 2026 00:09:48 -0700 Subject: [PATCH 27/67] Order rule batches by generation instead of a semaphore Three review findings on EvaluateRulesAsync, plus a missing registration. The per-context semaphore was held across caller predicates, so one that never completed blocked every later batch for that context forever, and the latest-wins check ran only before the first await -- a synchronous EvaluateRules call never advanced the ticket at all, so a stale async completion could permanently overwrite a newer synchronous set. Both batch APIs now advance one generation counter, every verdict is computed before any is installed, and the generation is verified at the point results are committed. An overtaken batch stands down with nothing to unwind, and no lock is held across caller code. This also removes the async-generation staleness the batch path could previously leave behind: ComputeAsync deliberately never opens a per-producer generation, because the batch orders itself. Mounting or updating a ValidationRule now registers its field, which the batch and async-field paths already did. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 373c7212-34f6-4ddf-8ae9-59553c77cf48 --- .../Validation/ValidationReconciler.cs | 119 ++++++++++-------- .../Controls/Validation/ValidationRule.cs | 23 ++++ .../Core/V1Protocol/CompositeLifecycle.cs | 2 + .../ValidationRenderScopeTests.cs | 87 +++++++++++++ 4 files changed, 182 insertions(+), 49 deletions(-) diff --git a/src/Reactor/Controls/Validation/ValidationReconciler.cs b/src/Reactor/Controls/Validation/ValidationReconciler.cs index 12ae54a63..aa09ba5d0 100644 --- a/src/Reactor/Controls/Validation/ValidationReconciler.cs +++ b/src/Reactor/Controls/Validation/ValidationReconciler.cs @@ -103,23 +103,17 @@ public static void EvaluateRules( ValidationContext ctx, params ValidationRuleElement[] rules) { + var generation = NextRuleSetGeneration(ctx); + var applied = new List<(string Field, string Producer)>(rules.Length); for (var i = 0; i < rules.Length; i++) { - var producer = "rules[" + i.ToString(global::System.Globalization.CultureInfo.InvariantCulture) + "]"; - ctx.RegisterField(rules[i].Field); - rules[i].Evaluate(ctx, producer); + var producer = RuleProducer(i); applied.Add((rules[i].Field, producer)); + CommitRuleVerdict(ctx, rules[i].Field, producer, rules[i].ComputeSync()); } - if (_ruleSets.TryGetValue(ctx, out var previous)) - { - foreach (var entry in previous.Where(entry => !applied.Contains(entry))) - ctx.RetireProducer(entry.Field, entry.Producer); - _ruleSets.Remove(ctx); - } - - _ruleSets.Add(ctx, applied); + CommitRuleSet(ctx, generation, applied); } /// @@ -137,58 +131,85 @@ public static void EvaluateRules( /// contribution withdrawn. /// /// - /// Batches against one context are serialized, and a batch that finds a newer one - /// already requested stands down without writing. Overlapping batches would - /// otherwise interleave: an older call's rules[0] on field A and a newer - /// call's rules[0] on field B are tracked under different fields, so the - /// per-producer generation cannot order them, and whichever finished last would own - /// the bookkeeping for both. + /// Every verdict is computed before any of them is installed, and nothing is + /// installed if another batch — synchronous or asynchronous — has been requested + /// against this context in the meantime. Both APIs advance one generation counter, so + /// an overtaken batch stands down with nothing to unwind. No lock is held across a + /// caller's predicate, so a predicate that never completes cannot block later batches. /// /// public static async Task EvaluateRulesAsync( ValidationContext ctx, params ValidationRuleElement[] rules) { - var ticket = _ruleSetTickets.GetValue(ctx, static _ => new global::System.Runtime.CompilerServices.StrongBox(0)); - var generation = global::System.Threading.Interlocked.Increment(ref ticket.Value); + var generation = NextRuleSetGeneration(ctx); - var gate = _ruleSetGates.GetValue(ctx, static _ => new global::System.Threading.SemaphoreSlim(1, 1)); - await gate.WaitAsync().ConfigureAwait(true); - try + 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, RuleProducer(i), await rules[i].ComputeAsync())); + + if (!IsNewestRuleSet(ctx, generation)) return; + + var applied = new List<(string Field, string Producer)>(verdicts.Count); + foreach (var verdict in verdicts) { - // A newer batch was requested while this one waited; it supersedes this set - // wholesale, so writing anything here would be stale by definition. - if (global::System.Threading.Volatile.Read(ref ticket.Value) != generation) return; - - var applied = new List<(string Field, string Producer)>(rules.Length); - for (var i = 0; i < rules.Length; i++) - { - var producer = "rules[" + i.ToString(global::System.Globalization.CultureInfo.InvariantCulture) + "]"; - ctx.RegisterField(rules[i].Field); - await rules[i].EvaluateAsync(ctx, producer); - applied.Add((rules[i].Field, producer)); - } - - if (_ruleSets.TryGetValue(ctx, out var previous)) - { - foreach (var entry in previous.Where(entry => !applied.Contains(entry))) - ctx.RetireProducer(entry.Field, entry.Producer); - _ruleSets.Remove(ctx); - } - - _ruleSets.Add(ctx, applied); + applied.Add((verdict.Field, verdict.Producer)); + CommitRuleVerdict(ctx, verdict.Field, verdict.Producer, verdict.Messages); } - finally + + CommitRuleSet(ctx, generation, applied); + } + + private static string RuleProducer(int index) => + "rules[" + index.ToString(global::System.Globalization.CultureInfo.InvariantCulture) + "]"; + + /// + /// Installs one rule's verdict. + /// + /// No per-producer async generation is involved: a batch computes every verdict + /// before installing any of them and orders itself by its own generation, so + /// ComputeAsync deliberately never opens one. That is what keeps a batch + /// producer that switches from asynchronous to synchronous from leaving a stale + /// token behind for the next value change to act on. + /// + /// + private static void CommitRuleVerdict( + ValidationContext ctx, string field, string producer, List messages) + { + ctx.RegisterField(field); + ctx.ApplyOwned(field, producer, messages); + } + + private static void CommitRuleSet( + ValidationContext ctx, int generation, List<(string Field, string Producer)> applied) + { + if (!IsNewestRuleSet(ctx, generation)) return; + + if (_ruleSets.TryGetValue(ctx, out var previous)) { - gate.Release(); + foreach (var entry in previous.Where(entry => !applied.Contains(entry))) + ctx.RetireProducer(entry.Field, entry.Producer); + _ruleSets.Remove(ctx); } + + _ruleSets.Add(ctx, applied); } - // Per-context batch ordering: the ticket names the most recently requested batch, the - // gate keeps two from running at once. Weak on the context so neither holds it alive. - private static readonly global::System.Runtime.CompilerServices.ConditionalWeakTable> _ruleSetTickets = new(); - private static readonly global::System.Runtime.CompilerServices.ConditionalWeakTable _ruleSetGates = new(); + private static int NextRuleSetGeneration(ValidationContext ctx) + { + var ticket = _ruleSetTickets.GetValue(ctx, static _ => new global::System.Runtime.CompilerServices.StrongBox(0)); + return global::System.Threading.Interlocked.Increment(ref ticket.Value); + } + private static bool IsNewestRuleSet(ValidationContext ctx, int generation) + { + var ticket = _ruleSetTickets.GetValue(ctx, static _ => new global::System.Runtime.CompilerServices.StrongBox(0)); + return global::System.Threading.Volatile.Read(ref ticket.Value) == generation; + } + + // Names the most recently requested rule set for a context, so an overtaken batch can + // stand down. Weak on the context so it holds nothing alive. + private static readonly global::System.Runtime.CompilerServices.ConditionalWeakTable> _ruleSetTickets = new(); // The rule set most recently evaluated against each context, so the next call can // withdraw whatever disappeared. Weak on the context so it holds nothing alive. private static readonly global::System.Runtime.CompilerServices.ConditionalWeakTable> _ruleSets = new(); diff --git a/src/Reactor/Controls/Validation/ValidationRule.cs b/src/Reactor/Controls/Validation/ValidationRule.cs index d2b2ebcde..33b2185f5 100644 --- a/src/Reactor/Controls/Validation/ValidationRule.cs +++ b/src/Reactor/Controls/Validation/ValidationRule.cs @@ -136,6 +136,29 @@ internal static async Task EvaluateAsync(this ValidationRuleElement rule, Valida ctx.ApplyAsyncOwned(rule.Field, producer, generation, BuildMessages(rule, result)); } + internal static List ComputeSync(this ValidationRuleElement rule) + { + if (rule.AsyncPredicate is not null) + { + 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()); + + var result = await rule.AsyncPredicate(); + cancellationToken.ThrowIfCancellationRequested(); + return BuildMessages(rule, result); + } + /// /// Identity of last resort for a rule evaluated outside the reconciler, where there /// is no mounted instance to key on. diff --git a/src/Reactor/Core/V1Protocol/CompositeLifecycle.cs b/src/Reactor/Core/V1Protocol/CompositeLifecycle.cs index 4b6ef0d12..c1697455b 100644 --- a/src/Reactor/Core/V1Protocol/CompositeLifecycle.cs +++ b/src/Reactor/Core/V1Protocol/CompositeLifecycle.cs @@ -326,6 +326,7 @@ internal static UIElement MountValidationRule(Reconciler reconciler, ValidationR var valCtx = reconciler.ReadContext(ValidationContexts.Current); if (valCtx is not null) { + valCtx.RegisterField(rule.Field); EvaluateRuleForBinding(rule, valCtx, binding); binding.Context = valCtx; binding.Field = rule.Field; @@ -357,6 +358,7 @@ internal static UIElement MountValidationRule(Reconciler reconciler, ValidationR if (valCtx is not null) { + valCtx.RegisterField(rule.Field); EvaluateRuleForBinding(rule, valCtx, binding); binding.Context = valCtx; binding.Field = rule.Field; diff --git a/tests/Reactor.Tests/ValidationRenderScopeTests.cs b/tests/Reactor.Tests/ValidationRenderScopeTests.cs index c72ba105b..90fc8558a 100644 --- a/tests/Reactor.Tests/ValidationRenderScopeTests.cs +++ b/tests/Reactor.Tests/ValidationRenderScopeTests.cs @@ -1387,6 +1387,93 @@ public async Task An_Overtaken_Async_Batch_Stands_Down_Instead_Of_Overwriting() Assert.Equal("Current verdict", ctx.GetMessages("b")[0].Text); } + [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_Batch_Overtakes_A_Running_Async_Batch() + { + var ctx = new ValidationContext(); + var slow = new TaskCompletionSource(); + + var older = ValidationReconciler.EvaluateRulesAsync( + ctx, ValidationRuleAsync(() => slow.Task, "Stale verdict", "a")); + + // A synchronous batch requested while the async one is still out. It advances + // the same generation counter, so the async batch must stand down. + ValidationReconciler.EvaluateRules( + ctx, 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, ValidationRuleAsync(() => never.Task, "Never resolves", "a")); + + // A later batch has to make progress regardless. + await ValidationReconciler.EvaluateRulesAsync( + ctx, ValidationRuleAsync(() => Task.FromResult(false), "Current verdict", "b")); + + Assert.Single(ctx.GetMessages("b")); + Assert.False(stuck.IsCompleted); + } + + [Fact] + public async Task A_Batch_Producer_That_Turns_Synchronous_Keeps_Its_Verdict() + { + var ctx = new ValidationContext(); + + await ValidationReconciler.EvaluateRulesAsync( + ctx, ValidationRuleAsync(() => Task.FromResult(false), "Rule failed", "name")); + Assert.Single(ctx.GetMessages("name")); + + // Same position, now a synchronous rule — so the same producer key. + await ValidationReconciler.EvaluateRulesAsync( + ctx, 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_Value_Change_Leaves_Sync_Messages_For_The_New_Value_Intact() { From dfd6733355f66a0c583d82d6cf73d97a26263763 Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Thu, 24 Sep 2026 00:32:27 -0700 Subject: [PATCH 28/67] Commit rule sets atomically; record the value in ValidateFieldAsync The rule-set commit installed one producer at a time, and ApplyOwned raises Changed. A subscriber woken by the first verdict could start a new evaluation and advance the generation; the outer call carried on installing the rest of a set it no longer owned, and CommitRuleSet then rejected only the ownership update -- leaving orphaned messages nothing would retract. The generation check, every producer replacement, and the retirement of producers that disappeared are now one transaction on the context, emitting a single notification. The recorded set is updated before the transaction so a re-entrant evaluation sees consistent bookkeeping. EvaluateRules also computes every verdict before installing any, as the async path already did. ValidateFieldAsync now records the value it validated, mirroring ValidateField: a caller re-invoking it on a new value no longer has to remember a separate NotifyValueChanged, and the error about the previous value does not linger while the new check is pending. Async_Validators_Install_As_One_Notification now asserts the verdict is never observed half-installed and lands exactly once, rather than counting all notifications -- recording the value is a legitimate separate one. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 373c7212-34f6-4ddf-8ae9-59553c77cf48 --- .../Controls/Validation/ValidationContext.cs | 40 +++++++++ .../Validation/ValidationReconciler.cs | 65 +++++++-------- .../ValidationRenderScopeTests.cs | 81 ++++++++++++++++++- 3 files changed, 151 insertions(+), 35 deletions(-) diff --git a/src/Reactor/Controls/Validation/ValidationContext.cs b/src/Reactor/Controls/Validation/ValidationContext.cs index cf199f85b..f6033359d 100644 --- a/src/Reactor/Controls/Validation/ValidationContext.cs +++ b/src/Reactor/Controls/Validation/ValidationContext.cs @@ -894,6 +894,46 @@ public void NotifyValueChanged(string field, object? value) 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) + { + _registeredFields.Add(field); + if (ApplyOwnedLocked(field, producer, messages)) changed = true; + } + + if (changed) BumpVersionLocked(messagesOnly: true); + } + if (changed) RaiseChanged(messagesOnly: true); + } + /// /// Drops a producer's async generation entry without touching its messages, for a /// producer that has stopped being asynchronous. diff --git a/src/Reactor/Controls/Validation/ValidationReconciler.cs b/src/Reactor/Controls/Validation/ValidationReconciler.cs index aa09ba5d0..2d0f3147c 100644 --- a/src/Reactor/Controls/Validation/ValidationReconciler.cs +++ b/src/Reactor/Controls/Validation/ValidationReconciler.cs @@ -61,10 +61,12 @@ public static async Task ValidateFieldAsync( IAsyncValidator[] asyncValidators, CancellationToken cancellationToken = default) { - // The synchronous path registers through ApplyValidation; this one has to do it - // explicitly, or MarkAllTouched() and the validity summary would skip a field - // that only ever had async validators (issue #1262 review). + // Mirror the synchronous path: record the value, which also clears an external + // verdict about the old one and retires any async pass still out for it. The + // helper is self-contained, so a caller that re-invokes it on a new value does + // not have to remember a separate NotifyValueChanged (issue #1262 review). ctx.RegisterField(fieldName); + ctx.NotifyValueChanged(fieldName, value); var generation = ctx.BeginAsyncValidation(fieldName); @@ -105,15 +107,13 @@ public static void EvaluateRules( { var generation = NextRuleSetGeneration(ctx); - var applied = new List<(string Field, string Producer)>(rules.Length); + // 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. + var verdicts = new List<(string Field, string Producer, List Messages)>(rules.Length); for (var i = 0; i < rules.Length; i++) - { - var producer = RuleProducer(i); - applied.Add((rules[i].Field, producer)); - CommitRuleVerdict(ctx, rules[i].Field, producer, rules[i].ComputeSync()); - } + verdicts.Add((rules[i].Field, RuleProducer(i), rules[i].ComputeSync())); - CommitRuleSet(ctx, generation, applied); + CommitRuleSet(ctx, generation, verdicts); } /// @@ -148,23 +148,25 @@ public static async Task EvaluateRulesAsync( for (var i = 0; i < rules.Length; i++) verdicts.Add((rules[i].Field, RuleProducer(i), await rules[i].ComputeAsync())); - if (!IsNewestRuleSet(ctx, generation)) return; - - var applied = new List<(string Field, string Producer)>(verdicts.Count); - foreach (var verdict in verdicts) - { - applied.Add((verdict.Field, verdict.Producer)); - CommitRuleVerdict(ctx, verdict.Field, verdict.Producer, verdict.Messages); - } - - CommitRuleSet(ctx, generation, applied); + CommitRuleSet(ctx, generation, verdicts); } private static string RuleProducer(int index) => "rules[" + index.ToString(global::System.Globalization.CultureInfo.InvariantCulture) + "]"; /// - /// Installs one rule's verdict. + /// Installs a computed rule set, if this call still owns it. + /// + /// The generation check, every producer replacement, and the retirement of producers + /// that have disappeared are one transaction against the context, emitting a single + /// notification. Installing producer by producer was observable mid-set: a subscriber + /// woken by the first verdict could start a new evaluation, and this call would carry + /// on installing the rest of a set it no longer owned. + /// + /// + /// The recorded set is updated before the transaction, so a re-entrant evaluation + /// triggered by its notification sees consistent bookkeeping. + /// /// /// No per-producer async generation is involved: a batch computes every verdict /// before installing any of them and orders itself by its own generation, so @@ -173,26 +175,25 @@ private static string RuleProducer(int index) => /// token behind for the next value change to act on. /// /// - private static void CommitRuleVerdict( - ValidationContext ctx, string field, string producer, List messages) - { - ctx.RegisterField(field); - ctx.ApplyOwned(field, producer, messages); - } - private static void CommitRuleSet( - ValidationContext ctx, int generation, List<(string Field, string Producer)> applied) + ValidationContext ctx, int generation, + List<(string Field, string Producer, List Messages)> verdicts) { if (!IsNewestRuleSet(ctx, generation)) return; + var applied = new List<(string Field, string Producer)>(verdicts.Count); + foreach (var verdict in verdicts) + applied.Add((verdict.Field, verdict.Producer)); + + var retired = new List<(string Field, string Producer)>(); if (_ruleSets.TryGetValue(ctx, out var previous)) { - foreach (var entry in previous.Where(entry => !applied.Contains(entry))) - ctx.RetireProducer(entry.Field, entry.Producer); + retired.AddRange(previous.Where(entry => !applied.Contains(entry))); _ruleSets.Remove(ctx); } - _ruleSets.Add(ctx, applied); + + ctx.ApplyRuleSet(verdicts, retired); } private static int NextRuleSetGeneration(ValidationContext ctx) diff --git a/tests/Reactor.Tests/ValidationRenderScopeTests.cs b/tests/Reactor.Tests/ValidationRenderScopeTests.cs index 90fc8558a..b68acd6e5 100644 --- a/tests/Reactor.Tests/ValidationRenderScopeTests.cs +++ b/tests/Reactor.Tests/ValidationRenderScopeTests.cs @@ -317,9 +317,11 @@ await ValidationReconciler.ValidateFieldAsync( ctx, "username", "admin", validators, TestContext.Current.CancellationToken); // Adding each result as it resolved exposed a partial verdict and repainted - // between messages. - Assert.Single(seen); - Assert.Equal(2, seen[0]); + // 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); } @@ -1474,6 +1476,79 @@ await ValidationReconciler.EvaluateRulesAsync( 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 starts its own evaluation. The outer call + // must not go on installing producers it no longer owns. + ValidationReconciler.EvaluateRules( + ctx, ValidationRule(() => true, "Inner rule", "a")); + }; + + ValidationReconciler.EvaluateRules( + ctx, + ValidationRule(() => false, "Outer first", "a"), + ValidationRule(() => false, "Outer second", "b")); + + Assert.True(reentered); + + // The inner set is the newest, and it owns the whole context: nothing from the + // outer set 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 void A_Value_Change_Leaves_Sync_Messages_For_The_New_Value_Intact() { From d63b0fe9dba6434dc0549c0a28ed2ea4eccdbe4e Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Thu, 24 Sep 2026 00:52:10 -0700 Subject: [PATCH 29/67] Issue async generations atomically with the value they validate Two review findings. ValidateFieldAsync recorded the value and opened its async pass as two separate locked operations, so concurrent callers could interleave as old.record, new.record + new.open, old.open -- leaving the older value's pass holding the newest token and free to install a verdict about a value that had already been replaced. BeginAsyncValidation(field, value) now does both under one lock. The public synchronous rule path did not retire an in-flight async generation for its producer, so an EvaluateAsync started earlier could resolve afterwards, still match its token, and overwrite the newer synchronous verdict. Evaluate now retires it first, which also subsumes the async-to-sync swap handling the mounted path did explicitly. CommitRuleSet holds a per-context lock across its generation check, the recorded-set swap and the context transaction, so a concurrent commit cannot slip between the check and the act. No caller code runs inside it. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 373c7212-34f6-4ddf-8ae9-59553c77cf48 --- .../Controls/Validation/ValidationContext.cs | 45 ++++++++++++++++-- .../Validation/ValidationReconciler.cs | 46 +++++++++++-------- .../Controls/Validation/ValidationRule.cs | 17 ++++--- .../Core/V1Protocol/CompositeLifecycle.cs | 7 +-- .../ValidationRenderScopeTests.cs | 21 +++++++++ 5 files changed, 98 insertions(+), 38 deletions(-) diff --git a/src/Reactor/Controls/Validation/ValidationContext.cs b/src/Reactor/Controls/Validation/ValidationContext.cs index f6033359d..595349ddb 100644 --- a/src/Reactor/Controls/Validation/ValidationContext.cs +++ b/src/Reactor/Controls/Validation/ValidationContext.cs @@ -495,10 +495,10 @@ private static bool SameMessages(List? a, List /// 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. + /// 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) @@ -540,6 +540,43 @@ internal void ApplyAsyncOwned(string field, string producer, int generation, Lis /// 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) + { + _registeredFields.Add(field); + + var known = _currentValues.TryGetValue(field, out var previous); + changed = !known || !Equals(previous, value); + if (changed) + { + _currentValues[field] = value; + _externalMessages.Remove(field); + RetractAsyncProducersLocked(field); + BumpVersionLocked(messagesOnly: false); + } + + 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 diff --git a/src/Reactor/Controls/Validation/ValidationReconciler.cs b/src/Reactor/Controls/Validation/ValidationReconciler.cs index 2d0f3147c..c3ec19a11 100644 --- a/src/Reactor/Controls/Validation/ValidationReconciler.cs +++ b/src/Reactor/Controls/Validation/ValidationReconciler.cs @@ -61,14 +61,12 @@ public static async Task ValidateFieldAsync( IAsyncValidator[] asyncValidators, CancellationToken cancellationToken = default) { - // Mirror the synchronous path: record the value, which also clears an external - // verdict about the old one and retires any async pass still out for it. The - // helper is self-contained, so a caller that re-invokes it on a new value does - // not have to remember a separate NotifyValueChanged (issue #1262 review). - ctx.RegisterField(fieldName); - ctx.NotifyValueChanged(fieldName, value); - - var generation = ctx.BeginAsyncValidation(fieldName); + // 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) @@ -179,23 +177,31 @@ private static void CommitRuleSet( ValidationContext ctx, int generation, List<(string Field, string Producer, List Messages)> verdicts) { - if (!IsNewestRuleSet(ctx, generation)) return; + // The generation check, the recorded-set swap and the context transaction are one + // critical section: checking and then acting let a concurrent commit slip between + // them. Nothing inside runs caller code, so this cannot be blocked by a predicate. + lock (_ruleSetCommitLocks.GetValue(ctx, static _ => new object())) + { + if (!IsNewestRuleSet(ctx, generation)) return; - var applied = new List<(string Field, string Producer)>(verdicts.Count); - foreach (var verdict in verdicts) - applied.Add((verdict.Field, verdict.Producer)); + var applied = new List<(string Field, string Producer)>(verdicts.Count); + foreach (var verdict in verdicts) + applied.Add((verdict.Field, verdict.Producer)); - var retired = new List<(string Field, string Producer)>(); - if (_ruleSets.TryGetValue(ctx, out var previous)) - { - retired.AddRange(previous.Where(entry => !applied.Contains(entry))); - _ruleSets.Remove(ctx); - } - _ruleSets.Add(ctx, applied); + var retired = new List<(string Field, string Producer)>(); + if (_ruleSets.TryGetValue(ctx, out var previous)) + { + retired.AddRange(previous.Where(entry => !applied.Contains(entry))); + _ruleSets.Remove(ctx); + } + _ruleSets.Add(ctx, applied); - ctx.ApplyRuleSet(verdicts, retired); + ctx.ApplyRuleSet(verdicts, retired); + } } + private static readonly global::System.Runtime.CompilerServices.ConditionalWeakTable _ruleSetCommitLocks = new(); + private static int NextRuleSetGeneration(ValidationContext ctx) { var ticket = _ruleSetTickets.GetValue(ctx, static _ => new global::System.Runtime.CompilerServices.StrongBox(0)); diff --git a/src/Reactor/Controls/Validation/ValidationRule.cs b/src/Reactor/Controls/Validation/ValidationRule.cs index 33b2185f5..41a5d9982 100644 --- a/src/Reactor/Controls/Validation/ValidationRule.cs +++ b/src/Reactor/Controls/Validation/ValidationRule.cs @@ -86,18 +86,17 @@ public static void Evaluate(this ValidationRuleElement rule, ValidationContext c /// 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) { - if (rule.AsyncPredicate is not null) - { - 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."); - } - - ctx.ApplyOwned(rule.Field, producer, BuildMessages(rule, rule.Predicate())); + ctx.ClearAsyncGeneration(rule.Field, producer); + ctx.ApplyOwned(rule.Field, producer, rule.ComputeSync()); } /// diff --git a/src/Reactor/Core/V1Protocol/CompositeLifecycle.cs b/src/Reactor/Core/V1Protocol/CompositeLifecycle.cs index c1697455b..9dc7628e6 100644 --- a/src/Reactor/Core/V1Protocol/CompositeLifecycle.cs +++ b/src/Reactor/Core/V1Protocol/CompositeLifecycle.cs @@ -416,11 +416,8 @@ private static void EvaluateRuleForBinding(ValidationRuleElement rule, Validatio { CancelPendingRule(binding); - // The rule may have just stopped being async on this same placeholder. Its - // generation entry has to go, or the next value change would treat this - // producer as async and retract the synchronous verdict written below. - valCtx.ClearAsyncGeneration(rule.Field, binding.Producer); - + // 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; } diff --git a/tests/Reactor.Tests/ValidationRenderScopeTests.cs b/tests/Reactor.Tests/ValidationRenderScopeTests.cs index b68acd6e5..580cbfb09 100644 --- a/tests/Reactor.Tests/ValidationRenderScopeTests.cs +++ b/tests/Reactor.Tests/ValidationRenderScopeTests.cs @@ -1549,6 +1549,27 @@ await ValidationReconciler.ValidateFieldAsync( 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 A_Value_Change_Leaves_Sync_Messages_For_The_New_Value_Intact() { From 70c921b3152282ac85fcfe03c1cdb60c79ef7a47 Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Thu, 24 Sep 2026 01:20:04 -0700 Subject: [PATCH 30/67] Scope rule-set ownership to an explicit set id EvaluateRules gained per-context set ownership in this PR, which made it globally exclusive: a second caller evaluating a different rule set retired every producer absent from its list, so two unrelated callers sharing a context silently lost each other's errors. Before this PR the method simply evaluated each rule, so this was a regression on a public API. The params overloads are back to that behaviour -- each rule owns its own slot, keyed by field and predicate call site, independent of every other caller. New overloads taking a setId provide the destructive semantics where they are wanted: the call owns that named set, and a rule that disappears from it has its message withdrawn. Generations, the commit transaction and the commit lock are all scoped per set id. Directly evaluated rules now register their field, which the mounted and set paths already did, so MarkAllTouched() covers a rule-only field however the rule was run. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 373c7212-34f6-4ddf-8ae9-59553c77cf48 --- CHANGELOG.md | 9 +- .../reactor-dsl/references/reactor.api.txt | 2 + skills/reactor.api.txt | 2 + .../Validation/ValidationReconciler.cs | 158 +++++++++++------- .../Controls/Validation/ValidationRule.cs | 2 + .../ValidationRenderScopeTests.cs | 85 ++++++---- 6 files changed, 166 insertions(+), 92 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 77b4ed961..8b5165a20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,11 @@ Conventions for contributors: `EvaluateRules`, for rule sets containing rules built with `ValidationRuleAsync`. Rules run in order, so the resulting message order matches the order given (issue #1262). +- `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 + (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 @@ -124,7 +129,9 @@ Conventions for contributors: with is now silent (issue #1262). - `ValidationReconciler.ValidateFieldAsync` and the rule batch paths did not register the fields they validated, so `MarkAllTouched()` and the validity summary skipped a - field validated only through them (issue #1262). + field validated only through them. Directly evaluated rules + (`rule.Evaluate(ctx)` / `rule.EvaluateAsync(ctx)`) register their field too + (issue #1262). - A mounted rule swapped from `ValidationRuleAsync` to `ValidationRule` kept its async generation entry, so the next value change treated it as async and retracted a synchronous verdict that was still current (issue #1262). diff --git a/plugins/reactor/skills/reactor-dsl/references/reactor.api.txt b/plugins/reactor/skills/reactor-dsl/references/reactor.api.txt index 51b6f7276..d58423f17 100644 --- a/plugins/reactor/skills/reactor-dsl/references/reactor.api.txt +++ b/plugins/reactor/skills/reactor-dsl/references/reactor.api.txt @@ -6143,7 +6143,9 @@ ToString() → string ### static class ValidationReconciler EvaluateRules(ValidationContext ctx, ValidationRuleElement[] rules) → void +EvaluateRules(ValidationContext ctx, string setId, ValidationRuleElement[] rules) → void EvaluateRulesAsync(ValidationContext ctx, 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/skills/reactor.api.txt b/skills/reactor.api.txt index 51b6f7276..d58423f17 100644 --- a/skills/reactor.api.txt +++ b/skills/reactor.api.txt @@ -6143,7 +6143,9 @@ ToString() → string ### static class ValidationReconciler EvaluateRules(ValidationContext ctx, ValidationRuleElement[] rules) → void +EvaluateRules(ValidationContext ctx, string setId, ValidationRuleElement[] rules) → void EvaluateRulesAsync(ValidationContext ctx, 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/ValidationReconciler.cs b/src/Reactor/Controls/Validation/ValidationReconciler.cs index c3ec19a11..5edc7fe5f 100644 --- a/src/Reactor/Controls/Validation/ValidationReconciler.cs +++ b/src/Reactor/Controls/Validation/ValidationReconciler.cs @@ -84,73 +84,110 @@ public static async Task ValidateFieldAsync( } /// - /// Evaluates a complete set of ValidationRuleElements and pushes results to the - /// context. + /// Evaluates ValidationRuleElements and pushes their results to the context. /// - /// Each rule is keyed by its position, so re-running the same set replaces each - /// rule's previous verdict rather than accumulating, and two rules on one field stay - /// independent even if they carry the same message. + /// 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. /// /// - /// The call owns the whole set for that context: any producer from the previous call - /// that is absent this time — because the list shrank, or a rule moved to another - /// field — has its contribution withdrawn. Without that, a removed rule's message - /// would keep the form invalid forever. Mount rules into the element tree instead if - /// you need several independent rule sets against one context. + /// 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) { - var generation = NextRuleSetGeneration(ctx); + foreach (var rule in rules) + rule.Evaluate(ctx); + } + + /// + /// 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 = NextRuleSetGeneration(ctx, 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. 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, RuleProducer(i), rules[i].ComputeSync())); + verdicts.Add((rules[i].Field, RuleProducer(setId, i), rules[i].ComputeSync())); - CommitRuleSet(ctx, generation, verdicts); + CommitRuleSet(ctx, setId, generation, verdicts); } /// - /// The asynchronous counterpart to , for rule sets that - /// contain rules built by ValidationRuleAsync. Synchronous rules in the set - /// are evaluated normally. + /// 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. /// /// - /// Ownership works exactly as in the synchronous overload: each rule is keyed by its - /// position, and a producer from the previous call that is absent this time has its - /// contribution withdrawn. + /// 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 async Task EvaluateRulesAsync( + ValidationContext ctx, + params ValidationRuleElement[] rules) + { + foreach (var rule in rules) + await rule.EvaluateAsync(ctx); + } + + /// + /// The asynchronous counterpart to + /// . /// - /// Every verdict is computed before any of them is installed, and nothing is - /// installed if another batch — synchronous or asynchronous — has been requested - /// against this context in the meantime. Both APIs advance one generation counter, so - /// an overtaken batch stands down with nothing to unwind. No lock is held across a - /// caller's predicate, so a predicate that never completes cannot block later batches. + /// Every verdict is computed before any is installed, and nothing is installed if + /// another call has been made for the same in the meantime. + /// Both overloads of the named form advance one generation per set, so an overtaken + /// call stands down with nothing to unwind. No lock is held across a caller's + /// predicate, so one that never completes cannot block later evaluations. /// /// public static async Task EvaluateRulesAsync( ValidationContext ctx, + string setId, params ValidationRuleElement[] rules) { - var generation = NextRuleSetGeneration(ctx); + var generation = NextRuleSetGeneration(ctx, setId); 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, RuleProducer(i), await rules[i].ComputeAsync())); + verdicts.Add((rules[i].Field, RuleProducer(setId, i), await rules[i].ComputeAsync())); - CommitRuleSet(ctx, generation, verdicts); + CommitRuleSet(ctx, setId, generation, verdicts); } - private static string RuleProducer(int index) => - "rules[" + index.ToString(global::System.Globalization.CultureInfo.InvariantCulture) + "]"; + private static string RuleProducer(string setId, int index) => + "ruleset:" + setId + "[" + index.ToString(global::System.Globalization.CultureInfo.InvariantCulture) + "]"; /// /// Installs a computed rule set, if this call still owns it. @@ -162,19 +199,15 @@ private static string RuleProducer(int index) => /// on installing the rest of a set it no longer owned. /// /// - /// The recorded set is updated before the transaction, so a re-entrant evaluation - /// triggered by its notification sees consistent bookkeeping. - /// - /// - /// No per-producer async generation is involved: a batch computes every verdict - /// before installing any of them and orders itself by its own generation, so - /// ComputeAsync deliberately never opens one. That is what keeps a batch - /// producer that switches from asynchronous to synchronous from leaving a stale - /// token behind for the next value change to act on. + /// No per-producer async generation is involved: a set computes every verdict before + /// installing any of them and orders itself by its own generation, so + /// ComputeAsync deliberately never opens one. That is what keeps a set producer + /// that switches from asynchronous to synchronous from leaving a stale token behind + /// for the next value change to act on. /// /// private static void CommitRuleSet( - ValidationContext ctx, int generation, + ValidationContext ctx, string setId, int generation, List<(string Field, string Producer, List Messages)> verdicts) { // The generation check, the recorded-set swap and the context transaction are one @@ -182,42 +215,47 @@ private static void CommitRuleSet( // them. Nothing inside runs caller code, so this cannot be blocked by a predicate. lock (_ruleSetCommitLocks.GetValue(ctx, static _ => new object())) { - if (!IsNewestRuleSet(ctx, generation)) return; + if (!IsNewestRuleSet(ctx, setId, generation)) return; var applied = new List<(string Field, string Producer)>(verdicts.Count); foreach (var verdict in verdicts) applied.Add((verdict.Field, verdict.Producer)); + var sets = _ruleSets.GetValue(ctx, static _ => new Dictionary>(StringComparer.Ordinal)); + var retired = new List<(string Field, string Producer)>(); - if (_ruleSets.TryGetValue(ctx, out var previous)) - { + if (sets.TryGetValue(setId, out var previous)) retired.AddRange(previous.Where(entry => !applied.Contains(entry))); - _ruleSets.Remove(ctx); - } - _ruleSets.Add(ctx, applied); + sets[setId] = applied; ctx.ApplyRuleSet(verdicts, retired); } } - private static readonly global::System.Runtime.CompilerServices.ConditionalWeakTable _ruleSetCommitLocks = new(); - - private static int NextRuleSetGeneration(ValidationContext ctx) + private static int NextRuleSetGeneration(ValidationContext ctx, string setId) { - var ticket = _ruleSetTickets.GetValue(ctx, static _ => new global::System.Runtime.CompilerServices.StrongBox(0)); - return global::System.Threading.Interlocked.Increment(ref ticket.Value); + var tickets = _ruleSetTickets.GetValue(ctx, static _ => new Dictionary>(StringComparer.Ordinal)); + lock (tickets) + { + if (!tickets.TryGetValue(setId, out var box)) + tickets[setId] = box = new global::System.Runtime.CompilerServices.StrongBox(0); + return global::System.Threading.Interlocked.Increment(ref box.Value); + } } - private static bool IsNewestRuleSet(ValidationContext ctx, int generation) + private static bool IsNewestRuleSet(ValidationContext ctx, string setId, int generation) { - var ticket = _ruleSetTickets.GetValue(ctx, static _ => new global::System.Runtime.CompilerServices.StrongBox(0)); - return global::System.Threading.Volatile.Read(ref ticket.Value) == generation; + var tickets = _ruleSetTickets.GetValue(ctx, static _ => new Dictionary>(StringComparer.Ordinal)); + lock (tickets) + { + return tickets.TryGetValue(setId, out var box) + && global::System.Threading.Volatile.Read(ref box.Value) == generation; + } } - // Names the most recently requested rule set for a context, so an overtaken batch can - // stand down. Weak on the context so it holds nothing alive. - private static readonly global::System.Runtime.CompilerServices.ConditionalWeakTable> _ruleSetTickets = new(); - // The rule set most recently evaluated against each context, so the next call can - // withdraw whatever disappeared. Weak on the context so it holds nothing alive. - private static readonly global::System.Runtime.CompilerServices.ConditionalWeakTable> _ruleSets = new(); -} + // Per-context, per-set bookkeeping: the last recorded membership of each named set, + // the generation naming its most recent call, and the lock that makes a commit + // atomic. All weak on the context so none of them holds it alive. + private static readonly global::System.Runtime.CompilerServices.ConditionalWeakTable>> _ruleSets = new(); + private static readonly global::System.Runtime.CompilerServices.ConditionalWeakTable>> _ruleSetTickets = new(); + private static readonly global::System.Runtime.CompilerServices.ConditionalWeakTable _ruleSetCommitLocks = new();} diff --git a/src/Reactor/Controls/Validation/ValidationRule.cs b/src/Reactor/Controls/Validation/ValidationRule.cs index 41a5d9982..6b48752e2 100644 --- a/src/Reactor/Controls/Validation/ValidationRule.cs +++ b/src/Reactor/Controls/Validation/ValidationRule.cs @@ -95,6 +95,7 @@ public static void Evaluate(this ValidationRuleElement rule, ValidationContext c /// internal static void Evaluate(this ValidationRuleElement rule, ValidationContext ctx, string producer) { + ctx.RegisterField(rule.Field); ctx.ClearAsyncGeneration(rule.Field, producer); ctx.ApplyOwned(rule.Field, producer, rule.ComputeSync()); } @@ -127,6 +128,7 @@ internal static async Task EvaluateAsync(this ValidationRuleElement rule, Valida return; } + ctx.RegisterField(rule.Field); var generation = ctx.BeginAsyncProducer(rule.Field, producer); var result = await rule.AsyncPredicate(); diff --git a/tests/Reactor.Tests/ValidationRenderScopeTests.cs b/tests/Reactor.Tests/ValidationRenderScopeTests.cs index 580cbfb09..195010ed3 100644 --- a/tests/Reactor.Tests/ValidationRenderScopeTests.cs +++ b/tests/Reactor.Tests/ValidationRenderScopeTests.cs @@ -447,14 +447,14 @@ public void A_Shrinking_Rule_Set_Withdraws_The_Rules_That_Disappeared() { var ctx = new ValidationContext(); - ValidationReconciler.EvaluateRules(ctx, + 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, + ValidationReconciler.EvaluateRules(ctx, "form-rules", ValidationRule(() => false, "First rule failed", "form")); var texts = ctx.GetMessages("form").Select(m => m.Text).ToList(); @@ -467,11 +467,11 @@ public void An_Emptied_Rule_Set_Leaves_The_Context_Valid() { var ctx = new ValidationContext(); - ValidationReconciler.EvaluateRules(ctx, + ValidationReconciler.EvaluateRules(ctx, "form-rules", ValidationRule(() => false, "Rule failed", "form")); Assert.False(ctx.IsValid()); - ValidationReconciler.EvaluateRules(ctx); + ValidationReconciler.EvaluateRules(ctx, "form-rules"); Assert.True(ctx.IsValid()); Assert.Empty(ctx.GetMessages("form")); @@ -482,11 +482,11 @@ public void A_Rule_That_Moves_Field_Withdraws_From_The_Old_One() { var ctx = new ValidationContext(); - ValidationReconciler.EvaluateRules(ctx, + ValidationReconciler.EvaluateRules(ctx, "range-rules", ValidationRule(() => false, "Rule failed", "start")); Assert.Single(ctx.GetMessages("start")); - ValidationReconciler.EvaluateRules(ctx, + ValidationReconciler.EvaluateRules(ctx, "range-rules", ValidationRule(() => false, "Rule failed", "end")); Assert.Empty(ctx.GetMessages("start")); @@ -499,12 +499,12 @@ 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, + 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); + ValidationReconciler.EvaluateRules(ctx, "form-rules"); var texts = ctx.GetMessages("form").Select(m => m.Text).ToList(); Assert.Single(texts); @@ -1370,25 +1370,48 @@ public async Task An_Overtaken_Async_Batch_Stands_Down_Instead_Of_Overwriting() var ctx = new ValidationContext(); var slow = new TaskCompletionSource(); - // Older batch: blocks on field "a". + // Older call for this set: blocks on field "a". var older = ValidationReconciler.EvaluateRulesAsync( - ctx, ValidationRuleAsync(() => slow.Task, "Stale verdict", "a")); + ctx, "range-rules", ValidationRuleAsync(() => slow.Task, "Stale verdict", "a")); - // Newer batch, requested while the older one is still out. + // Newer call for the same set, made while the older one is still out. var newer = ValidationReconciler.EvaluateRulesAsync( - ctx, ValidationRuleAsync(() => Task.FromResult(false), "Current verdict", "b")); + ctx, "range-rules", ValidationRuleAsync(() => Task.FromResult(false), "Current verdict", "b")); slow.SetResult(false); await older; await newer; - // The older batch stood down: it neither installed its own verdict nor retired - // the newer batch's producer. + // 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() { @@ -1417,18 +1440,18 @@ public void Two_Producers_With_Identical_Messages_Stay_Independent() } [Fact] - public async Task A_Sync_Batch_Overtakes_A_Running_Async_Batch() + 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, ValidationRuleAsync(() => slow.Task, "Stale verdict", "a")); + ctx, "range-rules", ValidationRuleAsync(() => slow.Task, "Stale verdict", "a")); - // A synchronous batch requested while the async one is still out. It advances - // the same generation counter, so the async batch must stand down. + // 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, ValidationRule(() => false, "Current verdict", "b")); + ctx, "range-rules", ValidationRule(() => false, "Current verdict", "b")); slow.SetResult(false); await older; @@ -1446,28 +1469,28 @@ public async Task A_Never_Completing_Rule_Does_Not_Block_Later_Batches() // Deliberately never completed: a caller's predicate can hang. var stuck = ValidationReconciler.EvaluateRulesAsync( - ctx, ValidationRuleAsync(() => never.Task, "Never resolves", "a")); + ctx, "range-rules", ValidationRuleAsync(() => never.Task, "Never resolves", "a")); - // A later batch has to make progress regardless. + // A later evaluation has to make progress regardless. await ValidationReconciler.EvaluateRulesAsync( - ctx, ValidationRuleAsync(() => Task.FromResult(false), "Current verdict", "b")); + ctx, "other-rules", ValidationRuleAsync(() => Task.FromResult(false), "Current verdict", "b")); Assert.Single(ctx.GetMessages("b")); Assert.False(stuck.IsCompleted); } [Fact] - public async Task A_Batch_Producer_That_Turns_Synchronous_Keeps_Its_Verdict() + public async Task A_Set_Producer_That_Turns_Synchronous_Keeps_Its_Verdict() { var ctx = new ValidationContext(); await ValidationReconciler.EvaluateRulesAsync( - ctx, ValidationRuleAsync(() => Task.FromResult(false), "Rule failed", "name")); + ctx, "name-rules", ValidationRuleAsync(() => Task.FromResult(false), "Rule failed", "name")); Assert.Single(ctx.GetMessages("name")); - // Same position, now a synchronous rule — so the same producer key. + // Same position in the same set, now a synchronous rule — so the same producer. await ValidationReconciler.EvaluateRulesAsync( - ctx, ValidationRule(() => false, "Rule failed", "name")); + 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. @@ -1487,21 +1510,21 @@ public void A_Rule_Set_Commits_Atomically_Against_A_Reentrant_Evaluation() if (reentered) return; reentered = true; - // A subscriber woken mid-commit starts its own evaluation. The outer call + // 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, ValidationRule(() => true, "Inner rule", "a")); + ctx, "form-rules", ValidationRule(() => true, "Inner rule", "a")); }; ValidationReconciler.EvaluateRules( - ctx, + ctx, "form-rules", ValidationRule(() => false, "Outer first", "a"), ValidationRule(() => false, "Outer second", "b")); Assert.True(reentered); - // The inner set is the newest, and it owns the whole context: nothing from the - // outer set may be left orphaned. + // 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()); From ac80c02f74324254efe9e07bdf2ac09fdc171d85 Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Thu, 24 Sep 2026 01:57:57 -0700 Subject: [PATCH 31/67] Release hung async rules; key direct rules by position Two review findings. A mounted async rule that never resolved could not be released: the predicate takes no cancellation token, and EvaluateAsync awaited it before checking the token, so cancelling on update or unmount left a suspended task retaining the rule binding and its ValidationContext -- with another accumulating on every re-render. The await now goes through WaitAsync(cancellationToken), so cancellation releases the evaluation immediately even though the caller's task keeps running. FallbackProducerKey was method-only, so two rules sharing a predicate on one field -- ValidationRule(IsRangeValid, "A", "dates") and ValidationRule(IsRangeValid, "B", "dates") -- collapsed into one slot and retracted each other. Identity now includes the rule's position in the call. AsyncValidationRule_Respects_Cancellation now accepts any OperationCanceledException rather than the exact base type, since WaitAsync reports cancellation as TaskCanceledException. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 373c7212-34f6-4ddf-8ae9-59553c77cf48 --- CHANGELOG.md | 7 +++ .../Validation/ValidationReconciler.cs | 8 +-- .../Controls/Validation/ValidationRule.cs | 50 +++++++++------ .../ValidationRenderScopeTests.cs | 61 +++++++++++++++++++ tests/Reactor.Tests/ValidationRuleTests.cs | 4 +- 5 files changed, 108 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b5165a20..7f538f482 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -139,6 +139,13 @@ Conventions for contributors: `EvaluateRules(...)` — recorded a passing verdict without ever running its predicate, reporting an invalid field as valid. Those paths now throw, and `EvaluateRulesAsync` is the batch path that runs them (issue #1262). +- A mounted async `ValidationRule` whose predicate never completed was never released: + the predicate takes no cancellation token, so cancelling on update or unmount left a + suspended task holding the rule binding and its `ValidationContext`, with another + added on every re-render. The await is now cancellation-aware (issue #1262). +- Two directly-evaluated rules sharing a predicate method on one field collapsed into + a single slot and retracted each other. A rule's identity now includes its position + in the call (issue #1262). - An async `ValidationRule` placed in the element tree never ran its predicate. `ValidationRuleAsync` builds an element whose synchronous predicate is a constant `true`, and the mount/update lifecycle evaluated that, so the rule diff --git a/src/Reactor/Controls/Validation/ValidationReconciler.cs b/src/Reactor/Controls/Validation/ValidationReconciler.cs index 5edc7fe5f..7bc6c9ced 100644 --- a/src/Reactor/Controls/Validation/ValidationReconciler.cs +++ b/src/Reactor/Controls/Validation/ValidationReconciler.cs @@ -103,8 +103,8 @@ public static void EvaluateRules( ValidationContext ctx, params ValidationRuleElement[] rules) { - foreach (var rule in rules) - rule.Evaluate(ctx); + for (var i = 0; i < rules.Length; i++) + rules[i].Evaluate(ctx, ValidationRuleDsl.DirectProducerKey(rules[i], i)); } /// @@ -157,8 +157,8 @@ public static async Task EvaluateRulesAsync( ValidationContext ctx, params ValidationRuleElement[] rules) { - foreach (var rule in rules) - await rule.EvaluateAsync(ctx); + for (var i = 0; i < rules.Length; i++) + await rules[i].EvaluateAsync(ctx, ValidationRuleDsl.DirectProducerKey(rules[i], i)); } /// diff --git a/src/Reactor/Controls/Validation/ValidationRule.cs b/src/Reactor/Controls/Validation/ValidationRule.cs index 6b48752e2..2777b52a3 100644 --- a/src/Reactor/Controls/Validation/ValidationRule.cs +++ b/src/Reactor/Controls/Validation/ValidationRule.cs @@ -131,8 +131,11 @@ internal static async Task EvaluateAsync(this ValidationRuleElement rule, Valida ctx.RegisterField(rule.Field); var generation = ctx.BeginAsyncProducer(rule.Field, producer); - var result = await rule.AsyncPredicate(); - cancellationToken.ThrowIfCancellationRequested(); + // 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)); } @@ -155,35 +158,48 @@ internal static async Task> ComputeAsync( { if (rule.AsyncPredicate is null) return BuildMessages(rule, rule.Predicate()); - var result = await rule.AsyncPredicate(); - cancellationToken.ThrowIfCancellationRequested(); + // 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); } /// - /// Identity of last resort for a rule evaluated outside the reconciler, where there - /// is no mounted instance to key on. + /// 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. /// - /// Derived from the field plus the predicate's *method* — for a lambda, the - /// compiler-generated method for that call site — so the same line of code owns the - /// same slot on every evaluation. 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. + /// 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. /// /// - /// Two rules built at the same call site for the same field still share a slot and - /// overwrite each other. Mounting them through the element tree gives each a real - /// per-instance identity; prefer that over calling Evaluate in a loop. + /// The position disambiguates rules that share a predicate — two + /// ValidationRule(IsRangeValid, …) on one field would otherwise collapse into + /// one slot and retract each other. Two rules built at the same call site *and* + /// passed in the same position across calls still share a slot; mounting them in the + /// element tree gives each a real per-instance identity, which is what to reach for + /// when rules are generated in a loop. /// /// - internal static string FallbackProducerKey(ValidationRuleElement rule) + 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}"; + return $"rule:{rule.Field}:{method.DeclaringType?.FullName}.{method.Name}#{position.ToString(global::System.Globalization.CultureInfo.InvariantCulture)}"; } private static List BuildMessages(ValidationRuleElement rule, bool passed) => diff --git a/tests/Reactor.Tests/ValidationRenderScopeTests.cs b/tests/Reactor.Tests/ValidationRenderScopeTests.cs index 195010ed3..ad9a561e0 100644 --- a/tests/Reactor.Tests/ValidationRenderScopeTests.cs +++ b/tests/Reactor.Tests/ValidationRenderScopeTests.cs @@ -1593,6 +1593,67 @@ public async Task A_Sync_Evaluation_Retires_An_In_Flight_Async_Pass_For_The_Same 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 void A_Value_Change_Leaves_Sync_Messages_For_The_New_Value_Intact() { 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)); } From 83da873fa8b8518bc1e445ffe6ab94888de28a38 Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Thu, 24 Sep 2026 02:15:56 -0700 Subject: [PATCH 32/67] Add cancellation to the batch APIs; clear pass slots atomically Two review findings. EvaluateRulesAsync exposed no CancellationToken, so a never-completing predicate could hang either overload and retain the ValidationContext with no framework-level way out -- the same leak just fixed for mounted rules. Both overloads now have a cancellation-aware form that threads the token into ComputeAsync/EvaluateAsync. A cancelled named set installs nothing, because verdicts are computed before any is committed. The completion path cleared binding.Pending with check-then-assign, so an old pass could observe itself as current, an update could install a newer source, and the old pass would then null out the new one -- leaving the new pass uncancellable. Both the completion and cancellation paths now use interlocked operations. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 373c7212-34f6-4ddf-8ae9-59553c77cf48 --- .../reactor-dsl/references/reactor.api.txt | 2 + skills/reactor.api.txt | 2 + .../Validation/ValidationReconciler.cs | 28 ++++++++++++- .../Core/V1Protocol/CompositeLifecycle.cs | 8 ++-- .../ValidationRenderScopeTests.cs | 39 +++++++++++++++++++ 5 files changed, 74 insertions(+), 5 deletions(-) diff --git a/plugins/reactor/skills/reactor-dsl/references/reactor.api.txt b/plugins/reactor/skills/reactor-dsl/references/reactor.api.txt index d58423f17..55ced324c 100644 --- a/plugins/reactor/skills/reactor-dsl/references/reactor.api.txt +++ b/plugins/reactor/skills/reactor-dsl/references/reactor.api.txt @@ -6144,7 +6144,9 @@ 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 diff --git a/skills/reactor.api.txt b/skills/reactor.api.txt index d58423f17..55ced324c 100644 --- a/skills/reactor.api.txt +++ b/skills/reactor.api.txt @@ -6144,7 +6144,9 @@ 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 diff --git a/src/Reactor/Controls/Validation/ValidationReconciler.cs b/src/Reactor/Controls/Validation/ValidationReconciler.cs index 7bc6c9ced..3e18148ec 100644 --- a/src/Reactor/Controls/Validation/ValidationReconciler.cs +++ b/src/Reactor/Controls/Validation/ValidationReconciler.cs @@ -153,12 +153,24 @@ public static void EvaluateRules( /// 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)); + await rules[i].EvaluateAsync(ctx, ValidationRuleDsl.DirectProducerKey(rules[i], i), cancellationToken); } /// @@ -172,16 +184,28 @@ public static async Task EvaluateRulesAsync( /// predicate, so one that never completes cannot block later evaluations. /// /// + 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) { var generation = NextRuleSetGeneration(ctx, setId); 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, RuleProducer(setId, i), await rules[i].ComputeAsync())); + verdicts.Add((rules[i].Field, RuleProducer(setId, i), await rules[i].ComputeAsync(cancellationToken))); CommitRuleSet(ctx, setId, generation, verdicts); } diff --git a/src/Reactor/Core/V1Protocol/CompositeLifecycle.cs b/src/Reactor/Core/V1Protocol/CompositeLifecycle.cs index 9dc7628e6..4111f458e 100644 --- a/src/Reactor/Core/V1Protocol/CompositeLifecycle.cs +++ b/src/Reactor/Core/V1Protocol/CompositeLifecycle.cs @@ -453,15 +453,17 @@ private static async Task RunAsyncRuleAsync( Diagnostics.LogCategory.Reactor, "ValidationRuleAsync.Evaluate", ex); } - if (ReferenceEquals(binding.Pending, cts)) binding.Pending = null; + // 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 = binding.Pending; + var pending = global::System.Threading.Interlocked.Exchange(ref binding.Pending, null); if (pending is null) return; - binding.Pending = null; try { pending.Cancel(); diff --git a/tests/Reactor.Tests/ValidationRenderScopeTests.cs b/tests/Reactor.Tests/ValidationRenderScopeTests.cs index ad9a561e0..36d5fed50 100644 --- a/tests/Reactor.Tests/ValidationRenderScopeTests.cs +++ b/tests/Reactor.Tests/ValidationRenderScopeTests.cs @@ -1654,6 +1654,45 @@ public void External_Errors_Survive_Revalidating_The_Same_Value() 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 A_Value_Change_Leaves_Sync_Messages_For_The_New_Value_Intact() { From de71c8aa1edb16f928b298d41bc30d662543a043 Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Thu, 24 Sep 2026 02:32:15 -0700 Subject: [PATCH 33/67] Advance rule-set generations under the commit lock IsNewestRuleSet was checked inside the commit lock, but the generation counter was advanced under a different one. A concurrent call could therefore increment between another call's check and its apply, so that call committed and notified with a verdict it had already been overtaken on, instead of standing down as documented. Generation advancement, the check, the recorded-set swap and the context transaction now all take one per-context lock. Monitor is reentrant, so a notification raised by the commit that re-enters evaluation on the same thread still works -- which the re-entrancy regression covers. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 373c7212-34f6-4ddf-8ae9-59553c77cf48 --- .../Validation/ValidationReconciler.cs | 28 ++++++++++++------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/src/Reactor/Controls/Validation/ValidationReconciler.cs b/src/Reactor/Controls/Validation/ValidationReconciler.cs index 3e18148ec..f1ffe8633 100644 --- a/src/Reactor/Controls/Validation/ValidationReconciler.cs +++ b/src/Reactor/Controls/Validation/ValidationReconciler.cs @@ -235,9 +235,10 @@ private static void CommitRuleSet( List<(string Field, string Producer, List Messages)> verdicts) { // The generation check, the recorded-set swap and the context transaction are one - // critical section: checking and then acting let a concurrent commit slip between - // them. Nothing inside runs caller code, so this cannot be blocked by a predicate. - lock (_ruleSetCommitLocks.GetValue(ctx, static _ => new object())) + // critical section, and generation advancement takes the same lock — so no + // concurrent call can overtake this one between its check and its apply. Nothing + // inside runs caller code, so this cannot be blocked by a predicate. + lock (RuleSetLock(ctx)) { if (!IsNewestRuleSet(ctx, setId, generation)) return; @@ -258,25 +259,32 @@ private static void CommitRuleSet( private static int NextRuleSetGeneration(ValidationContext ctx, string setId) { - var tickets = _ruleSetTickets.GetValue(ctx, static _ => new Dictionary>(StringComparer.Ordinal)); - lock (tickets) + // Advancing the generation takes the same lock as the commit, so a concurrent + // call cannot slip an increment between another call's check and its apply — + // which would let that call commit a verdict it had already been overtaken on. + // Monitor is reentrant, so a commit-triggered notification that re-enters here + // on the same thread is fine. + lock (RuleSetLock(ctx)) { + var tickets = _ruleSetTickets.GetValue(ctx, static _ => new Dictionary>(StringComparer.Ordinal)); if (!tickets.TryGetValue(setId, out var box)) tickets[setId] = box = new global::System.Runtime.CompilerServices.StrongBox(0); - return global::System.Threading.Interlocked.Increment(ref box.Value); + return ++box.Value; } } private static bool IsNewestRuleSet(ValidationContext ctx, string setId, int generation) { - var tickets = _ruleSetTickets.GetValue(ctx, static _ => new Dictionary>(StringComparer.Ordinal)); - lock (tickets) + lock (RuleSetLock(ctx)) { - return tickets.TryGetValue(setId, out var box) - && global::System.Threading.Volatile.Read(ref box.Value) == generation; + var tickets = _ruleSetTickets.GetValue(ctx, static _ => new Dictionary>(StringComparer.Ordinal)); + return tickets.TryGetValue(setId, out var box) && box.Value == generation; } } + private static object RuleSetLock(ValidationContext ctx) => + _ruleSetCommitLocks.GetValue(ctx, static _ => new object()); + // Per-context, per-set bookkeeping: the last recorded membership of each named set, // the generation naming its most recent call, and the lock that makes a commit // atomic. All weak on the context so none of them holds it alive. From f0b17917acd9fc9863f3d69a46c20eb81c0e90a7 Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Thu, 24 Sep 2026 02:51:38 -0700 Subject: [PATCH 34/67] Store validation lifecycle bindings on native attached state Mounted-rule, blur and FormField-root bindings were held in ConditionalWeakTables keyed by the managed UIElement. WinRT can project two RCWs over one native DependencyObject, so a lookup landing on the other wrapper would mint a fresh producer while the old one could never be retired, leaving a stale validation message behind. That is the hazard ChangeEchoSuppressor already documents for EchoSuppressCount (issues #86/#114); these now use the same ReactorAttached.StateProperty storage. Also documents precisely what a direct rule key can and cannot separate. The predicate's method is the only caller-derived component available -- C# cannot supply [CallerFilePath] after a params array, and stack walking is neither cheap nor trimming-safe -- so two callers passing the same named method for the same field and position share a slot. That is what the setId overloads are for, and the behaviour both ways is now pinned by a test. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 373c7212-34f6-4ddf-8ae9-59553c77cf48 --- .../Validation/ValidationReconciler.cs | 6 +++ .../Controls/Validation/ValidationRule.cs | 16 +++++-- src/Reactor/Core/Reconciler.cs | 12 +++++ .../Core/V1Protocol/CompositeLifecycle.cs | 48 +++++++++++++------ .../ValidationRenderScopeTests.cs | 26 ++++++++++ 5 files changed, 89 insertions(+), 19 deletions(-) diff --git a/src/Reactor/Controls/Validation/ValidationReconciler.cs b/src/Reactor/Controls/Validation/ValidationReconciler.cs index f1ffe8633..348e57811 100644 --- a/src/Reactor/Controls/Validation/ValidationReconciler.cs +++ b/src/Reactor/Controls/Validation/ValidationReconciler.cs @@ -92,6 +92,12 @@ public static async Task ValidateFieldAsync( /// 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 /// diff --git a/src/Reactor/Controls/Validation/ValidationRule.cs b/src/Reactor/Controls/Validation/ValidationRule.cs index 2777b52a3..1accb3550 100644 --- a/src/Reactor/Controls/Validation/ValidationRule.cs +++ b/src/Reactor/Controls/Validation/ValidationRule.cs @@ -187,10 +187,18 @@ internal static async Task> ComputeAsync( /// /// The position disambiguates rules that share a predicate — two /// ValidationRule(IsRangeValid, …) on one field would otherwise collapse into - /// one slot and retract each other. Two rules built at the same call site *and* - /// passed in the same position across calls still share a slot; mounting them in the - /// element tree gives each a real per-instance identity, which is what to reach for - /// when rules are generated in a loop. + /// 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) diff --git a/src/Reactor/Core/Reconciler.cs b/src/Reactor/Core/Reconciler.cs index 313e44a98..2e702ab31 100644 --- a/src/Reactor/Core/Reconciler.cs +++ b/src/Reactor/Core/Reconciler.cs @@ -413,6 +413,18 @@ 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; // 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 diff --git a/src/Reactor/Core/V1Protocol/CompositeLifecycle.cs b/src/Reactor/Core/V1Protocol/CompositeLifecycle.cs index 4111f458e..ad33940a5 100644 --- a/src/Reactor/Core/V1Protocol/CompositeLifecycle.cs +++ b/src/Reactor/Core/V1Protocol/CompositeLifecycle.cs @@ -373,7 +373,7 @@ internal static UIElement MountValidationRule(Reconciler reconciler, ValidationR /// internal static void RetractValidationRule(UIElement placeholder) { - if (!_ruleBindings.TryGetValue(placeholder, out var binding)) return; + 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. @@ -478,11 +478,18 @@ private static void CancelPendingRule(RuleBinding binding) } private static long s_ruleProducerSeed; - private static readonly global::System.Runtime.CompilerServices.ConditionalWeakTable _ruleBindings = new(); + // 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 (_ruleBindings.TryGetValue(placeholder, out var existing)) return existing; + if (ReadRuleBinding(placeholder) is { } existing) return existing; var binding = new RuleBinding { @@ -490,7 +497,8 @@ private static RuleBinding GetOrCreateRuleBinding(UIElement placeholder) .Increment(ref s_ruleProducerSeed) .ToString(global::System.Globalization.CultureInfo.InvariantCulture), }; - _ruleBindings.Add(placeholder, binding); + if (placeholder is FrameworkElement fe) + Reconciler.GetOrCreateReactorState(fe).ValidationRuleBinding = binding; return binding; } @@ -535,7 +543,7 @@ private static void WireTouchedOnBlur(UIElement formFieldRoot, UIElement content return; } - if (_touchBindings.TryGetValue(fe, out var existing)) + if (Reconciler.GetOrCreateReactorState(fe).ValidationTouchBinding is TouchBinding existing) { existing.Context = valCtx; existing.FieldName = fieldName; @@ -544,7 +552,7 @@ private static void WireTouchedOnBlur(UIElement formFieldRoot, UIElement content } var binding = new TouchBinding { Context = valCtx, FieldName = fieldName }; - _touchBindings.Add(fe, binding); + Reconciler.GetOrCreateReactorState(fe).ValidationTouchBinding = binding; ReplaceRootBinding(formFieldRoot, binding); fe.LostFocus += (_, _) => { @@ -565,16 +573,16 @@ private static void WireTouchedOnBlur(UIElement formFieldRoot, UIElement content /// private static void ReplaceRootBinding(UIElement formFieldRoot, TouchBinding binding) { - if (_rootBindings.TryGetValue(formFieldRoot, out var previous)) + if (ReadRootBinding(formFieldRoot) is { } previous) { if (ReferenceEquals(previous, binding)) return; previous.Context = null; previous.FieldName = null; - _rootBindings.Remove(formFieldRoot); + WriteRootBinding(formFieldRoot, null); } - _rootBindings.Add(formFieldRoot, binding); + WriteRootBinding(formFieldRoot, binding); } /// @@ -594,7 +602,7 @@ 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 (_rootBindings.TryGetValue(formFieldRoot, out var binding)) + if (ReadRootBinding(formFieldRoot) is { } binding) { binding.Context = null; binding.FieldName = null; @@ -603,7 +611,8 @@ internal static void ClearFormFieldTouchBinding(UIElement formFieldRoot) private static void ClearTouchBinding(UIElement contentControl) { - if (contentControl is FrameworkElement fe && _touchBindings.TryGetValue(fe, out var binding)) + if (contentControl is FrameworkElement fe + && Reconciler.GetOrCreateReactorState(fe).ValidationTouchBinding is TouchBinding binding) { binding.Context = null; binding.FieldName = null; @@ -617,7 +626,7 @@ private static void ClearTouchBinding(UIElement contentControl) private static void ReleaseRootBinding(UIElement formFieldRoot) { ClearFormFieldTouchBinding(formFieldRoot); - _rootBindings.Remove(formFieldRoot); + WriteRootBinding(formFieldRoot, null); } // Test-only accessor (InternalsVisibleTo Reactor.Tests / Reactor.AppTests.Host): @@ -627,7 +636,7 @@ private static void ReleaseRootBinding(UIElement formFieldRoot) // which is not deterministic enough to assert on. internal static bool HasLiveTouchBindingForTests(UIElement contentControl) => contentControl is FrameworkElement fe - && _touchBindings.TryGetValue(fe, out var binding) + && Reconciler.GetOrCreateReactorState(fe).ValidationTouchBinding is TouchBinding binding && binding.Context is not null && !string.IsNullOrEmpty(binding.FieldName); @@ -637,11 +646,20 @@ private sealed class TouchBinding internal string? FieldName; } - private static readonly global::System.Runtime.CompilerServices.ConditionalWeakTable _touchBindings = new(); + 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 readonly global::System.Runtime.CompilerServices.ConditionalWeakTable _rootBindings = new(); + private static void ApplyFormFieldAutomation(UIElement contentControl, string? label) { diff --git a/tests/Reactor.Tests/ValidationRenderScopeTests.cs b/tests/Reactor.Tests/ValidationRenderScopeTests.cs index 36d5fed50..c78a34bab 100644 --- a/tests/Reactor.Tests/ValidationRenderScopeTests.cs +++ b/tests/Reactor.Tests/ValidationRenderScopeTests.cs @@ -1693,6 +1693,32 @@ public async Task A_Hung_Named_Set_Releases_Its_Evaluation_And_Installs_Nothing( 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 void A_Value_Change_Leaves_Sync_Messages_For_The_New_Value_Intact() { From ac5020e956e10179b5e464a3ef82b077f9c8cd61 Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Thu, 24 Sep 2026 03:11:01 -0700 Subject: [PATCH 35/67] Move rule-set bookkeeping onto the context lock Two review findings, both resolved by putting the set generation and membership where the messages already live. Named async rule-set verdicts were stored under ruleset:{setId}[i] but tracked in no async generation, so a field value change could not retract them and a late result could install a verdict about a value that no longer existed. Each genuinely async rule in a set now opens a per-producer generation before its predicate runs, and those are verified at the moment of application -- if a value changed in the meantime, the whole set stands down. A synchronous rule in a set is computed at call time and cannot go stale that way, so it gets no token, and any token left from a previous async incarnation is cleared. The rule-set coordination lock was also held across ctx.ApplyRuleSet, which raises the public Changed event -- so a handler that waited on work which evaluated another set on the same context could deadlock. That lock is gone: BeginRuleSet and CommitRuleSet live on ValidationContext under its own lock, and Changed is raised after it is released, as everywhere else in the class. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 373c7212-34f6-4ddf-8ae9-59553c77cf48 --- .../Controls/Validation/ValidationContext.cs | 85 +++++++++++++ .../Validation/ValidationReconciler.cs | 118 +++++------------- .../ValidationRenderScopeTests.cs | 45 +++++++ 3 files changed, 159 insertions(+), 89 deletions(-) diff --git a/src/Reactor/Controls/Validation/ValidationContext.cs b/src/Reactor/Controls/Validation/ValidationContext.cs index 595349ddb..4d486dc78 100644 --- a/src/Reactor/Controls/Validation/ValidationContext.cs +++ b/src/Reactor/Controls/Validation/ValidationContext.cs @@ -971,6 +971,91 @@ internal void ApplyRuleSet( 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) + { + 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; + } + } + + 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) + { + if (applied.Contains(entry)) continue; + 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) + { + _registeredFields.Add(field); + if (ApplyOwnedLocked(field, producer, messages)) changed = true; + } + + if (changed) BumpVersionLocked(messagesOnly: true); + } + if (changed) RaiseChanged(messagesOnly: true); + } + + private readonly Dictionary _ruleSetTickets = new(StringComparer.Ordinal); + private readonly Dictionary> _ruleSetMembership = new(StringComparer.Ordinal); + /// /// Drops a producer's async generation entry without touching its messages, for a /// producer that has stopped being asynchronous. diff --git a/src/Reactor/Controls/Validation/ValidationReconciler.cs b/src/Reactor/Controls/Validation/ValidationReconciler.cs index 348e57811..0e59d0166 100644 --- a/src/Reactor/Controls/Validation/ValidationReconciler.cs +++ b/src/Reactor/Controls/Validation/ValidationReconciler.cs @@ -133,7 +133,7 @@ public static void EvaluateRules( string setId, params ValidationRuleElement[] rules) { - var generation = NextRuleSetGeneration(ctx, setId); + 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. @@ -141,7 +141,7 @@ public static void EvaluateRules( for (var i = 0; i < rules.Length; i++) verdicts.Add((rules[i].Field, RuleProducer(setId, i), rules[i].ComputeSync())); - CommitRuleSet(ctx, setId, generation, verdicts); + ctx.CommitRuleSet(setId, generation, verdicts, asyncTokens: null); } /// @@ -183,11 +183,10 @@ public static async Task EvaluateRulesAsync( /// The asynchronous counterpart to /// . /// - /// Every verdict is computed before any is installed, and nothing is installed if - /// another call has been made for the same in the meantime. - /// Both overloads of the named form advance one generation per set, so an overtaken - /// call stands down with nothing to unwind. No lock is held across a caller's - /// predicate, so one that never completes cannot block later evaluations. + /// 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( @@ -207,93 +206,34 @@ public static async Task EvaluateRulesAsync( CancellationToken cancellationToken, params ValidationRuleElement[] rules) { - var generation = NextRuleSetGeneration(ctx, setId); - - var verdicts = new List<(string Field, string Producer, List Messages)>(rules.Length); + 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 + // cleared, or the next value change would retract a verdict that is current. + var tokens = new List<(string Field, string Producer, int Token)>(rules.Length); + var producers = new string[rules.Length]; for (var i = 0; i < rules.Length; i++) - verdicts.Add((rules[i].Field, RuleProducer(setId, i), await rules[i].ComputeAsync(cancellationToken))); - - CommitRuleSet(ctx, setId, generation, verdicts); - } - - private static string RuleProducer(string setId, int index) => - "ruleset:" + setId + "[" + index.ToString(global::System.Globalization.CultureInfo.InvariantCulture) + "]"; - - /// - /// Installs a computed rule set, if this call still owns it. - /// - /// The generation check, every producer replacement, and the retirement of producers - /// that have disappeared are one transaction against the context, emitting a single - /// notification. Installing producer by producer was observable mid-set: a subscriber - /// woken by the first verdict could start a new evaluation, and this call would carry - /// on installing the rest of a set it no longer owned. - /// - /// - /// No per-producer async generation is involved: a set computes every verdict before - /// installing any of them and orders itself by its own generation, so - /// ComputeAsync deliberately never opens one. That is what keeps a set producer - /// that switches from asynchronous to synchronous from leaving a stale token behind - /// for the next value change to act on. - /// - /// - private static void CommitRuleSet( - ValidationContext ctx, string setId, int generation, - List<(string Field, string Producer, List Messages)> verdicts) - { - // The generation check, the recorded-set swap and the context transaction are one - // critical section, and generation advancement takes the same lock — so no - // concurrent call can overtake this one between its check and its apply. Nothing - // inside runs caller code, so this cannot be blocked by a predicate. - lock (RuleSetLock(ctx)) { - if (!IsNewestRuleSet(ctx, setId, generation)) return; - - var applied = new List<(string Field, string Producer)>(verdicts.Count); - foreach (var verdict in verdicts) - applied.Add((verdict.Field, verdict.Producer)); + var producer = producers[i] = RuleProducer(setId, i); + ctx.RegisterField(rules[i].Field); - var sets = _ruleSets.GetValue(ctx, static _ => new Dictionary>(StringComparer.Ordinal)); - - var retired = new List<(string Field, string Producer)>(); - if (sets.TryGetValue(setId, out var previous)) - retired.AddRange(previous.Where(entry => !applied.Contains(entry))); - sets[setId] = applied; - - ctx.ApplyRuleSet(verdicts, retired); + if (rules[i].AsyncPredicate is null) + ctx.ClearAsyncGeneration(rules[i].Field, producer); + else + tokens.Add((rules[i].Field, producer, ctx.BeginAsyncProducer(rules[i].Field, producer))); } - } - private static int NextRuleSetGeneration(ValidationContext ctx, string setId) - { - // Advancing the generation takes the same lock as the commit, so a concurrent - // call cannot slip an increment between another call's check and its apply — - // which would let that call commit a verdict it had already been overtaken on. - // Monitor is reentrant, so a commit-triggered notification that re-enters here - // on the same thread is fine. - lock (RuleSetLock(ctx)) - { - var tickets = _ruleSetTickets.GetValue(ctx, static _ => new Dictionary>(StringComparer.Ordinal)); - if (!tickets.TryGetValue(setId, out var box)) - tickets[setId] = box = new global::System.Runtime.CompilerServices.StrongBox(0); - return ++box.Value; - } - } + 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))); - private static bool IsNewestRuleSet(ValidationContext ctx, string setId, int generation) - { - lock (RuleSetLock(ctx)) - { - var tickets = _ruleSetTickets.GetValue(ctx, static _ => new Dictionary>(StringComparer.Ordinal)); - return tickets.TryGetValue(setId, out var box) && box.Value == generation; - } + ctx.CommitRuleSet(setId, generation, verdicts, tokens); } - private static object RuleSetLock(ValidationContext ctx) => - _ruleSetCommitLocks.GetValue(ctx, static _ => new object()); - - // Per-context, per-set bookkeeping: the last recorded membership of each named set, - // the generation naming its most recent call, and the lock that makes a commit - // atomic. All weak on the context so none of them holds it alive. - private static readonly global::System.Runtime.CompilerServices.ConditionalWeakTable>> _ruleSets = new(); - private static readonly global::System.Runtime.CompilerServices.ConditionalWeakTable>> _ruleSetTickets = new(); - private static readonly global::System.Runtime.CompilerServices.ConditionalWeakTable _ruleSetCommitLocks = new();} + 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/tests/Reactor.Tests/ValidationRenderScopeTests.cs b/tests/Reactor.Tests/ValidationRenderScopeTests.cs index c78a34bab..bf164b2fd 100644 --- a/tests/Reactor.Tests/ValidationRenderScopeTests.cs +++ b/tests/Reactor.Tests/ValidationRenderScopeTests.cs @@ -1719,6 +1719,51 @@ public void Two_Callers_Sharing_A_Named_Predicate_Need_A_Set_Id() 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_Value_Change_Leaves_Sync_Messages_For_The_New_Value_Intact() { From 962494a0bcbe393111be66aae23758ae8cb55123 Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Thu, 24 Sep 2026 03:33:18 -0700 Subject: [PATCH 36/67] Stop FormField validating value-less attachments against null FormField ran attached validators whenever any were present, passing attached.Value -- which the validator-only overload never sets. So FormField(TextBox("Alice").Validate("name", Validate.Required())) reported a required-field error against null for a control that plainly had text. The condition predates this PR; its own comment said "with a Value" while the check did not test for one. null is a legitimate value, so the property alone cannot distinguish an empty field from an unattached one. ValidationAttached now records HasValue, set by the value overloads, and FormField validates only when a value was attached. A value-less attachment stays declarative, exactly as it is outside a FormField, and its field is still registered so MarkAllTouched() covers it. The synchronous named-set overload also now clears each producer's async generation, mirroring the async one: a set that switches from EvaluateRulesAsync to EvaluateRules would otherwise have its current synchronous verdict retracted by the next value change. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 373c7212-34f6-4ddf-8ae9-59553c77cf48 --- CHANGELOG.md | 6 +++ .../reactor-dsl/references/reactor.api.txt | 1 + skills/reactor.api.txt | 1 + .../Controls/Validation/ValidateExtensions.cs | 18 +++++++- .../Validation/ValidationReconciler.cs | 11 ++++- .../Core/V1Protocol/CompositeLifecycle.cs | 25 +++++++---- .../Fixtures/ValidationCoverageFixtures.cs | 38 +++++++++++++++++ .../SelfTest/SelfTestFixtureRegistry.cs | 2 + .../ValidationRenderScopeTests.cs | 41 +++++++++++++++++++ 9 files changed, 132 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f538f482..a3b0fb6ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -139,6 +139,12 @@ Conventions for contributors: `EvaluateRules(...)` — recorded a passing verdict without ever running its predicate, reporting an invalid field as valid. Those paths now throw, and `EvaluateRulesAsync` is the batch path that runs them (issue #1262). +- A validator-only attachment inside a `FormField` — + `FormField(TextBox("Alice").Validate("name", Validate.Required()))` — was validated + against `null` and reported a required-field error for a control that plainly had + text. That overload supplies no value, and `null` is a legitimate one, so + `ValidationAttached` now records whether a value was attached and `FormField` + validates only when one was. The field is still registered (issue #1262). - A mounted async `ValidationRule` whose predicate never completed was never released: the predicate takes no cancellation token, so cancelling on update or unmount left a suspended task holding the rule binding and its `ValidationContext`, with another diff --git a/plugins/reactor/skills/reactor-dsl/references/reactor.api.txt b/plugins/reactor/skills/reactor-dsl/references/reactor.api.txt index 55ced324c..e8a486a4e 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 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("", [], []); } @@ -71,9 +83,10 @@ 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 }; RunDuringRender(merged, value); return (T)el.SetAttached(merged); @@ -120,9 +133,10 @@ 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. diff --git a/src/Reactor/Controls/Validation/ValidationReconciler.cs b/src/Reactor/Controls/Validation/ValidationReconciler.cs index 0e59d0166..cd8f9e910 100644 --- a/src/Reactor/Controls/Validation/ValidationReconciler.cs +++ b/src/Reactor/Controls/Validation/ValidationReconciler.cs @@ -139,7 +139,16 @@ public static void EvaluateRules( // otherwise re-enter and take ownership of the set out from under this call. 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, RuleProducer(setId, i), rules[i].ComputeSync())); + { + var producer = RuleProducer(setId, i); + + // The set may have been asynchronous last time. A leftover generation would + // classify this synchronous verdict as async, and the next value change would + // retract an error that is current. + ctx.ClearAsyncGeneration(rules[i].Field, producer); + + verdicts.Add((rules[i].Field, producer, rules[i].ComputeSync())); + } ctx.CommitRuleSet(setId, generation, verdicts, asyncTokens: null); } diff --git a/src/Reactor/Core/V1Protocol/CompositeLifecycle.cs b/src/Reactor/Core/V1Protocol/CompositeLifecycle.cs index ad33940a5..cb2fb766b 100644 --- a/src/Reactor/Core/V1Protocol/CompositeLifecycle.cs +++ b/src/Reactor/Core/V1Protocol/CompositeLifecycle.cs @@ -104,25 +104,34 @@ internal static WinUI.StackPanel MountFormField(Reconciler reconciler, FormField /// Runs a content element's attached synchronous validators against the context a /// FormField resolved, and makes sure the field exists either way. /// - /// Registration cannot be left to the sync 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 an async-only 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). + /// 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. + /// + /// + /// 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(ValidationContext? valCtx, ValidationAttached? attached) { if (valCtx is null || attached is null) return; + if (string.IsNullOrEmpty(attached.FieldName)) return; - if (attached.Validators.Length > 0) + if (attached.HasValue && attached.Validators.Length > 0) { ValidationReconciler.ValidateAttached(valCtx, attached, attached.Value); return; } - if (attached.AsyncValidators.Length > 0 && !string.IsNullOrEmpty(attached.FieldName)) + if (attached.Validators.Length > 0 || attached.AsyncValidators.Length > 0) valCtx.RegisterField(attached.FieldName); } diff --git a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs index 56a98222e..5a9ceb97a 100644 --- a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs +++ b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs @@ -1351,4 +1351,42 @@ public override async Task RunAsync() 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(); + } + } } diff --git a/tests/Reactor.AppTests.Host/SelfTest/SelfTestFixtureRegistry.cs b/tests/Reactor.AppTests.Host/SelfTest/SelfTestFixtureRegistry.cs index 5925dfe1e..5fc5ad12c 100644 --- a/tests/Reactor.AppTests.Host/SelfTest/SelfTestFixtureRegistry.cs +++ b/tests/Reactor.AppTests.Host/SelfTest/SelfTestFixtureRegistry.cs @@ -820,6 +820,7 @@ internal static class SelfTestFixtureRegistry "ValCov_Issue1262_MountedAsyncRule", "ValCov_Issue1262_AsyncRuleProviderRemoved", "ValCov_Issue1262_AsyncRuleBecomesSync", + "ValCov_Issue1262_ValidatorOnlyInFormField", // Controls coverage — MaskEngine, InputFormatter, AutoSuggest "ControlsCov_MaskEngineBasic", "ControlsCov_MaskEngineNavigation", @@ -2734,6 +2735,7 @@ internal static string[] StaleTierDeclarations() => "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), // Controls coverage "ControlsCov_MaskEngineBasic" => new ControlsCoverageFixtures.MaskEngineBasic(harness), "ControlsCov_MaskEngineNavigation" => new ControlsCoverageFixtures.MaskEngineNavigation(harness), diff --git a/tests/Reactor.Tests/ValidationRenderScopeTests.cs b/tests/Reactor.Tests/ValidationRenderScopeTests.cs index bf164b2fd..8c09e083a 100644 --- a/tests/Reactor.Tests/ValidationRenderScopeTests.cs +++ b/tests/Reactor.Tests/ValidationRenderScopeTests.cs @@ -1764,6 +1764,47 @@ await ValidationReconciler.EvaluateRulesAsync( 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_Value_Change_Leaves_Sync_Messages_For_The_New_Value_Intact() { From 55066ff60788c1b14058272f05ea5d32c18dcc35 Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Thu, 24 Sep 2026 03:36:20 -0700 Subject: [PATCH 37/67] Use Where for the departed rule-set producer filter Addresses GitHub code-quality feedback on CommitRuleSet: the loop over the previous membership is a pure filter. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 373c7212-34f6-4ddf-8ae9-59553c77cf48 --- src/Reactor/Controls/Validation/ValidationContext.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Reactor/Controls/Validation/ValidationContext.cs b/src/Reactor/Controls/Validation/ValidationContext.cs index 4d486dc78..ec6512548 100644 --- a/src/Reactor/Controls/Validation/ValidationContext.cs +++ b/src/Reactor/Controls/Validation/ValidationContext.cs @@ -1029,9 +1029,8 @@ internal void CommitRuleSet( if (_ruleSetMembership.TryGetValue(setId, out var previous)) { - foreach (var entry in previous) + foreach (var entry in previous.Where(entry => !applied.Contains(entry))) { - if (applied.Contains(entry)) continue; if (_asyncGeneration.TryGetValue(entry.Field, out var byProducer)) { byProducer.Remove(entry.Producer); From c0c0ba407d46bc1c36c4fc7bc4a5e110b504efc0 Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Thu, 24 Sep 2026 03:57:09 -0700 Subject: [PATCH 38/67] Correct the docs on validator-only attachments in FormField The guide template and both forms skills still said FormField runs the validator-only overload when it mounts. That stopped being true in 962494a0, which is exactly the change that stopped it validating null against a control that had a value. All three now distinguish the two attach-only cases: a validator-only attachment records the validators and registers the field but is never run, while a value-carrying attachment built outside a render pass is run by FormField on mount. docs/guide/forms.md regenerated from the template. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 373c7212-34f6-4ddf-8ae9-59553c77cf48 --- docs/_pipeline/templates/forms.md.dt | 12 +++++++----- docs/guide/forms.md | 12 +++++++----- plugins/reactor/skills/reactor-forms/SKILL.md | 5 +++-- skills/forms.md | 3 ++- 4 files changed, 19 insertions(+), 13 deletions(-) diff --git a/docs/_pipeline/templates/forms.md.dt b/docs/_pipeline/templates/forms.md.dt index f9ad48fc7..b5963025e 100644 --- a/docs/_pipeline/templates/forms.md.dt +++ b/docs/_pipeline/templates/forms.md.dt @@ -221,11 +221,13 @@ 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, and validators attached to an element built outside a render -> pass — cached in a field, assembled inside an event handler — have no context -> to reach. In both cases `.Validate()` only records the validators, and -> `FormField` runs them when it mounts. Pass the value if you want the eager -> behaviour. +> nothing to check: it records the validators and registers the field, so +> `MarkAllTouched()` still covers it, but nothing runs them — not even +> `FormField`, which would otherwise be checking `null` against a control that +> has a value. 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 diff --git a/docs/guide/forms.md b/docs/guide/forms.md index 00a705b4b..e07ac6f4d 100644 --- a/docs/guide/forms.md +++ b/docs/guide/forms.md @@ -384,11 +384,13 @@ 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, and validators attached to an element built outside a render -> pass — cached in a field, assembled inside an event handler — have no context -> to reach. In both cases `.Validate()` only records the validators, and -> `FormField` runs them when it mounts. Pass the value if you want the eager -> behaviour. +> nothing to check: it records the validators and registers the field, so +> `MarkAllTouched()` still covers it, but nothing runs them — not even +> `FormField`, which would otherwise be checking `null` against a control that +> has a value. 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 diff --git a/plugins/reactor/skills/reactor-forms/SKILL.md b/plugins/reactor/skills/reactor-forms/SKILL.md index b2e02ee7f..82dc6da42 100644 --- a/plugins/reactor/skills/reactor-forms/SKILL.md +++ b/plugins/reactor/skills/reactor-forms/SKILL.md @@ -129,8 +129,9 @@ through React-style ambient context — you do not pass `validation` explicitly. 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 and -only attaches — `FormField` runs those when it mounts. +validator-only overload `.Validate(fieldName, ...)` has no value to check, so it +only attaches and registers the field — nothing runs it, including `FormField`, +which would otherwise be checking `null` against a control that has a value. You do not need `.Provide(ValidationContexts.Current, validation)`: a component-local context is published to the rendered subtree automatically, so diff --git a/skills/forms.md b/skills/forms.md index f5fd82e2e..fbd5b8c03 100644 --- a/skills/forms.md +++ b/skills/forms.md @@ -110,7 +110,8 @@ 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 and only attaches. +`.Validate(fieldName, validators...)` has no value to check, so it only attaches +and registers the field — nothing runs it, `FormField` included. You do not need `.Provide(ValidationContexts.Current, validation)`: a component-local context is published to the subtree automatically, so From d278a0de588608a88ef08cb0f9b5c73e3f33ed4a Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Thu, 24 Sep 2026 04:20:53 -0700 Subject: [PATCH 39/67] Retire rule-set async tokens inside the ownership transaction ClearAsyncGeneration ran before CommitRuleSet checked that this evaluation still owned the set, so a stale synchronous call could retire a token a newer async call had just opened -- the newer commit then failed its own verification and stood down too, leaving neither set installed. Token retirement moved into CommitRuleSet, past the generation check, for both named overloads. Opening tokens necessarily stays ahead of the predicates, but opening is per-producer and verified at commit, so a newer call simply supersedes an older one there. Also corrects the attach-only docs again: the validator-only overload does not register the field either. Registration happens when a FormField mounts the attachment, so on a bare control it is inert -- no verdict and no MarkAllTouched() coverage. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 373c7212-34f6-4ddf-8ae9-59553c77cf48 --- docs/_pipeline/templates/forms.md.dt | 15 ++++++----- docs/guide/forms.md | 15 ++++++----- plugins/reactor/skills/reactor-forms/SKILL.md | 5 ++-- skills/forms.md | 6 +++-- .../Controls/Validation/ValidationContext.cs | 26 ++++++++++++++----- .../Validation/ValidationReconciler.cs | 22 +++++++++------- 6 files changed, 54 insertions(+), 35 deletions(-) diff --git a/docs/_pipeline/templates/forms.md.dt b/docs/_pipeline/templates/forms.md.dt index b5963025e..2c7c2b2f8 100644 --- a/docs/_pipeline/templates/forms.md.dt +++ b/docs/_pipeline/templates/forms.md.dt @@ -221,13 +221,14 @@ 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 registers the field, so -> `MarkAllTouched()` still covers it, but nothing runs them — not even -> `FormField`, which would otherwise be checking `null` against a control that -> has a value. 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. +> 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 diff --git a/docs/guide/forms.md b/docs/guide/forms.md index e07ac6f4d..073951609 100644 --- a/docs/guide/forms.md +++ b/docs/guide/forms.md @@ -384,13 +384,14 @@ 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 registers the field, so -> `MarkAllTouched()` still covers it, but nothing runs them — not even -> `FormField`, which would otherwise be checking `null` against a control that -> has a value. 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. +> 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 diff --git a/plugins/reactor/skills/reactor-forms/SKILL.md b/plugins/reactor/skills/reactor-forms/SKILL.md index 82dc6da42..314fb92c4 100644 --- a/plugins/reactor/skills/reactor-forms/SKILL.md +++ b/plugins/reactor/skills/reactor-forms/SKILL.md @@ -130,8 +130,9 @@ 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 attaches and registers the field — nothing runs it, including `FormField`, -which would otherwise be checking `null` against a control that has a value. +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 diff --git a/skills/forms.md b/skills/forms.md index fbd5b8c03..f2eff64ed 100644 --- a/skills/forms.md +++ b/skills/forms.md @@ -110,8 +110,10 @@ 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 attaches -and registers the field — nothing runs it, `FormField` included. +`.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 diff --git a/src/Reactor/Controls/Validation/ValidationContext.cs b/src/Reactor/Controls/Validation/ValidationContext.cs index ec6512548..246182f5b 100644 --- a/src/Reactor/Controls/Validation/ValidationContext.cs +++ b/src/Reactor/Controls/Validation/ValidationContext.cs @@ -1005,7 +1005,8 @@ 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, int Token)>? asyncTokens, + List<(string Field, string Producer)>? clearAsync = null) { var changed = false; lock (_lock) @@ -1023,6 +1024,15 @@ internal void CommitRuleSet( } } + // 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)); @@ -1067,12 +1077,14 @@ internal void CommitRuleSet( /// internal void ClearAsyncGeneration(string field, string producer) { - lock (_lock) - { - if (!_asyncGeneration.TryGetValue(field, out var byProducer)) return; - byProducer.Remove(producer); - if (byProducer.Count == 0) _asyncGeneration.Remove(field); - } + 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); } /// diff --git a/src/Reactor/Controls/Validation/ValidationReconciler.cs b/src/Reactor/Controls/Validation/ValidationReconciler.cs index cd8f9e910..cc8a87336 100644 --- a/src/Reactor/Controls/Validation/ValidationReconciler.cs +++ b/src/Reactor/Controls/Validation/ValidationReconciler.cs @@ -137,20 +137,20 @@ public static void EvaluateRules( // 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); - - // The set may have been asynchronous last time. A leftover generation would - // classify this synchronous verdict as async, and the next value change would - // retract an error that is current. - ctx.ClearAsyncGeneration(rules[i].Field, producer); - + clearAsync.Add((rules[i].Field, producer)); verdicts.Add((rules[i].Field, producer, rules[i].ComputeSync())); } - ctx.CommitRuleSet(setId, generation, verdicts, asyncTokens: null); + ctx.CommitRuleSet(setId, generation, verdicts, asyncTokens: null, clearAsync); } /// @@ -222,8 +222,10 @@ public static async Task EvaluateRulesAsync( // 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 - // cleared, or the next value change would retract a verdict that is current. + // 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++) { @@ -231,7 +233,7 @@ public static async Task EvaluateRulesAsync( ctx.RegisterField(rules[i].Field); if (rules[i].AsyncPredicate is null) - ctx.ClearAsyncGeneration(rules[i].Field, producer); + clearAsync.Add((rules[i].Field, producer)); else tokens.Add((rules[i].Field, producer, ctx.BeginAsyncProducer(rules[i].Field, producer))); } @@ -240,7 +242,7 @@ public static async Task EvaluateRulesAsync( 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); + ctx.CommitRuleSet(setId, generation, verdicts, tokens, clearAsync); } private static string RuleProducer(string setId, int index) => From d10e912a748e1bba277d6b0b3088216a5fc37111 Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Thu, 24 Sep 2026 04:39:55 -0700 Subject: [PATCH 40/67] Clear validation bindings on full detach The three validation slots added to ReactorState were not cleared by DetachReactorState, so a control retired outside the normal FormField / ValidationRule unmount path kept its ValidationContext alive -- and its once-per-lifetime LostFocus handler, which survives detach, kept marking the field it used to host. Detach now neutralizes each binding before dropping the slot: resetting matters as well as clearing, because the handler holds the binding object directly and a pending async rule reads its context when it resolves. Resetting a rule binding also cancels any pass still out. Core reaches the binding types through a small internal interface rather than depending on the V1 composite lifecycle's private types. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 373c7212-34f6-4ddf-8ae9-59553c77cf48 --- src/Reactor/Core/Reconciler.cs | 21 +++++++ .../Core/V1Protocol/CompositeLifecycle.cs | 23 ++++++- .../Fixtures/ValidationCoverageFixtures.cs | 61 +++++++++++++++++++ .../SelfTest/SelfTestFixtureRegistry.cs | 2 + 4 files changed, 105 insertions(+), 2 deletions(-) diff --git a/src/Reactor/Core/Reconciler.cs b/src/Reactor/Core/Reconciler.cs index 2e702ab31..fe3e4564d 100644 --- a/src/Reactor/Core/Reconciler.cs +++ b/src/Reactor/Core/Reconciler.cs @@ -823,6 +823,27 @@ 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.ValidationTouchBinding = null; + state.ValidationRootBinding = null; + state.ValidationRuleBinding = null; + } + + /// + /// Lets neutralize a validation binding without + /// Core depending on the V1 composite lifecycle's private binding types. + /// + internal interface IValidationBindingReset + { + void Reset(); } // ════════════════════════════════════════════════════════════════════ diff --git a/src/Reactor/Core/V1Protocol/CompositeLifecycle.cs b/src/Reactor/Core/V1Protocol/CompositeLifecycle.cs index cb2fb766b..b7db6aa6c 100644 --- a/src/Reactor/Core/V1Protocol/CompositeLifecycle.cs +++ b/src/Reactor/Core/V1Protocol/CompositeLifecycle.cs @@ -395,12 +395,22 @@ internal static void RetractValidationRule(UIElement placeholder) binding.Field = null; } - private sealed class RuleBinding + 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, and drop the context so a result + // that resolves anyway cannot write to it. + public void Reset() + { + CancelPendingRule(this); + Context = null; + Field = null; + } } /// @@ -649,10 +659,19 @@ contentControl is FrameworkElement fe && binding.Context is not null && !string.IsNullOrEmpty(binding.FieldName); - private sealed class TouchBinding + 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) => diff --git a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs index 5a9ceb97a..7a3e1efc1 100644 --- a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs +++ b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs @@ -1389,4 +1389,65 @@ public override async Task RunAsync() 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(); + } + } } diff --git a/tests/Reactor.AppTests.Host/SelfTest/SelfTestFixtureRegistry.cs b/tests/Reactor.AppTests.Host/SelfTest/SelfTestFixtureRegistry.cs index 5fc5ad12c..ada311670 100644 --- a/tests/Reactor.AppTests.Host/SelfTest/SelfTestFixtureRegistry.cs +++ b/tests/Reactor.AppTests.Host/SelfTest/SelfTestFixtureRegistry.cs @@ -821,6 +821,7 @@ internal static class SelfTestFixtureRegistry "ValCov_Issue1262_AsyncRuleProviderRemoved", "ValCov_Issue1262_AsyncRuleBecomesSync", "ValCov_Issue1262_ValidatorOnlyInFormField", + "ValCov_Issue1262_DetachClearsBindings", // Controls coverage — MaskEngine, InputFormatter, AutoSuggest "ControlsCov_MaskEngineBasic", "ControlsCov_MaskEngineNavigation", @@ -2736,6 +2737,7 @@ internal static string[] StaleTierDeclarations() => "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), // Controls coverage "ControlsCov_MaskEngineBasic" => new ControlsCoverageFixtures.MaskEngineBasic(harness), "ControlsCov_MaskEngineNavigation" => new ControlsCoverageFixtures.MaskEngineNavigation(harness), From 25b862ab9662dd0bc3f782545ce54e7bbf6684da Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Thu, 24 Sep 2026 04:58:10 -0700 Subject: [PATCH 41/67] Retire the producer on rule detach; rerun merged validator chains Two review findings. RuleBinding.Reset cancelled the pending pass and dropped the context but never withdrew what the producer had installed, so an error outlived the control that produced it and nothing would ever re-evaluate it away. It now retires the producer first, as RetractValidationRule does. The validator-only overload merged its validators into an existing attachment without re-running them, so .Validate(f, v, Required()) .Validate(f, MinLength(3)) installed only the Required verdict and silently dropped the second validator for a bare control. It now re-runs the merged set when the attachment already carries a value; a standalone validator-only call stays attach-only, as documented. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 373c7212-34f6-4ddf-8ae9-59553c77cf48 --- .../Controls/Validation/ValidateExtensions.cs | 8 ++++ .../Core/V1Protocol/CompositeLifecycle.cs | 7 +++- .../Fixtures/ValidationCoverageFixtures.cs | 42 +++++++++++++++++++ .../SelfTest/SelfTestFixtureRegistry.cs | 2 + .../ValidationRenderScopeTests.cs | 30 +++++++++++++ 5 files changed, 87 insertions(+), 2 deletions(-) diff --git a/src/Reactor/Controls/Validation/ValidateExtensions.cs b/src/Reactor/Controls/Validation/ValidateExtensions.cs index a91f245af..a97c6be90 100644 --- a/src/Reactor/Controls/Validation/ValidateExtensions.cs +++ b/src/Reactor/Controls/Validation/ValidateExtensions.cs @@ -54,6 +54,14 @@ 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). + if (merged.HasValue) RunDuringRender(merged, merged.Value); + return (T)el.SetAttached(merged); } diff --git a/src/Reactor/Core/V1Protocol/CompositeLifecycle.cs b/src/Reactor/Core/V1Protocol/CompositeLifecycle.cs index b7db6aa6c..70a611594 100644 --- a/src/Reactor/Core/V1Protocol/CompositeLifecycle.cs +++ b/src/Reactor/Core/V1Protocol/CompositeLifecycle.cs @@ -403,11 +403,14 @@ private sealed class RuleBinding : Reconciler.IValidationBindingReset internal global::System.Threading.CancellationTokenSource? Pending; // Called by DetachReactorState when a control is retired outside the normal - // unmount path: cancel any pass still out, and drop the context so a result - // that resolves anyway cannot write to it. + // 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; } diff --git a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs index 7a3e1efc1..a6a0d11bf 100644 --- a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs +++ b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs @@ -1450,4 +1450,46 @@ public override async Task RunAsync() 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(); + } + } } diff --git a/tests/Reactor.AppTests.Host/SelfTest/SelfTestFixtureRegistry.cs b/tests/Reactor.AppTests.Host/SelfTest/SelfTestFixtureRegistry.cs index ada311670..05d610c95 100644 --- a/tests/Reactor.AppTests.Host/SelfTest/SelfTestFixtureRegistry.cs +++ b/tests/Reactor.AppTests.Host/SelfTest/SelfTestFixtureRegistry.cs @@ -822,6 +822,7 @@ internal static class SelfTestFixtureRegistry "ValCov_Issue1262_AsyncRuleBecomesSync", "ValCov_Issue1262_ValidatorOnlyInFormField", "ValCov_Issue1262_DetachClearsBindings", + "ValCov_Issue1262_DetachRetiresRuleVerdict", // Controls coverage — MaskEngine, InputFormatter, AutoSuggest "ControlsCov_MaskEngineBasic", "ControlsCov_MaskEngineNavigation", @@ -2738,6 +2739,7 @@ internal static string[] StaleTierDeclarations() => "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), // Controls coverage "ControlsCov_MaskEngineBasic" => new ControlsCoverageFixtures.MaskEngineBasic(harness), "ControlsCov_MaskEngineNavigation" => new ControlsCoverageFixtures.MaskEngineNavigation(harness), diff --git a/tests/Reactor.Tests/ValidationRenderScopeTests.cs b/tests/Reactor.Tests/ValidationRenderScopeTests.cs index 8c09e083a..9bffe65ba 100644 --- a/tests/Reactor.Tests/ValidationRenderScopeTests.cs +++ b/tests/Reactor.Tests/ValidationRenderScopeTests.cs @@ -1805,6 +1805,36 @@ await ValidationReconciler.EvaluateRulesAsync( 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 void A_Value_Change_Leaves_Sync_Messages_For_The_New_Value_Intact() { From 07a15572b6d8f7b4e617443c6c5dcb45fe73c7cf Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Thu, 24 Sep 2026 05:18:49 -0700 Subject: [PATCH 42/67] Compute rule verdicts before mutating context; cover off-thread mutation Evaluate cleared the producer's async generation before ComputeSync ran, and ComputeSync throws for a rule carrying an async predicate. The throw left the previous verdict installed with its generation already gone, so nothing could retract it and the stale error survived forever. The verdict is now computed first, so a throw leaves context state untouched. Adds ValCov_Issue1262_OffThreadMutationMarshals, which mutates a ValidationContext from Task.Run -- what a background async validator does when it resolves -- and asserts the mutation does not throw, drives no synchronous render from the worker, repaints, and that every render ran on the UI thread. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 373c7212-34f6-4ddf-8ae9-59553c77cf48 --- .../Controls/Validation/ValidationRule.cs | 8 +- .../Fixtures/ValidationCoverageFixtures.cs | 88 +++++++++++++++++++ .../SelfTest/SelfTestFixtureRegistry.cs | 2 + .../ValidationRenderScopeTests.cs | 21 +++++ 4 files changed, 118 insertions(+), 1 deletion(-) diff --git a/src/Reactor/Controls/Validation/ValidationRule.cs b/src/Reactor/Controls/Validation/ValidationRule.cs index 1accb3550..80aba125a 100644 --- a/src/Reactor/Controls/Validation/ValidationRule.cs +++ b/src/Reactor/Controls/Validation/ValidationRule.cs @@ -95,9 +95,15 @@ public static void Evaluate(this ValidationRuleElement rule, ValidationContext c /// 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, rule.ComputeSync()); + ctx.ApplyOwned(rule.Field, producer, messages); } /// diff --git a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs index a6a0d11bf..30a568435 100644 --- a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs +++ b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs @@ -1492,4 +1492,92 @@ public override async Task RunAsync() 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. This fixture is what makes that choice falsifiable. + // ════════════════════════════════════════════════════════════════════════ + + 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; + var renderThreads = new List(); + ValidationContext? captured = null; + + host.Mount(c => VStack(12, + Component( + new OffThreadProps(ctx => captured = ctx, id => renderThreads.Add(id))), + 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 rendersSeenInsideWorker = -1; + await Task.Run(() => + { + try { captured.Add("email", "Email is taken"); } + catch (global::System.Exception ex) { thrown = ex; } + rendersSeenInsideWorker = renderThreads.Count; + }); + + 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 + // before it returned. A threadSafe state setter invokes the re-render + // callback on the raising thread, which for a child component renders + // inline — on the worker. + H.Check("Issue1262_OffThread_NoSynchronousRenderFromWorker", + rendersSeenInsideWorker == rendersBefore, + $"before={rendersBefore} insideWorker={rendersSeenInsideWorker}"); + + 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(); + } + } } diff --git a/tests/Reactor.AppTests.Host/SelfTest/SelfTestFixtureRegistry.cs b/tests/Reactor.AppTests.Host/SelfTest/SelfTestFixtureRegistry.cs index 05d610c95..52e3a4606 100644 --- a/tests/Reactor.AppTests.Host/SelfTest/SelfTestFixtureRegistry.cs +++ b/tests/Reactor.AppTests.Host/SelfTest/SelfTestFixtureRegistry.cs @@ -823,6 +823,7 @@ internal static class SelfTestFixtureRegistry "ValCov_Issue1262_ValidatorOnlyInFormField", "ValCov_Issue1262_DetachClearsBindings", "ValCov_Issue1262_DetachRetiresRuleVerdict", + "ValCov_Issue1262_OffThreadMutationMarshals", // Controls coverage — MaskEngine, InputFormatter, AutoSuggest "ControlsCov_MaskEngineBasic", "ControlsCov_MaskEngineNavigation", @@ -2740,6 +2741,7 @@ internal static string[] StaleTierDeclarations() => "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), // Controls coverage "ControlsCov_MaskEngineBasic" => new ControlsCoverageFixtures.MaskEngineBasic(harness), "ControlsCov_MaskEngineNavigation" => new ControlsCoverageFixtures.MaskEngineNavigation(harness), diff --git a/tests/Reactor.Tests/ValidationRenderScopeTests.cs b/tests/Reactor.Tests/ValidationRenderScopeTests.cs index 9bffe65ba..c7f29f6b8 100644 --- a/tests/Reactor.Tests/ValidationRenderScopeTests.cs +++ b/tests/Reactor.Tests/ValidationRenderScopeTests.cs @@ -1835,6 +1835,27 @@ public void A_Standalone_Validator_Only_Call_Stays_Attach_Only() 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 void A_Value_Change_Leaves_Sync_Messages_For_The_New_Value_Intact() { From d3b5b36f2550ca8d895944b306f0c0f3be714bc1 Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Thu, 24 Sep 2026 05:41:00 -0700 Subject: [PATCH 43/67] Retire pending rule-set commits when clearing or resetting state Clearing or resetting a context did not touch _ruleSetTickets, so a named evaluation already in flight passed its ticket check afterwards and reinstalled verdicts the clear had just removed. The reachable case is a set spanning two fields: clearing one of them retires no async token -- the only token belongs to the other field -- so nothing stopped the commit. Clear, ClearInternal, ClearAll, Reset and ResetAll now invalidate the generations of every set that could still write the affected field, or of every set for the whole-context forms. A set whose membership is not yet recorded is treated as affected, since its first evaluation is the one most likely to be in flight. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 373c7212-34f6-4ddf-8ae9-59553c77cf48 --- .../Controls/Validation/ValidationContext.cs | 36 +++++++++ .../ValidationRenderScopeTests.cs | 75 +++++++++++++++++++ 2 files changed, 111 insertions(+) diff --git a/src/Reactor/Controls/Validation/ValidationContext.cs b/src/Reactor/Controls/Validation/ValidationContext.cs index 246182f5b..b0133fd52 100644 --- a/src/Reactor/Controls/Validation/ValidationContext.cs +++ b/src/Reactor/Controls/Validation/ValidationContext.cs @@ -356,6 +356,7 @@ public void Clear(string field) // An async pass still in flight would otherwise repopulate what this just // cleared: dropping the token makes its result stale on arrival. _asyncGeneration.Remove(field); + InvalidateRuleSetsLocked(field); if (changed) BumpVersionLocked(messagesOnly: true); } if (changed) RaiseChanged(messagesOnly: true); @@ -374,6 +375,7 @@ internal void ClearInternal(string field) _owned.Remove(field); // As in Clear: a pending async pass must not repopulate what this dropped. _asyncGeneration.Remove(field); + InvalidateRuleSetsLocked(field); if (changed) BumpVersionLocked(messagesOnly: true); } if (changed) RaiseChanged(messagesOnly: true); @@ -683,6 +685,7 @@ public void ClearAll() _externalMessages.Clear(); _owned.Clear(); _asyncGeneration.Clear(); + InvalidateRuleSetsLocked(null); if (changed) BumpVersionLocked(messagesOnly: true); } if (changed) RaiseChanged(messagesOnly: true); @@ -1065,6 +1068,37 @@ internal void CommitRuleSet( 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; + + List? affected = null; + foreach (var setId in _ruleSetTickets.Keys) + { + if (field is not null + && _ruleSetMembership.TryGetValue(setId, out var members) + && !members.Any(m => string.Equals(m.Field, field, StringComparison.Ordinal))) + continue; + + (affected ??= []).Add(setId); + } + + if (affected is null) return; + 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. @@ -1198,6 +1232,7 @@ public bool IsDirty() if (_externalMessages.Remove(field)) changed = true; _owned.Remove(field); _asyncGeneration.Remove(field); + InvalidateRuleSetsLocked(field); _initialValues.TryGetValue(field, out initial); // Only rewind a value the context is actually tracking. Creating an entry @@ -1232,6 +1267,7 @@ public bool IsDirty() _externalMessages.Clear(); _owned.Clear(); _asyncGeneration.Clear(); + InvalidateRuleSetsLocked(null); result = new Dictionary(); foreach (var (field, initial) in _initialValues) diff --git a/tests/Reactor.Tests/ValidationRenderScopeTests.cs b/tests/Reactor.Tests/ValidationRenderScopeTests.cs index c7f29f6b8..86aecea5b 100644 --- a/tests/Reactor.Tests/ValidationRenderScopeTests.cs +++ b/tests/Reactor.Tests/ValidationRenderScopeTests.cs @@ -1856,6 +1856,81 @@ public async Task A_Failed_Sync_Evaluation_Leaves_The_Async_Generation_Intact() 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 void A_Value_Change_Leaves_Sync_Messages_For_The_New_Value_Intact() { From 66f6493bf3b24487e646c93d1a12385e05a646a7 Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Thu, 24 Sep 2026 05:44:11 -0700 Subject: [PATCH 44/67] Narrow the off-thread fixture catch Addresses GitHub code-quality feedback: the capture-and-assert catch stays broad on purpose -- the assertion is "any failure at all" -- but no longer absorbs OutOfMemory or StackOverflow, matching the convention used in the reconciler. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 373c7212-34f6-4ddf-8ae9-59553c77cf48 --- .../SelfTest/Fixtures/ValidationCoverageFixtures.cs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs index 30a568435..50623a8e7 100644 --- a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs +++ b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs @@ -1547,7 +1547,15 @@ public override async Task RunAsync() await Task.Run(() => { try { captured.Add("email", "Email is taken"); } - catch (global::System.Exception ex) { thrown = ex; } + 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; + } rendersSeenInsideWorker = renderThreads.Count; }); From 41a0e68d8e81a8b8c9c8e8b2e7aae5d1e6496126 Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Thu, 24 Sep 2026 06:06:26 -0700 Subject: [PATCH 45/67] Retire stale rule sets and field verdicts when values or fields move Three review findings. A value change retracted async producers but left in-flight named rule-set evaluations alone, so a set computed from the superseded value could still commit. That also covers the mixed-batch case: a synchronous rule evaluated before a later rule awaits carries no generation of its own, so the ticket is what has to stop it. All three value-recording paths now invalidate the sets that could write the changed field. ValidateField installs under the field's own sync producer, and nothing withdrew it from a field the control had moved away from -- a conditional field name switching from email to phone left the email messages owned by a field nothing validates any more. UpdateFormField now retires the previous field's sync producer when the resolved name changes. Also fixes two documentation claims: ValidationAttached.Value no longer says the visualizers validate (they are display-only), and skills/design.md no longer shows FormField("Email", ...), .Validate(validation, ...), Validators.*, .Required(true) or showErrorWhen -- none of which exist. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 373c7212-34f6-4ddf-8ae9-59553c77cf48 --- skills/design.md | 10 ++-- .../Controls/Validation/ValidateExtensions.cs | 3 +- .../Controls/Validation/ValidationContext.cs | 5 ++ .../Core/V1Protocol/CompositeLifecycle.cs | 14 ++++++ .../Fixtures/ValidationCoverageFixtures.cs | 49 +++++++++++++++++++ .../SelfTest/SelfTestFixtureRegistry.cs | 2 + .../ValidationRenderScopeTests.cs | 32 ++++++++++++ 7 files changed, 109 insertions(+), 6 deletions(-) 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/src/Reactor/Controls/Validation/ValidateExtensions.cs b/src/Reactor/Controls/Validation/ValidateExtensions.cs index a97c6be90..4fd551cdf 100644 --- a/src/Reactor/Controls/Validation/ValidateExtensions.cs +++ b/src/Reactor/Controls/Validation/ValidateExtensions.cs @@ -13,7 +13,8 @@ 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; } diff --git a/src/Reactor/Controls/Validation/ValidationContext.cs b/src/Reactor/Controls/Validation/ValidationContext.cs index b0133fd52..f07e6a253 100644 --- a/src/Reactor/Controls/Validation/ValidationContext.cs +++ b/src/Reactor/Controls/Validation/ValidationContext.cs @@ -567,6 +567,7 @@ internal int BeginAsyncValidation(string field, object? value) _currentValues[field] = value; _externalMessages.Remove(field); RetractAsyncProducersLocked(field); + InvalidateRuleSetsLocked(field); BumpVersionLocked(messagesOnly: false); } @@ -639,6 +640,9 @@ internal void ApplyValidation(string field, object? value, List(); var valCtx = reconciler.ReadContext(ValidationContexts.Current); + + // A conditional field name can move 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, or showing an error against a control that moved on (issue #1262 + // review). + var previousField = FormFieldHelpers.ResolveFieldName(oldFf.FieldName, oldFf.Content); + if (valCtx is not null + && !string.IsNullOrEmpty(previousField) + && !string.Equals(previousField, fieldName, StringComparison.Ordinal)) + { + valCtx.RetireProducer(previousField, ValidationContext.SyncProducer); + } + ApplyAttachedValidation(valCtx, attached); // [0] Update label diff --git a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs index 50623a8e7..0d07e8398 100644 --- a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs +++ b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs @@ -1588,4 +1588,53 @@ await Task.Run(() => 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(); + } + } } diff --git a/tests/Reactor.AppTests.Host/SelfTest/SelfTestFixtureRegistry.cs b/tests/Reactor.AppTests.Host/SelfTest/SelfTestFixtureRegistry.cs index 52e3a4606..681514f73 100644 --- a/tests/Reactor.AppTests.Host/SelfTest/SelfTestFixtureRegistry.cs +++ b/tests/Reactor.AppTests.Host/SelfTest/SelfTestFixtureRegistry.cs @@ -824,6 +824,7 @@ internal static class SelfTestFixtureRegistry "ValCov_Issue1262_DetachClearsBindings", "ValCov_Issue1262_DetachRetiresRuleVerdict", "ValCov_Issue1262_OffThreadMutationMarshals", + "ValCov_Issue1262_FormFieldNameMigration", // Controls coverage — MaskEngine, InputFormatter, AutoSuggest "ControlsCov_MaskEngineBasic", "ControlsCov_MaskEngineNavigation", @@ -2742,6 +2743,7 @@ internal static string[] StaleTierDeclarations() => "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), // Controls coverage "ControlsCov_MaskEngineBasic" => new ControlsCoverageFixtures.MaskEngineBasic(harness), "ControlsCov_MaskEngineNavigation" => new ControlsCoverageFixtures.MaskEngineNavigation(harness), diff --git a/tests/Reactor.Tests/ValidationRenderScopeTests.cs b/tests/Reactor.Tests/ValidationRenderScopeTests.cs index 86aecea5b..3923371f7 100644 --- a/tests/Reactor.Tests/ValidationRenderScopeTests.cs +++ b/tests/Reactor.Tests/ValidationRenderScopeTests.cs @@ -1931,6 +1931,38 @@ await ValidationReconciler.EvaluateRulesAsync( 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() { From 41ab37870616e9d81ac40c6aa667907b6683b078 Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Thu, 24 Sep 2026 06:09:42 -0700 Subject: [PATCH 46/67] Use Where for the affected rule-set filter Addresses GitHub code-quality feedback on InvalidateRuleSetsLocked. The list is still materialized because the loop writes back into the dictionary. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 373c7212-34f6-4ddf-8ae9-59553c77cf48 --- .../Controls/Validation/ValidationContext.cs | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/src/Reactor/Controls/Validation/ValidationContext.cs b/src/Reactor/Controls/Validation/ValidationContext.cs index f07e6a253..89eb9fc23 100644 --- a/src/Reactor/Controls/Validation/ValidationContext.cs +++ b/src/Reactor/Controls/Validation/ValidationContext.cs @@ -1088,18 +1088,13 @@ private void InvalidateRuleSetsLocked(string? field) { if (_ruleSetTickets.Count == 0) return; - List? affected = null; - foreach (var setId in _ruleSetTickets.Keys) - { - if (field is not null - && _ruleSetMembership.TryGetValue(setId, out var members) - && !members.Any(m => string.Equals(m.Field, field, StringComparison.Ordinal))) - continue; - - (affected ??= []).Add(setId); - } + // 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(); - if (affected is null) return; foreach (var setId in affected) _ruleSetTickets[setId] = unchecked(_ruleSetTickets[setId] + 1); } From 1b3aad27c8a82af3cff8fbacceb07aff5fca6822 Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Thu, 24 Sep 2026 06:28:35 -0700 Subject: [PATCH 47/67] Withdraw attached validation whenever it stops applying Retiring only on an effective field-name change missed the other two ways a FormField's synchronous contribution can stop applying: the value-carrying validators disappearing while the name stays the same, and the provider changing from one context to another. Either left the old SyncProducer message owned by something nothing validates any more. The FormField root now carries the (context, field) its attached validators last produced a verdict for, on the same native attached state as the other validation bindings, and the contribution is withdrawn whenever that pair moves or stops being produced. Full detach resets it too, which withdraws as well as clears. This subsumes the field-name retraction added last round, so that special case is gone. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 373c7212-34f6-4ddf-8ae9-59553c77cf48 --- src/Reactor/Core/Reconciler.cs | 6 ++ .../Core/V1Protocol/CompositeLifecycle.cs | 81 ++++++++++++++----- .../Fixtures/ValidationCoverageFixtures.cs | 61 ++++++++++++++ .../SelfTest/SelfTestFixtureRegistry.cs | 2 + 4 files changed, 132 insertions(+), 18 deletions(-) diff --git a/src/Reactor/Core/Reconciler.cs b/src/Reactor/Core/Reconciler.cs index fe3e4564d..f9326af47 100644 --- a/src/Reactor/Core/Reconciler.cs +++ b/src/Reactor/Core/Reconciler.cs @@ -425,6 +425,10 @@ internal sealed class ReactorState 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 @@ -832,9 +836,11 @@ public static void DetachReactorState(FrameworkElement fe) (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; } /// diff --git a/src/Reactor/Core/V1Protocol/CompositeLifecycle.cs b/src/Reactor/Core/V1Protocol/CompositeLifecycle.cs index 0adf29263..8fe0b0fb8 100644 --- a/src/Reactor/Core/V1Protocol/CompositeLifecycle.cs +++ b/src/Reactor/Core/V1Protocol/CompositeLifecycle.cs @@ -63,7 +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); - ApplyAttachedValidation(valCtx, attached); + ApplyAttachedValidation(panel, valCtx, attached); // [0] Label — always present, collapsed when empty var displayLabel = FormFieldHelpers.GetDisplayLabel(ff.Label, ff.Required); @@ -100,6 +100,36 @@ 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; + + public void Reset() + { + if (Context is { } ctx && Field is { } field) + ctx.RetireProducer(field, ValidationContext.SyncProducer); + Context = null; + Field = 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; + } + /// /// Runs a content element's attached synchronous validators against the context a /// FormField resolved, and makes sure the field exists either way. @@ -112,6 +142,13 @@ internal static WinUI.StackPanel MountFormField(Reconciler reconciler, FormField /// 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 @@ -120,14 +157,34 @@ internal static WinUI.StackPanel MountFormField(Reconciler reconciler, FormField /// ValidateExtensions.ValidateAsync). /// /// - private static void ApplyAttachedValidation(ValidationContext? valCtx, ValidationAttached? attached) + private static void ApplyAttachedValidation( + UIElement formFieldRoot, ValidationContext? valCtx, ValidationAttached? attached) { - if (valCtx is null || attached is null) return; - if (string.IsNullOrEmpty(attached.FieldName)) return; + var binding = GetOrCreateAttachedBinding(formFieldRoot); - if (attached.HasValue && attached.Validators.Length > 0) + var produces = valCtx is not null + && attached is not null + && !string.IsNullOrEmpty(attached.FieldName) + && attached.HasValue + && attached.Validators.Length > 0; + var nextField = produces ? attached!.FieldName : null; + + if (binding.Context is { } previousCtx && binding.Field is { } previousField + && (!ReferenceEquals(previousCtx, valCtx) + || !string.Equals(previousField, nextField, StringComparison.Ordinal))) + { + previousCtx.RetireProducer(previousField, ValidationContext.SyncProducer); + binding.Context = null; + binding.Field = null; + } + + if (valCtx is null || attached is null || string.IsNullOrEmpty(attached.FieldName)) return; + + if (produces) { ValidationReconciler.ValidateAttached(valCtx, attached, attached.Value); + binding.Context = valCtx; + binding.Field = attached.FieldName; return; } @@ -149,20 +206,8 @@ private static void ApplyAttachedValidation(ValidationContext? valCtx, Validatio var attached = newFf.Content.GetAttached(); var valCtx = reconciler.ReadContext(ValidationContexts.Current); - // A conditional field name can move 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, or showing an error against a control that moved on (issue #1262 - // review). - var previousField = FormFieldHelpers.ResolveFieldName(oldFf.FieldName, oldFf.Content); - if (valCtx is not null - && !string.IsNullOrEmpty(previousField) - && !string.Equals(previousField, fieldName, StringComparison.Ordinal)) - { - valCtx.RetireProducer(previousField, ValidationContext.SyncProducer); - } + ApplyAttachedValidation(panel, valCtx, attached); - ApplyAttachedValidation(valCtx, attached); // [0] Update label if (panel.Children[0] is TextBlock labelTb) diff --git a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs index 0d07e8398..e404d7d18 100644 --- a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs +++ b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs @@ -1637,4 +1637,65 @@ public override async Task RunAsync() 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(); + } + } } diff --git a/tests/Reactor.AppTests.Host/SelfTest/SelfTestFixtureRegistry.cs b/tests/Reactor.AppTests.Host/SelfTest/SelfTestFixtureRegistry.cs index 681514f73..620c77efe 100644 --- a/tests/Reactor.AppTests.Host/SelfTest/SelfTestFixtureRegistry.cs +++ b/tests/Reactor.AppTests.Host/SelfTest/SelfTestFixtureRegistry.cs @@ -825,6 +825,7 @@ internal static class SelfTestFixtureRegistry "ValCov_Issue1262_DetachRetiresRuleVerdict", "ValCov_Issue1262_OffThreadMutationMarshals", "ValCov_Issue1262_FormFieldNameMigration", + "ValCov_Issue1262_AttachedValidationStops", // Controls coverage — MaskEngine, InputFormatter, AutoSuggest "ControlsCov_MaskEngineBasic", "ControlsCov_MaskEngineNavigation", @@ -2744,6 +2745,7 @@ internal static string[] StaleTierDeclarations() => "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), // Controls coverage "ControlsCov_MaskEngineBasic" => new ControlsCoverageFixtures.MaskEngineBasic(harness), "ControlsCov_MaskEngineNavigation" => new ControlsCoverageFixtures.MaskEngineNavigation(harness), From e2a012494e359f862819c3e8cee57c01f404d0d8 Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Thu, 24 Sep 2026 07:20:33 -0700 Subject: [PATCH 48/67] Take the verdict with the control that leaves the tree `.Validate(field, value, ...)` installs its message while the owning component renders, and nothing tracked what became of it 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 - permanently, with no way to clear it short of ClearAll(). The same held for a whole FormField: its unmount cleared only the blur binding, and normal unmount never reaches DetachReactorState, so the verdict (and the context reference) outlived the form. The mounted control is the only thing whose lifetime tracks the attachment, so the contribution is now recorded against it - on the same native attached state as the other validation bindings - and withdrawn from the generic unmount path, which covers pooled and replaced controls alike. Withdrawal has to be conditional to be safe. 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, so an unconditional retraction on the way out would erase the verdict that had just replaced it and report the form valid. Each producer slot now carries a stamp issued on every write, and a binding retracts only while its stamp is still the current one. Comparing stamps rather than message instances is what makes this exact: ApplyOwned deliberately keeps the stored instances stable across a pass that reproduces the same verdict. Recording rather than re-running the validators is deliberate too. Reconcile- time evaluation is FormField's job; doing it here as well would start judging the elements that are deliberately left declarative - those assembled outside a render pass. The stamp keeps that safe, because a contribution nobody made matches no live write. ValCov_Issue1262_UnmountWithdrawsVerdict covers all three: a bare validated control behind a condition, a whole FormField behind one, and the replacement guard. Disabling the unmount reset reddens the three withdrawal checks; dropping the stamp comparison reddens the replacement check alone. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 373c7212-34f6-4ddf-8ae9-59553c77cf48 --- CHANGELOG.md | 12 ++ .../Controls/Validation/ValidationContext.cs | 60 +++++++ src/Reactor/Core/Reconciler.Mount.cs | 5 + src/Reactor/Core/Reconciler.Update.cs | 8 + src/Reactor/Core/Reconciler.cs | 12 ++ .../Core/V1Protocol/CompositeLifecycle.cs | 100 +++++++++-- .../Fixtures/ValidationCoverageFixtures.cs | 156 ++++++++++++++++++ .../SelfTest/SelfTestFixtureRegistry.cs | 2 + 8 files changed, 337 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a3b0fb6ed..b54c07bc7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -182,6 +182,18 @@ Conventions for contributors: but no focus or blur handler existed and `MarkTouched` was reachable only from app code, so the documented `FormField` example stayed silent forever (issue #1262). +- A validated control leaving the tree left its verdict behind. `.Validate(field, + value, …)` installs its message while the owning component renders, and nothing + tracked what became of it: a control behind a condition installed an error on the + pass that showed it and then simply stopped being rendered, so the context stayed + invalid over a field with no control — permanently, with no way to clear it short + of `ClearAll()`. The same held for a whole `FormField`, whose unmount cleared only + its blur binding. The contribution is now recorded against the mounted control and + withdrawn when it is unmounted, when the field moves, or when the attachment stops + producing. Withdrawal is conditional on still owning the slot, so replacing a + `FormField`'s content — which installs the incoming verdict before unmounting the + outgoing control — no longer lets the departing control erase the verdict that + replaced it (issue #1262). ### Security diff --git a/src/Reactor/Controls/Validation/ValidationContext.cs b/src/Reactor/Controls/Validation/ValidationContext.cs index 89eb9fc23..38ec68e11 100644 --- a/src/Reactor/Controls/Validation/ValidationContext.cs +++ b/src/Reactor/Controls/Validation/ValidationContext.cs @@ -14,6 +14,12 @@ public sealed class ValidationContext private readonly Dictionary>> _owned = 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; @@ -479,9 +485,36 @@ private bool ApplyOwnedLocked(string field, string producer, List(StringComparer.Ordinal); + byProducer[producer] = token; + return token; + } + + /// + /// 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; @@ -688,6 +721,7 @@ public void ClearAll() _messages.Clear(); _externalMessages.Clear(); _owned.Clear(); + _producerStamp.Clear(); _asyncGeneration.Clear(); InvalidateRuleSetsLocked(null); if (changed) BumpVersionLocked(messagesOnly: true); @@ -1149,6 +1183,31 @@ internal void RetireProducer(string field, string producer) if (changed) RaiseChanged(messagesOnly: true); } + /// + /// 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. + /// + /// + internal void RetireProducer(string field, string producer, long expectedStamp) + { + lock (_lock) + { + var current = _producerStamp.TryGetValue(field, out var stamps) + && stamps.TryGetValue(producer, out var stamp) ? stamp : 0; + if (current != expectedStamp) return; + } + RetireProducer(field, producer); + } + /// /// Withdraws every async contribution to a field and retires its in-flight passes. /// @@ -1266,6 +1325,7 @@ public bool IsDirty() _messages.Clear(); _externalMessages.Clear(); _owned.Clear(); + _producerStamp.Clear(); _asyncGeneration.Clear(); InvalidateRuleSetsLocked(null); diff --git a/src/Reactor/Core/Reconciler.Mount.cs b/src/Reactor/Core/Reconciler.Mount.cs index 92594b64e..ce8fb7a30 100644 --- a/src/Reactor/Core/Reconciler.Mount.cs +++ b/src/Reactor/Core/Reconciler.Mount.cs @@ -152,6 +152,11 @@ 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. + if (element.Attached is not null && control is FrameworkElement valFe) + V1Protocol.CompositeLifecycle.TrackElementValidation( + this, valFe, element.GetAttached()); // Re-apply the TitleBar's caption-derived height after modifiers so a // .Tall() without an explicit .Height(...) still sizes the control. diff --git a/src/Reactor/Core/Reconciler.Update.cs b/src/Reactor/Core/Reconciler.Update.cs index 210c10694..47f57bac1 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; @@ -257,6 +258,13 @@ 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. + if ((newEl.Attached is not null || oldEl.Attached is not null) + && target is FrameworkElement valFe) + V1Protocol.CompositeLifecycle.TrackElementValidation( + this, valFe, newEl.GetAttached()); // 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 f9326af47..361eee9d4 100644 --- a/src/Reactor/Core/Reconciler.cs +++ b/src/Reactor/Core/Reconciler.cs @@ -2214,6 +2214,18 @@ 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. + if (control is FrameworkElement valFe + && valFe.GetValue(ReactorAttached.StateProperty) is ReactorState valState) + { + (valState.ValidationAttachedBinding as IValidationBindingReset)?.Reset(); + } + // 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 8fe0b0fb8..c75d06734 100644 --- a/src/Reactor/Core/V1Protocol/CompositeLifecycle.cs +++ b/src/Reactor/Core/V1Protocol/CompositeLifecycle.cs @@ -108,16 +108,47 @@ private sealed class AttachedValidationBinding : Reconciler.IValidationBindingRe { internal ValidationContext? Context; internal string? Field; + internal long Stamp; public void Reset() { if (Context is { } ctx && Field is { } field) - ctx.RetireProducer(field, ValidationContext.SyncProducer); + 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); } } + 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(); @@ -130,6 +161,53 @@ private static AttachedValidationBinding GetOrCreateAttachedBinding(UIElement fo 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( + Reconciler reconciler, FrameworkElement fe, ValidationAttached? attached) + { + var produces = Produces(attached); + + // 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 = produces ? GetOrCreateAttachedBinding(fe) : TryGetAttachedBinding(fe); + if (binding is null) return; + + var valCtx = produces ? reconciler.ReadContext(ValidationContexts.Current) : null; + binding.WithdrawIfMoved(valCtx, produces ? attached!.FieldName : null); + + if (produces && valCtx is not null) binding.Record(valCtx, attached!.FieldName); + } + /// /// Runs a content element's attached synchronous validators against the context a /// FormField resolved, and makes sure the field exists either way. @@ -162,29 +240,15 @@ private static void ApplyAttachedValidation( { var binding = GetOrCreateAttachedBinding(formFieldRoot); - var produces = valCtx is not null - && attached is not null - && !string.IsNullOrEmpty(attached.FieldName) - && attached.HasValue - && attached.Validators.Length > 0; - var nextField = produces ? attached!.FieldName : null; - - if (binding.Context is { } previousCtx && binding.Field is { } previousField - && (!ReferenceEquals(previousCtx, valCtx) - || !string.Equals(previousField, nextField, StringComparison.Ordinal))) - { - previousCtx.RetireProducer(previousField, ValidationContext.SyncProducer); - binding.Context = null; - binding.Field = null; - } + 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) { ValidationReconciler.ValidateAttached(valCtx, attached, attached.Value); - binding.Context = valCtx; - binding.Field = attached.FieldName; + binding.Record(valCtx, attached.FieldName); return; } diff --git a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs index e404d7d18..9332b075c 100644 --- a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs +++ b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs @@ -1698,4 +1698,160 @@ public override async Task RunAsync() await Harness.Render(); } } + + // ════════════════════════════════════════════════════════════════════════ + // Issue #1262 review — a validated control leaving the tree. + // + // `.Validate(field, value, …)` installs its verdict while the owning + // component renders. Nothing watched what became of that verdict: 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, and with no way to clear it + // short of ClearAll(). The same held for a whole FormField, whose unmount + // cleared only the blur binding. + // + // The third case is the guard: replacing a FormField's content installs + // the incoming control's verdict *before* the outgoing one is unmounted, + // so an unconditional retraction on the way out would erase the verdict + // that had just replaced it and report the form valid. + // ════════════════════════════════════════════════════════════════════════ + + internal sealed record BareValidateProps(bool Show, 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. + return VStack(8, + props.Show + ? TextBox("").Validate("email", "", Validate.Required("Email is required")) + : TextBlock("hidden"), + TextBlock("bare-tail")); + } + } + + internal class Issue1262_ValidatedControlUnmountWithdraws(Harness h) : SelfTestFixtureBase(h) + { + public override async Task RunAsync() + { + await BareControlAsync(); + await WholeFormFieldAsync(); + await ContentReplacedAsync(); + + var done = H.CreateHost(); + done.Mount(c => TextBlock("Issue1262 unmount withdraw done")); + await Harness.Render(); + } + + // A bare `.Validate()` — no FormField anywhere — behind a condition. + private async Task BareControlAsync() + { + var host = H.CreateHost(); + Action? setShow = null; + ValidationContext? ctx = null; + + host.Mount(c => + { + var (show, set) = c.UseState(true); + setShow = set; + + return VStack(12, + Component( + new BareValidateProps(show, found => ctx = found)), + TextBlock("host")); + }); + + await Harness.Render(); + H.Check("Issue1262_Unmount_BareContextResolved", ctx is not null); + if (ctx is null) return; + + H.Check("Issue1262_Unmount_BareInitialError", ctx.GetMessages("email").Count == 1, + $"email={ctx.GetMessages("email").Count}"); + + setShow!(false); + await Harness.Render(); + await Harness.Render(); + + H.Check("Issue1262_Unmount_BareWithdrawn", ctx.GetMessages("email").Count == 0, + $"remaining={string.Join("|", ctx.GetMessages("email").Select(m => m.Text))}"); + H.Check("Issue1262_Unmount_BareContextValid", ctx.IsValid(), + $"valid={ctx.IsValid()}"); + } + + // 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))}"); + } + + // Guard: the outgoing control must not take the incoming one's verdict + // with it. Swapping the content element type forces a replace-then- + // unmount rather than an in-place update. + private async Task ContentReplacedAsync() + { + var ctx = new ValidationContext(); + var host = H.CreateHost(); + Action? setAlt = null; + + host.Mount(c => + { + var (alt, set) = c.UseState(false); + setAlt = set; + + Element content = alt + ? PasswordBox("").Validate("email", "", Validate.Required("Email is required")) + : TextBox("").Validate("email", "", Validate.Required("Email is required")); + + return VStack(12, + FormField(content, label: "Email", showWhen: ShowWhen.Always)) + .Provide(ValidationContexts.Current, ctx); + }); + + await Harness.Render(); + H.Check("Issue1262_Unmount_ReplaceInitialError", ctx.GetMessages("email").Count == 1, + $"email={ctx.GetMessages("email").Count}"); + + setAlt!(true); + await Harness.Render(); + await Harness.Render(); + + H.Check("Issue1262_Unmount_ReplaceKeepsVerdict", ctx.GetMessages("email").Count == 1, + $"email={ctx.GetMessages("email").Count}"); + } + } } diff --git a/tests/Reactor.AppTests.Host/SelfTest/SelfTestFixtureRegistry.cs b/tests/Reactor.AppTests.Host/SelfTest/SelfTestFixtureRegistry.cs index 620c77efe..9d30d0026 100644 --- a/tests/Reactor.AppTests.Host/SelfTest/SelfTestFixtureRegistry.cs +++ b/tests/Reactor.AppTests.Host/SelfTest/SelfTestFixtureRegistry.cs @@ -826,6 +826,7 @@ internal static class SelfTestFixtureRegistry "ValCov_Issue1262_OffThreadMutationMarshals", "ValCov_Issue1262_FormFieldNameMigration", "ValCov_Issue1262_AttachedValidationStops", + "ValCov_Issue1262_UnmountWithdrawsVerdict", // Controls coverage — MaskEngine, InputFormatter, AutoSuggest "ControlsCov_MaskEngineBasic", "ControlsCov_MaskEngineNavigation", @@ -2746,6 +2747,7 @@ internal static string[] StaleTierDeclarations() => "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), // Controls coverage "ControlsCov_MaskEngineBasic" => new ControlsCoverageFixtures.MaskEngineBasic(harness), "ControlsCov_MaskEngineNavigation" => new ControlsCoverageFixtures.MaskEngineNavigation(harness), From 145e99e6e89350217ce57d31c63679e3bb5bbe88 Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Thu, 24 Sep 2026 08:21:45 -0700 Subject: [PATCH 49/67] Make a validated control's claim exact, and its withdrawal reachable Four follow-ups to the lifetime binding, each a case where "this control's verdict" was identified by something that is not actually specific to it. Resolving the binding at mount time was guesswork. The eager write goes to ValidationRenderScope.Current, and re-resolving the reconciler's context later is not the same thing: an explicit .Provide(...) inside a component that owns a local context separates them, so the binding named the provided context while the verdict lived in the local one and unmount never retracted it. Re-reading the field's current stamp had the same shape of error between siblings - two controls validating one field both captured the last one's stamp, so either could retract the other. The write itself now records which context it reached and which stamp it was issued, keyed by the attachment instance, and the mounted control inherits that exact claim. Chained links needed the same treatment. Every link evaluates eagerly, because a chain that ran only at its end would drop the earlier links' validators for a bare control - but the attachment that survives carries only the final field name, so .Validate("a", x, ...).Validate("b", y, ...) left field "a" invalid forever with no control associated with it. A link that moves the field now withdraws what the earlier link installed, identified by that link's own claim. Two links on one field carrying different values were worse: each recorded its own value, so the current-value map flipped between them every pass. Both writes are real value changes, so the frame announced one every time, which repainted, which churned again. Net-zero suppression trusted a boolean for this, and a boolean cannot tell churn from change. It now compares values, touched flags and the registered set the way it already compared messages. Measured: with the comparison stubbed out, the fixture's settle loop goes from zero additional renders to ~48,000. Removal tears down through UnmountAndCollect, not UnmountRecursive, so a validated control dropped from a parent's children kept its verdict while a replaced one did not. Both paths now call one helper; each is covered by its own case, and each reddens alone. Finally, ApplyOwned stamped the slot even while dropping its ownership, so every retired producer left an entry behind - unbounded, because every mounted rule gets a fresh rule#N identity. A stamp now exists exactly while its producer owns messages. The skip-arm refresh this started as was removed rather than kept: a verdict is republished only by a component that re-rendered, whose output is on the dirty ancestor path that the arm declines. Instrumenting it across the whole selftest corpus produced zero validated hits. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 373c7212-34f6-4ddf-8ae9-59553c77cf48 --- CHANGELOG.md | 21 ++ .../Controls/Validation/ValidateExtensions.cs | 28 ++ .../Controls/Validation/ValidationContext.cs | 85 +++++- .../Validation/ValidationRenderScope.cs | 64 ++++- src/Reactor/Core/Reconciler.Mount.cs | 2 +- src/Reactor/Core/Reconciler.Update.cs | 7 +- src/Reactor/Core/Reconciler.cs | 33 ++- .../Core/V1Protocol/CompositeLifecycle.cs | 28 +- .../Fixtures/ValidationCoverageFixtures.cs | 266 ++++++++++++++---- .../ValidationRenderScopeTests.cs | 64 ++++- 10 files changed, 515 insertions(+), 83 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b54c07bc7..e20d1fc47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -194,6 +194,27 @@ Conventions for contributors: `FormField`'s content — which installs the incoming verdict before unmounting the outgoing control — no longer lets the departing control erase the verdict that replaced it (issue #1262). +- A chain whose later link moved the attachment to another field orphaned the + earlier link's verdict. Every link evaluates eagerly, because a chain that ran + only at its end would drop the earlier links' validators for a bare control, but + the attachment that survives carries only the final `FieldName` — so + `.Validate("a", x, …).Validate("b", y, …)` left field `a` permanently invalid + with no control associated with it. A link that moves the field now withdraws + what the earlier link installed (issue #1262). +- Two links on one field carrying different values repainted forever. Each link + recorded its own value, so the current-value map flipped to the first link's and + back to the second's every pass; both writes were real value changes, so the + frame announced one every time, which repainted, which churned again. Net-zero + suppression now covers values, touched flags and the registered set as well as + messages, so a pass that ends where it started is silent whatever it churned + (issue #1262). +- A validated control removed from a parent's children kept its verdict: removal + tears down through the pooling traversal, not the ordinary unmount, and only the + latter withdrew. Both paths now go through one helper (issue #1262). +- `ValidationContext` accumulated an ownership stamp per retired producer. Every + mounted rule gets a fresh `rule#N` identity, so a long-lived context that saw + rules mount and unmount grew one entry per mount without bound. A stamp now + exists exactly while its producer owns messages (issue #1262). ### Security diff --git a/src/Reactor/Controls/Validation/ValidateExtensions.cs b/src/Reactor/Controls/Validation/ValidateExtensions.cs index 4fd551cdf..a4129d6c9 100644 --- a/src/Reactor/Controls/Validation/ValidateExtensions.cs +++ b/src/Reactor/Controls/Validation/ValidateExtensions.cs @@ -61,6 +61,7 @@ public static T Validate(this T el, string fieldName, params IValidator[] val // 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); @@ -97,6 +98,7 @@ public static T Validate(this T el, string fieldName, object? value, params I } : new ValidationAttached(fieldName, validators, []) { Value = value, HasValue = true }; + SupersedeEarlierLink(existing, fieldName); RunDuringRender(merged, value); return (T)el.SetAttached(merged); } @@ -114,6 +116,7 @@ public static T ValidateAsync(this T el, string fieldName, params IAsyncValid AsyncValidators = [.. existing.AsyncValidators, .. asyncValidators] } : new ValidationAttached(fieldName, [], asyncValidators); + SupersedeEarlierLink(existing, fieldName); return (T)el.SetAttached(merged); } @@ -149,6 +152,7 @@ public static T ValidateAsync(this T el, string fieldName, object? value, par // 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); return (T)el.SetAttached(merged); } @@ -165,6 +169,30 @@ private static void RunDuringRender(ValidationAttached attached, object? value) 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); } /// diff --git a/src/Reactor/Controls/Validation/ValidationContext.cs b/src/Reactor/Controls/Validation/ValidationContext.cs index 38ec68e11..618f736c9 100644 --- a/src/Reactor/Controls/Validation/ValidationContext.cs +++ b/src/Reactor/Controls/Validation/ValidationContext.cs @@ -114,7 +114,12 @@ private void RaiseChanged(bool messagesOnly = false) // 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; + lock (_lock) + { + _lastNotifiedMessages = null; + _lastNotifiedValues = null; + _lastNotifiedTouched = null; + } _changed?.Invoke(); } @@ -153,7 +158,7 @@ private void DeliverDeferred() // 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 + if ((!_frameTouchedNonMessageState || NonMessageStateUnchangedLocked()) && _lastNotifiedMessages is not null && string.Equals(_lastNotifiedMessages, snapshot, StringComparison.Ordinal)) { @@ -169,6 +174,7 @@ private void DeliverDeferred() } _lastNotifiedMessages = snapshot; + CaptureNonMessageStateLocked(); _frameTouchedNonMessageState = false; handler = _changed; @@ -239,9 +245,46 @@ private static void AppendCounted(global::System.Text.StringBuilder sb, string? } private string? _lastNotifiedMessages; + private Dictionary? _lastNotifiedValues; + private HashSet? _lastNotifiedTouched; + 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 || _lastNotifiedTouched is null) return false; + if (_lastNotifiedRegistered != _registeredFields.Count) return false; + if (_lastNotifiedValues.Count != _currentValues.Count) return false; + if (!_lastNotifiedTouched.SetEquals(_touchedFields)) return false; + + foreach (var (field, value) in _currentValues) + { + if (!_lastNotifiedValues.TryGetValue(field, out var previous)) return false; + if (!Equals(previous, value)) return false; + } + return true; + } + + private void CaptureNonMessageStateLocked() + { + _lastNotifiedValues = new Dictionary(_currentValues); + _lastNotifiedTouched = new HashSet(_touchedFields, StringComparer.Ordinal); + _lastNotifiedRegistered = _registeredFields.Count; + } + /// /// Bumps , except for a message-only change made while a render /// is in flight: those are held until the frame closes and bumped once, and only if @@ -485,21 +528,47 @@ private bool ApplyOwnedLocked(string field, string producer, List(StringComparer.Ordinal); byProducer[producer] = token; - return 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; + } + } } /// diff --git a/src/Reactor/Controls/Validation/ValidationRenderScope.cs b/src/Reactor/Controls/Validation/ValidationRenderScope.cs index 3be0562e4..a2efabcf8 100644 --- a/src/Reactor/Controls/Validation/ValidationRenderScope.cs +++ b/src/Reactor/Controls/Validation/ValidationRenderScope.cs @@ -34,6 +34,57 @@ internal static class ValidationRenderScope [ThreadStatic] private static ValidationContext? t_pendingProvide; [ThreadStatic] private static int t_depth; [ThreadStatic] private static List? t_deferred; + [ThreadStatic] private static Dictionary? t_owned; + + /// + /// 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; + } /// /// The context .Validate() should push results into, or null when no @@ -85,6 +136,14 @@ private static void FlushDeferredNotifications() /// internal static Frame Begin(ValidationContext? inherited) { + // Claims from the previous pass are dropped as the next one opens, not as the + // last one closed: the mount that consumes a claim does not always run inside a + // frame — a host's first render is not wrapped in one — so clearing on close + // discarded the claim before the control that inherits it existed. Anything + // unclaimed belongs to a superseded chain link or an element that was never + // mounted, and at most one pass' worth is ever held. + if (t_depth == 0) t_owned = null; + var frame = new Frame(t_context, t_pendingProvide); t_context = inherited; t_pendingProvide = null; @@ -164,7 +223,10 @@ public void Dispose() t_context = _previousContext; t_pendingProvide = _previousPendingProvide; if (t_depth > 0) t_depth--; - if (t_depth == 0) FlushDeferredNotifications(); + if (t_depth == 0) + { + FlushDeferredNotifications(); + } } } } diff --git a/src/Reactor/Core/Reconciler.Mount.cs b/src/Reactor/Core/Reconciler.Mount.cs index ce8fb7a30..878005085 100644 --- a/src/Reactor/Core/Reconciler.Mount.cs +++ b/src/Reactor/Core/Reconciler.Mount.cs @@ -156,7 +156,7 @@ public sealed partial class Reconciler // lifetime so it is withdrawn when the control leaves the tree. if (element.Attached is not null && control is FrameworkElement valFe) V1Protocol.CompositeLifecycle.TrackElementValidation( - this, valFe, element.GetAttached()); + valFe, element.GetAttached()); // Re-apply the TitleBar's caption-derived height after modifiers so a // .Tall() without an explicit .Height(...) still sizes the control. diff --git a/src/Reactor/Core/Reconciler.Update.cs b/src/Reactor/Core/Reconciler.Update.cs index 47f57bac1..701c89359 100644 --- a/src/Reactor/Core/Reconciler.Update.cs +++ b/src/Reactor/Core/Reconciler.Update.cs @@ -128,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. @@ -264,7 +269,7 @@ public sealed partial class Reconciler if ((newEl.Attached is not null || oldEl.Attached is not null) && target is FrameworkElement valFe) V1Protocol.CompositeLifecycle.TrackElementValidation( - this, valFe, newEl.GetAttached()); + valFe, newEl.GetAttached()); // 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 361eee9d4..928af21dd 100644 --- a/src/Reactor/Core/Reconciler.cs +++ b/src/Reactor/Core/Reconciler.cs @@ -852,6 +852,31 @@ 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(); + } + } + // ════════════════════════════════════════════════════════════════════ // Lazy event wiring for poolable types // ════════════════════════════════════════════════════════════════════ @@ -2220,11 +2245,7 @@ private void UnmountRecursive(UIElement control) // 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. - if (control is FrameworkElement valFe - && valFe.GetValue(ReactorAttached.StateProperty) is ReactorState valState) - { - (valState.ValidationAttachedBinding as IValidationBindingReset)?.Reset(); - } + WithdrawAttachedValidation(control); // OnUnmountAction (.OnUnmount) — imperative teardown half of .OnMount. if (control is FrameworkElement umFe && _onUnmountActions.TryGetValue(umFe, out var onUnmount)) @@ -2593,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 c75d06734..50dc01660 100644 --- a/src/Reactor/Core/V1Protocol/CompositeLifecycle.cs +++ b/src/Reactor/Core/V1Protocol/CompositeLifecycle.cs @@ -141,6 +141,17 @@ internal void Record(ValidationContext ctx, string field) 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) @@ -192,20 +203,23 @@ private static bool Produces(ValidationAttached? attached) /// contribution nobody made matches no live write. /// /// - internal static void TrackElementValidation( - Reconciler reconciler, FrameworkElement fe, ValidationAttached? attached) + internal static void TrackElementValidation(FrameworkElement fe, ValidationAttached? attached) { - var produces = Produces(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 = produces ? GetOrCreateAttachedBinding(fe) : TryGetAttachedBinding(fe); + var binding = claimed ? GetOrCreateAttachedBinding(fe) : TryGetAttachedBinding(fe); if (binding is null) return; - var valCtx = produces ? reconciler.ReadContext(ValidationContexts.Current) : null; - binding.WithdrawIfMoved(valCtx, produces ? attached!.FieldName : null); + binding.WithdrawIfMoved( + claimed ? owned.Context : null, + claimed ? attached!.FieldName : null); - if (produces && valCtx is not null) binding.Record(valCtx, attached!.FieldName); + if (claimed) binding.Adopt(owned.Context, attached!.FieldName, owned.Stamp); } /// diff --git a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs index 9332b075c..070bd4bab 100644 --- a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs +++ b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs @@ -1699,24 +1699,42 @@ public override async Task RunAsync() } } + // ════════════════════════════════════════════════════════════════════════ - // Issue #1262 review — a validated control leaving the tree. + // 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. Nothing watched what became of that verdict: 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, and with no way to clear it - // short of ClearAll(). The same held for a whole FormField, whose unmount - // cleared only the blur binding. + // 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. // - // The third case is the guard: replacing a FormField's content installs - // the incoming control's verdict *before* the outgoing one is unmounted, - // so an unconditional retraction on the way out would erase the verdict - // that had just replaced it and report the form valid. + // 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 sealed record BareValidateProps(bool Show, Action OnContext); + 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, + } + + internal sealed record BareValidateProps( + BareValidateShape Shape, int Nudge, Action OnContext); internal sealed class BareValidateOwner : Component { @@ -1727,12 +1745,53 @@ public override Element Render() props.OnContext(ctx); // No FormField anywhere: the verdict exists only because `.Validate()` - // ran inside this component's render scope. - return VStack(8, - props.Show - ? TextBox("").Validate("email", "", Validate.Required("Email is required")) - : TextBlock("hidden"), - TextBlock("bare-tail")); + // 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")), + _ => VStack(8, + TextBlock("bare-head"), + PasswordBox("").Validate("email", "", Validate.Required("Email is required"))), + }; + } + } + + 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")); } } @@ -1740,52 +1799,117 @@ internal class Issue1262_ValidatedControlUnmountWithdraws(Harness h) : SelfTestF { public override async Task RunAsync() { - await BareControlAsync(); + await BareAsync(BareValidateShape.Hidden, "Hidden", nudge: false); + await BareAsync(BareValidateShape.Removed, "Removed", nudge: false); + await BareAsync(BareValidateShape.Hidden, "Skipped", nudge: true); + await BareReplacedAsync(); await WholeFormFieldAsync(); - await ContentReplacedAsync(); + await ChainMovesFieldAsync(); + await ChainValueChurnSettlesAsync(); var done = H.CreateHost(); done.Mount(c => TextBlock("Issue1262 unmount withdraw done")); await Harness.Render(); } - // A bare `.Validate()` — no FormField anywhere — behind a condition. - private async Task BareControlAsync() + // 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? setShow = null; + Action? setShape = null; + Action? setNudge = null; ValidationContext? ctx = null; host.Mount(c => { - var (show, set) = c.UseState(true); - setShow = set; + var (shape, set) = c.UseState(BareValidateShape.Present); + var (nudgeCount, setN) = c.UseState(0); + setShape = set; + setNudge = setN; return VStack(12, Component( - new BareValidateProps(show, found => ctx = found)), + new BareValidateProps(shape, nudgeCount, found => ctx = found)), TextBlock("host")); }); await Harness.Render(); - H.Check("Issue1262_Unmount_BareContextResolved", ctx is not null); + H.Check($"Issue1262_Unmount_Bare{label}Resolved", ctx is not null); if (ctx is null) return; - H.Check("Issue1262_Unmount_BareInitialError", ctx.GetMessages("email").Count == 1, + H.Check($"Issue1262_Unmount_Bare{label}InitialError", ctx.GetMessages("email").Count == 1, $"email={ctx.GetMessages("email").Count}"); - setShow!(false); + 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(); - H.Check("Issue1262_Unmount_BareWithdrawn", ctx.GetMessages("email").Count == 0, + // 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_BareContextValid", ctx.IsValid(), - $"valid={ctx.IsValid()}"); + H.Check($"Issue1262_Unmount_Bare{label}Valid", ctx.IsValid(), $"valid={ctx.IsValid()}"); } - // The whole FormField behind a condition: its own unmount has to - // withdraw, not just its content's. + // 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(); @@ -1819,39 +1943,63 @@ private async Task WholeFormFieldAsync() $"remaining={string.Join("|", ctx.GetMessages("email").Select(m => m.Text))}"); } - // Guard: the outgoing control must not take the incoming one's verdict - // with it. Swapping the content element type forces a replace-then- - // unmount rather than an in-place update. - private async Task ContentReplacedAsync() + // 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 ctx = new ValidationContext(); var host = H.CreateHost(); - Action? setAlt = null; + ValidationContext? ctx = null; + var renders = 0; - host.Mount(c => - { - var (alt, set) = c.UseState(false); - setAlt = set; + host.Mount(c => VStack(12, + Component( + new ChainProps(true, found => ctx = found, () => renders++)), + TextBlock("host"))); - Element content = alt - ? PasswordBox("").Validate("email", "", Validate.Required("Email is required")) - : TextBox("").Validate("email", "", Validate.Required("Email is required")); + await Harness.Render(); + if (ctx is null) { H.Check("Issue1262_Chain_ContextResolved", false); return; } - return VStack(12, - FormField(content, label: "Email", showWhen: ShowWhen.Always)) - .Provide(ValidationContexts.Current, ctx); - }); + // 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))}"); + } - await Harness.Render(); - H.Check("Issue1262_Unmount_ReplaceInitialError", ctx.GetMessages("email").Count == 1, - $"email={ctx.GetMessages("email").Count}"); + // 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"))); - setAlt!(true); - await Harness.Render(); await Harness.Render(); + if (ctx is null) { H.Check("Issue1262_Churn_ContextResolved", false); return; } - H.Check("Issue1262_Unmount_ReplaceKeepsVerdict", ctx.GetMessages("email").Count == 1, - $"email={ctx.GetMessages("email").Count}"); + 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)}"); } } -} +} \ No newline at end of file diff --git a/tests/Reactor.Tests/ValidationRenderScopeTests.cs b/tests/Reactor.Tests/ValidationRenderScopeTests.cs index 3923371f7..e4636c329 100644 --- a/tests/Reactor.Tests/ValidationRenderScopeTests.cs +++ b/tests/Reactor.Tests/ValidationRenderScopeTests.cs @@ -2235,4 +2235,66 @@ public async Task Async_Producers_On_One_Field_Do_Not_Cancel_Each_Other() 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); + } +} \ No newline at end of file From c0658a3b1582f81286506f3ea149141fd84e83e1 Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Thu, 24 Sep 2026 09:04:58 -0700 Subject: [PATCH 50/67] Keep a root render's validation claim alive into reconciliation A host's root Render() runs in its own frame and closes it before Reconcile opens the reconcile frame. Clearing claims on any top-level Begin therefore discarded every root-level .Validate() claim in the gap between the two, so a bare validated control rendered directly by ReactorHost never got a binding and left its verdict behind when it was removed. Only a render frame starts a new pass now; the reconcile frame is the consumer of what the render just claimed, and does not clear. That leaves the question of claims nobody takes. An element built inside a render and then dropped has already written its verdict, and nothing will ever be mounted to own it. The reconcile frame retires whatever it did not hand to a control, before the depth drops so the retractions join the pass's own deferred batch rather than announcing one at a time on the way out. RetireProducer's stamped overload compared and withdrew under two separate lock acquisitions. A writer installing a newer verdict in the window between them would have it deleted by the check that exists to protect it; both halves now share one acquisition, with Changed raised outside it as everywhere else. Two documentation corrections. The Changed event said render-time mutations are "not announced", which stopped being true when deferral was added - they are held and delivered once the pass ends, and only dropped when the pass is net-zero. And the changelog credited the visualizers as a consumer that ran attached validators; they never did, and FormField was the only one. ValCov_Issue1262 gains a root-host removal and a discarded-element case. Reverting the reconcile frame to clear reddens both; disabling the unconsumed retirement reddens the discarded one alone. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 373c7212-34f6-4ddf-8ae9-59553c77cf48 --- CHANGELOG.md | 10 ++- .../Controls/Validation/ValidationContext.cs | 40 ++++++--- .../Validation/ValidationRenderScope.cs | 61 +++++++++++--- .../Fixtures/ValidationCoverageFixtures.cs | 83 +++++++++++++++++++ 4 files changed, 167 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e20d1fc47..001345748 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,10 +57,12 @@ Conventions for contributors: ### Changed - `.Validate(fieldName, value, validators…)` now runs its validators during the - render that calls it, instead of only when a `FormField` or visualizer mounts - the element. Results are therefore readable by the same `Render()` that - produced them. The validator-only `.Validate(fieldName, validators…)` overload - is unchanged and still attach-only (spec 011 §1A.5, issue #1262). + render that calls it, instead of only when a `FormField` mounts the element — + the only consumer that ever ran them. (The visualizers are display-only: they + render what a context already holds.) Results are therefore readable by the + same `Render()` that produced them. The validator-only + `.Validate(fieldName, validators…)` overload is unchanged and still attach-only + (spec 011 §1A.5, issue #1262). - `UseValidationContext()` publishes a component-local context to the rendered subtree automatically, so `FormField`, `ValidationVisualizer`, and nested components no longer require an explicit diff --git a/src/Reactor/Controls/Validation/ValidationContext.cs b/src/Reactor/Controls/Validation/ValidationContext.cs index 618f736c9..c0e25961a 100644 --- a/src/Reactor/Controls/Validation/ValidationContext.cs +++ b/src/Reactor/Controls/Validation/ValidationContext.cs @@ -43,9 +43,13 @@ public sealed class ValidationContext /// 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 — the rendering - /// component reads the new state later in the same pass, and notifying would - /// re-enter the reconciler's re-render path from inside Render(). + /// 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 @@ -1240,18 +1244,23 @@ internal void RetireProducer(string field, string producer) bool changed; lock (_lock) { - if (_asyncGeneration.TryGetValue(field, out var byProducer)) - { - byProducer.Remove(producer); - if (byProducer.Count == 0) _asyncGeneration.Remove(field); - } - - changed = ApplyOwnedLocked(field, producer, []); + changed = RetireProducerLocked(field, producer); if (changed) BumpVersionLocked(messagesOnly: true); } 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. @@ -1265,16 +1274,25 @@ internal void RetireProducer(string field, string producer) /// 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(messagesOnly: true); } - RetireProducer(field, producer); + if (changed) RaiseChanged(messagesOnly: true); } /// diff --git a/src/Reactor/Controls/Validation/ValidationRenderScope.cs b/src/Reactor/Controls/Validation/ValidationRenderScope.cs index a2efabcf8..a7bdf44d5 100644 --- a/src/Reactor/Controls/Validation/ValidationRenderScope.cs +++ b/src/Reactor/Controls/Validation/ValidationRenderScope.cs @@ -134,17 +134,21 @@ private static void FlushDeferredNotifications() /// 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) + internal static Frame Begin(ValidationContext? inherited) => BeginCore(inherited, isReconcile: false); + + private static Frame BeginCore(ValidationContext? inherited, bool isReconcile) { - // Claims from the previous pass are dropped as the next one opens, not as the - // last one closed: the mount that consumes a claim does not always run inside a - // frame — a host's first render is not wrapped in one — so clearing on close - // discarded the claim before the control that inherits it existed. Anything - // unclaimed belongs to a superseded chain link or an element that was never - // mounted, and at most one pass' worth is ever held. - if (t_depth == 0) t_owned = null; - - var frame = new Frame(t_context, t_pendingProvide); + // A render frame opening at depth 0 starts a new pass, so the previous pass's + // claims are dropped 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 clear. 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). + if (t_depth == 0 && !isReconcile) t_owned = null; + + var frame = new Frame(t_context, t_pendingProvide, isReconcile); t_context = inherited; t_pendingProvide = null; t_depth++; @@ -167,7 +171,30 @@ internal static Frame Begin(ValidationContext? inherited) /// reached from reconcile code stays attach-only exactly as before. /// /// - internal static Frame BeginReconcile() => Begin(null); + internal static Frame BeginReconcile() => BeginCore(null, isReconcile: true); + + /// + /// 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. + /// + /// + private static void RetireUnconsumedClaims() + { + var owned = t_owned; + t_owned = null; + if (owned is null || owned.Count == 0) return; + + foreach (var (attached, claim) in owned) + { + claim.Context.RetireProducer( + attached.FieldName, ValidationContext.SyncProducer, claim.Stamp); + } + } /// /// Called by UseValidationContext() with the context it resolved. @@ -211,17 +238,27 @@ internal static Element ApplyProvide(Element rendered) { private readonly ValidationContext? _previousContext; private readonly ValidationContext? _previousPendingProvide; + private readonly bool _isReconcile; - internal Frame(ValidationContext? previousContext, ValidationContext? previousPendingProvide) + 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) { diff --git a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs index 070bd4bab..f09713669 100644 --- a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs +++ b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs @@ -1731,6 +1731,8 @@ internal enum BareValidateShape Removed, /// Same field, different control type: the replacement guard. Replaced, + /// Validated element built and then dropped without being rendered. + Discarded, } internal sealed record BareValidateProps( @@ -1759,11 +1761,23 @@ public override Element Render() 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(), _ => 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")); + } } internal sealed record ChainProps(bool Moved, Action OnContext, Action OnRender); @@ -1802,6 +1816,8 @@ 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 BareReplacedAsync(); await WholeFormFieldAsync(); await ChainMovesFieldAsync(); @@ -1812,6 +1828,49 @@ public override async Task RunAsync() 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. @@ -1874,6 +1933,30 @@ private async Task BareAsync(BareValidateShape gone, string label, bool nudge) 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()}"); + } + // 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. From c2dca0e80156aab343e8b800ca94153d2127dc3d Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Thu, 24 Sep 2026 09:46:02 -0700 Subject: [PATCH 51/67] Carry the sync claim through an async link, and settle aborted claims Two cases where a verdict was written and then left with no lifetime owner. A chain ending on an async link lost its sync verdict's owner. SupersedeEarlierLink takes the earlier attachment's claim, but the async overloads never made one of their own, so .Validate(f, v, ...).ValidateAsync(f, ...) left a live sync message that no mounted control could withdraw - a bare control kept it after unmount. Both async overloads now re-run the merged synchronous validators when the chain already carries a value, so the surviving attachment takes the claim over. The async validators are still attach-only: RunDuringRender does nothing when there are no sync validators, so a purely async attachment is unaffected. A root render that writes a verdict and then throws never reaches reconciliation, so nothing consumes or retires the claim it made. Claims left over when a new render pass opens are now withdrawn rather than discarded - after the frame is open, so the retractions defer into that pass instead of announcing inline at depth 0. Two fixtures, each reddening alone: a sync-then-async chain that is removed, and a root render that aborts after validating and recovers into a tree without the field. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 373c7212-34f6-4ddf-8ae9-59553c77cf48 --- .../Controls/Validation/ValidateExtensions.cs | 17 ++++ .../Validation/ValidationRenderScope.cs | 34 +++++-- .../Fixtures/ValidationCoverageFixtures.cs | 89 +++++++++++++++++++ 3 files changed, 132 insertions(+), 8 deletions(-) diff --git a/src/Reactor/Controls/Validation/ValidateExtensions.cs b/src/Reactor/Controls/Validation/ValidateExtensions.cs index a4129d6c9..47770fd21 100644 --- a/src/Reactor/Controls/Validation/ValidateExtensions.cs +++ b/src/Reactor/Controls/Validation/ValidateExtensions.cs @@ -117,6 +117,17 @@ public static T ValidateAsync(this T el, string fieldName, params IAsyncValid } : 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); } @@ -154,6 +165,12 @@ public static T ValidateAsync(this T el, string fieldName, object? value, par // 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); } diff --git a/src/Reactor/Controls/Validation/ValidationRenderScope.cs b/src/Reactor/Controls/Validation/ValidationRenderScope.cs index a7bdf44d5..ac78c66e2 100644 --- a/src/Reactor/Controls/Validation/ValidationRenderScope.cs +++ b/src/Reactor/Controls/Validation/ValidationRenderScope.cs @@ -139,19 +139,32 @@ private static void FlushDeferredNotifications() 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 dropped here rather than when the last frame closed: the mount that + // 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 clear. 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). - if (t_depth == 0 && !isReconcile) t_owned = null; + // 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; + if (t_depth == 0 && !isReconcile) + { + abandoned = t_owned; + t_owned = 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); + return frame; } @@ -187,9 +200,14 @@ private static void RetireUnconsumedClaims() { var owned = t_owned; t_owned = null; - if (owned is null || owned.Count == 0) return; + RetireClaims(owned); + } + + private static void RetireClaims(Dictionary? claims) + { + if (claims is null || claims.Count == 0) return; - foreach (var (attached, claim) in owned) + foreach (var (attached, claim) in claims) { claim.Context.RetireProducer( attached.FieldName, ValidationContext.SyncProducer, claim.Stamp); diff --git a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs index f09713669..5356d47c1 100644 --- a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs +++ b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs @@ -1818,6 +1818,8 @@ public override async Task RunAsync() await BareAsync(BareValidateShape.Hidden, "Skipped", nudge: true); await RootHostAsync(); await DiscardedElementAsync(); + await SyncThenAsyncChainAsync(); + await AbortedRootRenderAsync(); await BareReplacedAsync(); await WholeFormFieldAsync(); await ChainMovesFieldAsync(); @@ -1957,6 +1959,93 @@ private async Task DiscardedElementAsync() 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 next + // pass has to settle it rather than discard it, or the field stays in error + // for the lifetime of the context. + 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; + + H.Check("Issue1262_Aborted_VerdictWritten", ctx.GetMessages("aborted").Count == 1, + $"aborted={ctx.GetMessages("aborted").Count}"); + + // The recovered tree no longer renders the field at all. + 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))}"); + } + // 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. From bc40ab250bfa56bb961c84e65c75f3ea945aea4a Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Thu, 24 Sep 2026 10:26:26 -0700 Subject: [PATCH 52/67] Compare baselines too, and correct the async validation guide Net-zero suppression snapshotted current values, touched flags and the registered set, but not the baselines. SetInitialValue re-baselining an edited field flips IsDirty without touching any of those, so a component that re-baselines during render marked non-message state dirty and then had its notification dropped as "nothing changed" - another subscriber rendering dirty state kept the stale one. Baselines are now captured and compared alongside the values. The forms guide's async section documented ValidationContext.IsValidating, which does not exist anywhere in the tree, and said Validate.MustAsync runs automatically - the opposite of the ValidateAsync contract this PR documents everywhere else. It now states the real one (attach-only, driven from an effect through ValidateFieldAsync) and points at ValidationRuleAsync for the cross-field case that is run for you. The example is a compiled doc-app snippet rather than typed into the template, which is what the inline-snippet ledger requires; the "three behaviours" intro above it also listed four. ReBaseliningDuringRenderStillNotifies reddens when baselines are dropped from the comparison. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 373c7212-34f6-4ddf-8ae9-59553c77cf48 --- CHANGELOG.md | 18 +++++ docs/_pipeline/apps/forms/App.cs | 45 ++++++++++++ docs/_pipeline/templates/forms.md.dt | 28 ++++++-- docs/guide/forms.md | 68 +++++++++++++++++-- .../Controls/Validation/ValidationContext.cs | 16 ++++- .../ValidationRenderScopeTests.cs | 31 +++++++++ 6 files changed, 193 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 001345748..249af3ea5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -217,6 +217,24 @@ Conventions for contributors: mounted rule gets a fresh `rule#N` identity, so a long-lived context that saw rules mount and unmount grew one entry per mount without bound. A stamp now exists exactly while its producer owns messages (issue #1262). +- A chain ending on an async link left its synchronous verdict with no lifetime + owner, so `.Validate(f, v, …).ValidateAsync(f, …)` on a bare control kept the + sync message after the control was unmounted. The async overloads now re-run + the merged synchronous validators when the chain already carries a value; the + async validators themselves are still attach-only (issue #1262). +- A root render that wrote a verdict and then threw never reached + reconciliation, so nothing withdrew what it had installed and the field stayed + in error for the life of the context. Claims left over when the next render + pass opens are now withdrawn rather than discarded (issue #1262). +- Re-baselining an edited field with `SetInitialValue` during a render was + silently suppressed: it flips `IsDirty` without touching messages, touched + flags or the current value, and the net-zero comparison did not look at + baselines. A subscriber rendering dirty state kept the stale one (issue #1262). +- The forms guide documented `ValidationContext.IsValidating`, which does not + exist, and said `Validate.MustAsync` runs automatically. The async section now + describes the real contract — attach-only, run it yourself through + `ValidationReconciler.ValidateFieldAsync` — and points at `ValidationRuleAsync` + for the cross-field case that *is* run for you (issue #1262). ### Security diff --git a/docs/_pipeline/apps/forms/App.cs b/docs/_pipeline/apps/forms/App.cs index c293c2a15..076a57f70 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,49 @@ 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) + { + _ = ValidationReconciler.ValidateFieldAsync( + ctx, "email", email, + [Validate.MustAsync(IsEmailFree, "Email is taken")], + cts.Token); + } + return () => cts.Cancel(); + }, 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 +466,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 2c7c2b2f8..85d58af70 100644 --- a/docs/_pipeline/templates/forms.md.dt +++ b/docs/_pipeline/templates/forms.md.dt @@ -186,7 +186,7 @@ Key pieces: ### How validation runs -Three behaviours make the example above work without any extra wiring. They are +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 @@ -438,11 +438,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/forms.md b/docs/guide/forms.md index 073951609..928ff8490 100644 --- a/docs/guide/forms.md +++ b/docs/guide/forms.md @@ -349,7 +349,7 @@ Key pieces: ### How validation runs -Three behaviours make the example above work without any extra wiring. They are +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 @@ -779,11 +779,67 @@ 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) + { + _ = ValidationReconciler.ValidateFieldAsync( + ctx, "email", email, + [Validate.MustAsync(IsEmailFree, "Email is taken")], + cts.Token); + } + return () => cts.Cancel(); + }, 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/src/Reactor/Controls/Validation/ValidationContext.cs b/src/Reactor/Controls/Validation/ValidationContext.cs index c0e25961a..96a251517 100644 --- a/src/Reactor/Controls/Validation/ValidationContext.cs +++ b/src/Reactor/Controls/Validation/ValidationContext.cs @@ -122,6 +122,7 @@ private void RaiseChanged(bool messagesOnly = false) { _lastNotifiedMessages = null; _lastNotifiedValues = null; + _lastNotifiedInitials = null; _lastNotifiedTouched = null; } _changed?.Invoke(); @@ -250,6 +251,7 @@ private static void AppendCounted(global::System.Text.StringBuilder sb, string? private string? _lastNotifiedMessages; private Dictionary? _lastNotifiedValues; + private Dictionary? _lastNotifiedInitials; private HashSet? _lastNotifiedTouched; private int _lastNotifiedRegistered; private bool _frameTouchedNonMessageState; @@ -269,9 +271,11 @@ private static void AppendCounted(global::System.Text.StringBuilder sb, string? /// private bool NonMessageStateUnchangedLocked() { - if (_lastNotifiedValues is null || _lastNotifiedTouched is null) return false; + 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; foreach (var (field, value) in _currentValues) @@ -279,12 +283,22 @@ private bool NonMessageStateUnchangedLocked() 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; } diff --git a/tests/Reactor.Tests/ValidationRenderScopeTests.cs b/tests/Reactor.Tests/ValidationRenderScopeTests.cs index e4636c329..154cdde71 100644 --- a/tests/Reactor.Tests/ValidationRenderScopeTests.cs +++ b/tests/Reactor.Tests/ValidationRenderScopeTests.cs @@ -2297,4 +2297,35 @@ public void StampedRetireIsIgnoredOnceAnotherWriterOwnsTheSlot() 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}"); + } } \ No newline at end of file From 6a61979007b4557a8b06e418b0e40adc46dfc516 Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Thu, 24 Sep 2026 11:04:09 -0700 Subject: [PATCH 53/67] Hold Version through a render, reject rule batches whole, keep adopted slots Three findings, all of which turn a net-zero or failed operation into observable state change. Version was held during a render only for message-only writes. A chain such as .Validate("f", "", ...).Validate("f", "bb", ...) rewrites the current value twice per pass and lands where it started, so Changed was correctly suppressed while Version grew on every render forever - the one signal a UseMemo or UseEffect is most likely to be keyed on, and therefore the one where a silent leak costs the most. Every render-frame bump is now held and committed once, only when the pass ended somewhere different from where it started. The held bump also registers a deferred notification, so a bump with no notification of its own (BeginAsyncValidation) cannot strand it. EvaluateRules(ctx, rules...) evaluated rule by rule, so a batch containing an async rule installed the earlier verdicts and then threw. A caller cannot reason about a partial batch from a call that reported failure, so the batch is now rejected before anything is installed. Each rule's own Evaluate still rejects an async predicate; this makes the batch atomic rather than the rule safe. The third is a collision in the ownership model this PR introduced. Every value-carrying .Validate() on a field shares one producer slot, so a render that returns one validated control and also builds and drops another naming the same field leaves the dropped element holding the newer stamp - and retiring its unconsumed claim cleared the slot the mounted control depended on, reporting an invalid field as valid. A slot a mounted control has adopted is now left alone. The dropped element's verdict still wins the slot's contents, which is the pre-existing last-writer-wins behaviour for two elements naming one field; only the erasure is fixed, and that limit is stated in the code. Each fix reddens its own check: removing the deferral reddens four version tests, removing the preflight reddens the batch test, and removing the adoption guard reddens Issue1262_SameField_* with exactly the reported symptom (valid=True for a mounted invalid field). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 373c7212-34f6-4ddf-8ae9-59553c77cf48 --- CHANGELOG.md | 19 +++++ .../Controls/Validation/ValidationContext.cs | 62 ++++++++++------- .../Validation/ValidationReconciler.cs | 11 +++ .../Validation/ValidationRenderScope.cs | 42 ++++++++++- .../Core/V1Protocol/CompositeLifecycle.cs | 6 +- .../Fixtures/ValidationCoverageFixtures.cs | 45 ++++++++++++ .../ValidationRenderScopeTests.cs | 69 +++++++++++++++++++ 7 files changed, 225 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 249af3ea5..62a3dd866 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -235,6 +235,25 @@ Conventions for contributors: describes the real contract — attach-only, run it yourself through `ValidationReconciler.ValidateFieldAsync` — and points at `ValidationRuleAsync` for the cross-field case that *is* run for you (issue #1262). +- `Version` advanced on every render for a net-zero pass that churned a field's + *value*. Only message-only writes were held during a render, so a chain such as + `.Validate("f", "", …).Validate("f", "bb", …)` left `Changed` correctly silent + while `Version` grew without bound — breaking a `UseMemo` or `UseEffect` keyed + on it, which is the signal most likely to be used that way. Every render-frame + bump is now held and committed once, only if the pass ended somewhere different + from where it started (issue #1262). +- `ValidationReconciler.EvaluateRules(ctx, rules…)` mutated the context rule by + rule, so a batch containing an async rule installed the earlier verdicts before + throwing — a partial batch from a call that reported failure. The batch is now + rejected before anything is installed (issue #1262). +- A render that returned one validated control and also built and dropped another + naming the same field reported the mounted, invalid field as **valid**. Both + share one synchronous producer slot, and the dropped element held the newer + stamp, so withdrawing its unconsumed claim cleared the slot the mounted control + depended on. A slot a mounted control has adopted is now left alone. (Two + elements naming one field still share the slot, so the last writer's verdict + wins its contents — that is pre-existing, and only the erasure is fixed.) + (issue #1262) ### Security diff --git a/src/Reactor/Controls/Validation/ValidationContext.cs b/src/Reactor/Controls/Validation/ValidationContext.cs index 96a251517..2a7a4ff5f 100644 --- a/src/Reactor/Controls/Validation/ValidationContext.cs +++ b/src/Reactor/Controls/Validation/ValidationContext.cs @@ -304,20 +304,32 @@ private void CaptureNonMessageStateLocked() } /// - /// Bumps , except for a message-only change made while a render - /// is in flight: those are held until the frame closes and bumped once, and only if - /// the frame ended with different messages than it started with. + /// 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(bool messagesOnly) + private void BumpVersionLocked() { - if (messagesOnly && ValidationRenderScope.InRender) + 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++; @@ -372,7 +384,7 @@ public void Add(ValidationMessage message) _messages[message.Field] = list; } list.Add(message); - BumpVersionLocked(messagesOnly: true); + BumpVersionLocked(); } RaiseChanged(messagesOnly: true); } @@ -399,7 +411,7 @@ public void AddExternal(ValidationMessage message) _externalMessages[message.Field] = list; } list.Add(message); - BumpVersionLocked(messagesOnly: true); + BumpVersionLocked(); } RaiseChanged(messagesOnly: true); } @@ -424,7 +436,7 @@ public void Clear(string field) // cleared: dropping the token makes its result stale on arrival. _asyncGeneration.Remove(field); InvalidateRuleSetsLocked(field); - if (changed) BumpVersionLocked(messagesOnly: true); + if (changed) BumpVersionLocked(); } if (changed) RaiseChanged(messagesOnly: true); } @@ -443,7 +455,7 @@ internal void ClearInternal(string field) // As in Clear: a pending async pass must not repopulate what this dropped. _asyncGeneration.Remove(field); InvalidateRuleSetsLocked(field); - if (changed) BumpVersionLocked(messagesOnly: true); + if (changed) BumpVersionLocked(); } if (changed) RaiseChanged(messagesOnly: true); } @@ -475,7 +487,7 @@ internal void ApplyOwned(string field, string producer, List lock (_lock) { changed = ApplyOwnedLocked(field, producer, messages); - if (changed) BumpVersionLocked(messagesOnly: true); + if (changed) BumpVersionLocked(); } if (changed) RaiseChanged(messagesOnly: true); } @@ -644,7 +656,7 @@ internal void ApplyAsyncOwned(string field, string producer, int generation, Lis return; changed = ApplyOwnedLocked(field, producer, messages); - if (changed) BumpVersionLocked(messagesOnly: true); + if (changed) BumpVersionLocked(); } if (changed) RaiseChanged(messagesOnly: true); } @@ -688,7 +700,7 @@ internal int BeginAsyncValidation(string field, object? value) _externalMessages.Remove(field); RetractAsyncProducersLocked(field); InvalidateRuleSetsLocked(field); - BumpVersionLocked(messagesOnly: false); + BumpVersionLocked(); } token = unchecked(++_asyncTicket); @@ -777,7 +789,7 @@ internal void ApplyValidation(string field, object? value, 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 @@ -86,6 +87,26 @@ internal static bool TryTakeOwnership(ValidationAttached attached, out Ownership return false; } + /// + /// 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. @@ -147,10 +168,13 @@ private static Frame BeginCore(ValidationContext? inherited, bool isReconcile) // 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); @@ -163,7 +187,7 @@ private static Frame BeginCore(ValidationContext? inherited, bool isReconcile) // 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); + if (abandoned is not null) RetireClaims(abandoned, abandonedAdopted); return frame; } @@ -195,20 +219,32 @@ private static Frame BeginCore(ValidationContext? inherited, bool isReconcile) /// 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; - RetireClaims(owned); + t_adopted = null; + RetireClaims(owned, adopted); } - private static void RetireClaims(Dictionary? claims) + 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); } diff --git a/src/Reactor/Core/V1Protocol/CompositeLifecycle.cs b/src/Reactor/Core/V1Protocol/CompositeLifecycle.cs index 50dc01660..1c85bd583 100644 --- a/src/Reactor/Core/V1Protocol/CompositeLifecycle.cs +++ b/src/Reactor/Core/V1Protocol/CompositeLifecycle.cs @@ -219,7 +219,11 @@ internal static void TrackElementValidation(FrameworkElement fe, ValidationAttac claimed ? owned.Context : null, claimed ? attached!.FieldName : null); - if (claimed) binding.Adopt(owned.Context, attached!.FieldName, owned.Stamp); + if (claimed) + { + binding.Adopt(owned.Context, attached!.FieldName, owned.Stamp); + ValidationRenderScope.MarkAdopted(owned.Context, attached.FieldName); + } } /// diff --git a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs index 5356d47c1..7a4deb6f1 100644 --- a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs +++ b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs @@ -1733,6 +1733,11 @@ internal enum BareValidateShape 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( @@ -1762,6 +1767,7 @@ public override Element Render() 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"))), @@ -1778,6 +1784,19 @@ 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); @@ -1818,6 +1837,7 @@ public override async Task RunAsync() await BareAsync(BareValidateShape.Hidden, "Skipped", nudge: true); await RootHostAsync(); await DiscardedElementAsync(); + await DiscardedSameFieldAsync(); await SyncThenAsyncChainAsync(); await AbortedRootRenderAsync(); await BareReplacedAsync(); @@ -2046,6 +2066,31 @@ private async Task AbortedRootRenderAsync() $"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. diff --git a/tests/Reactor.Tests/ValidationRenderScopeTests.cs b/tests/Reactor.Tests/ValidationRenderScopeTests.cs index 154cdde71..821432a03 100644 --- a/tests/Reactor.Tests/ValidationRenderScopeTests.cs +++ b/tests/Reactor.Tests/ValidationRenderScopeTests.cs @@ -2328,4 +2328,73 @@ public void ReBaseliningDuringRenderStillNotifies() 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()); + } } \ No newline at end of file From 4dc717cfc10c29ed77701aa018354814be4c49e2 Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Thu, 24 Sep 2026 11:07:54 -0700 Subject: [PATCH 54/67] Dispose the CancellationTokenSource in the async-validation snippet The effect allocates a fresh source per run and only cancelled it, leaking one per keystroke. Flagged by code quality on the doc app, and worth more than the usual care because this is a snippet readers copy: an example that leaks is a pattern that spreads. Cancel stays in try and Dispose in finally so the source is released even if cancellation throws. Guide regenerated. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 373c7212-34f6-4ddf-8ae9-59553c77cf48 --- docs/_pipeline/apps/forms/App.cs | 9 ++++++++- docs/guide/forms.md | 9 ++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/docs/_pipeline/apps/forms/App.cs b/docs/_pipeline/apps/forms/App.cs index 076a57f70..9e06376d4 100644 --- a/docs/_pipeline/apps/forms/App.cs +++ b/docs/_pipeline/apps/forms/App.cs @@ -199,7 +199,14 @@ public override Element Render() [Validate.MustAsync(IsEmailFree, "Email is taken")], cts.Token); } - return () => cts.Cancel(); + // Cancel on cleanup so a check for a value the user has already replaced + // stops waiting, and dispose the source with it — the effect allocates a + // fresh one per run. + return () => + { + try { cts.Cancel(); } + finally { cts.Dispose(); } + }; }, email); return VStack(12, diff --git a/docs/guide/forms.md b/docs/guide/forms.md index 928ff8490..f9bd4dd0e 100644 --- a/docs/guide/forms.md +++ b/docs/guide/forms.md @@ -816,7 +816,14 @@ class AsyncValidationDemo : Component [Validate.MustAsync(IsEmailFree, "Email is taken")], cts.Token); } - return () => cts.Cancel(); + // Cancel on cleanup so a check for a value the user has already replaced + // stops waiting, and dispose the source with it — the effect allocates a + // fresh one per run. + return () => + { + try { cts.Cancel(); } + finally { cts.Dispose(); } + }; }, email); return VStack(12, From f3676cb154fdb0e6da09f1b7af3e372e14f03cae Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Thu, 24 Sep 2026 11:41:03 -0700 Subject: [PATCH 55/67] Attribute the off-thread render check by thread, not by counting The AOT selftest job caught this one, and it was my own bad oracle rather than a product defect: Issue1262_OffThread_NoSynchronousRenderFromWorker compared a render count taken on the worker against one taken before it, and read the difference as "the worker rendered". A marshalled render landing on the UI thread inside that same window moves the same counter, so the check reddened under AOT while its sibling Issue1262_OffThread_AllRendersOnUiThread passed in the very same run - which is the direct evidence that every render was, in fact, on the UI thread. Healthy and broken were separated only by timing. The check now attributes renders by thread id: no render may carry the worker's id. The thread list also becomes a ConcurrentQueue, since it is appended from the render and read from the worker, which raced even when nothing rendered off-thread. That leaves the question of whether the new check can fail at all, and honesty requires answering it rather than banking a green. It cannot be falsified by mutating the product: flipping UseValidationContext's setter to threadSafe: true - the exact choice this fixture exists to justify - leaves it green, so the re-render is marshalled somewhere below the setter. So the instrument is proven directly instead, with a positive control that runs the same filter over a queue deliberately holding a worker-thread entry and asserts it matches. Blinding that probe reddens it. The overstated claim in the fixture's header comment is corrected in place. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 373c7212-34f6-4ddf-8ae9-59553c77cf48 --- .../Fixtures/ValidationCoverageFixtures.cs | 43 ++++++++++++++----- 1 file changed, 33 insertions(+), 10 deletions(-) diff --git a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs index 7a4deb6f1..1a6ef4f4e 100644 --- a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs +++ b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs @@ -1526,12 +1526,14 @@ public override async Task RunAsync() { var host = H.CreateHost(); var uiThreadId = global::System.Environment.CurrentManagedThreadId; - var renderThreads = new List(); + // 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, id => renderThreads.Add(id))), + new OffThreadProps(ctx => captured = ctx, renderThreads.Enqueue)), TextBlock("host"))); await Harness.Render(); @@ -1543,9 +1545,10 @@ public override async Task RunAsync() // Exactly what a background async validator does when it resolves. global::System.Exception? thrown = null; - var rendersSeenInsideWorker = -1; + 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 @@ -1556,19 +1559,39 @@ await Task.Run(() => // surfacing as a COM or invalid-operation exception. thrown = ex; } - rendersSeenInsideWorker = renderThreads.Count; }); 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 - // before it returned. A threadSafe state setter invokes the re-render - // callback on the raising thread, which for a child component renders - // inline — on the worker. + // 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", - rendersSeenInsideWorker == rendersBefore, - $"before={rendersBefore} insideWorker={rendersSeenInsideWorker}"); + 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(); From cf91afa4cd766f761a75c641e2eb15bcc242b3c7 Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Thu, 24 Sep 2026 11:43:10 -0700 Subject: [PATCH 56/67] Correct the off-thread fixture's header claim The previous commit's message said this was done; it was not. The header still claimed the fixture "makes that choice falsifiable", which the same commit had just demonstrated to be false - flipping UseValidationContext to threadSafe: true leaves every check in it green. The comment now states what the fixture does establish (no throw, no worker-thread render, repaint on the UI thread) and what it does not, rather than the stronger claim. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 373c7212-34f6-4ddf-8ae9-59553c77cf48 --- .../SelfTest/Fixtures/ValidationCoverageFixtures.cs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs index 1a6ef4f4e..af8ff3c32 100644 --- a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs +++ b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs @@ -1500,7 +1500,15 @@ public override async Task RunAsync() // 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. This fixture is what makes that choice falsifiable. + // 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); From 29830b0ce90739bddc8f2b9ab976b5dee28b63f4 Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Thu, 24 Sep 2026 12:02:32 -0700 Subject: [PATCH 57/67] Filter the async preflight explicitly Code quality is right that this loop filters its sequence implicitly. Unlike the earlier Where suggestion on this PR, which was declined because that loop acted on every element, this one really does select a subset - the async rules whose presence rejects the batch - so the filter belongs in the sequence. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 373c7212-34f6-4ddf-8ae9-59553c77cf48 --- src/Reactor/Controls/Validation/ValidationReconciler.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Reactor/Controls/Validation/ValidationReconciler.cs b/src/Reactor/Controls/Validation/ValidationReconciler.cs index b3c512789..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; @@ -115,9 +116,10 @@ public static void EvaluateRules( // 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 rule in rules) + foreach (var asyncRule in rules.Where(rule => rule.AsyncPredicate is not null)) { - if (rule.AsyncPredicate is not null) rule.ComputeSync(); + // Throws, naming the offending field. + asyncRule.ComputeSync(); } for (var i = 0; i < rules.Length; i++) From 7021b33236b0a6b921cc945f44d85b282724defa Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Thu, 24 Sep 2026 12:52:10 -0700 Subject: [PATCH 58/67] Collapse the changelog to one entry per section The Unreleased section had grown 33 separate #1262 bullets - 27 of them under Fixed - because each review round appended its own. That reads as a pile of unrelated changes when it is one story: the documented Validation Context example did not work, and making it work is what everything else serves. Now one entry per section, grouped by what a reader would actually look for. The mechanism the old bullets described - producer stamps, ownership claims, retirement, net-zero suppression - is implementation detail that belongs in the commits and code comments, both of which carry it; a changelog reader needs the symptom and the resolution. Nothing user-visible is dropped. The individual defects survive as themed sub-entries (stale verdicts, repaint loops, async ordering and lifetime, silent async rules, rule identity, unregistered fields, wrong verdicts, missed repaints, leaks, docs), and the two pre-existing #1268 entries are untouched. 199 lines to 89; 39 mentions of the issue to 3. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 373c7212-34f6-4ddf-8ae9-59553c77cf48 --- CHANGELOG.md | 287 ++++++++++++++++----------------------------------- 1 file changed, 89 insertions(+), 198 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 62a3dd866..016c076fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,24 +28,25 @@ Conventions for contributors: ### Added -- `ValidationReconciler.EvaluateRulesAsync(...)` — the asynchronous counterpart to - `EvaluateRules`, for rule sets containing rules built with `ValidationRuleAsync`. - Rules run in order, so the resulting message order matches the order given - (issue #1262). -- `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 - (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 (issue #1262). -- `FormField` now marks its field touched when the editor loses focus, so the - default `ShowWhen.WhenTouched` reveals errors on blur as the guide describes. - Previously nothing in the framework ever called `MarkTouched`, leaving the - default unreachable unless the app marked fields by hand (spec 011 §1E.1, - issue #1262). +- **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). - `Publish docs` now verifies the live site after deploying. The `publish` job stamps the Pages artifact with the run that built it, and a new `verify` job polls until it is serving *that* run — @@ -56,18 +57,16 @@ 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. (The visualizers are display-only: they - render what a context already holds.) Results are therefore readable by the - same `Render()` that produced them. The validator-only - `.Validate(fieldName, validators…)` overload is unchanged and still attach-only - (spec 011 §1A.5, issue #1262). -- `UseValidationContext()` publishes a component-local context to the rendered - subtree automatically, so `FormField`, `ValidationVisualizer`, and nested - components no longer require an explicit - `.Provide(ValidationContexts.Current, ctx)`. An explicit provide still takes - precedence (issue #1262). +- **`.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) - A push to `main` and the release tag's own run both deploy the docs, as before. Standing one of them down was tried and removed: every version of that check has to predict that the other run will deploy, and each way the prediction fails (an evicted pending run, a @@ -86,174 +85,66 @@ Conventions for contributors: ### Fixed -- Validators attached to a plain control with `.Validate()` never ran unless the - control was wrapped in a `FormField`, so a form built from the documented - "Validation Context" example registered no fields, produced no messages, - reported `IsValid() == true`, and submitted while empty (issue #1262). -- A failing `ValidationRule` could drive the reconciler into its re-render - re-entrancy limit: rules evaluate during reconcile, outside the render scope, - and cleared-then-re-added their message every pass, so each pass looked like a - change and requested another render. Rule results are now applied as one diffed - replacement (issue #1262). -- `ValidationContext.NotifyValueChanged` discarded external messages on every - call rather than only when the value actually changed, so a server-side error - added with `AddExternal` was wiped by the next render (issue #1262). -- `ValidationContext.NotifyValueChanged` left an async verdict about the previous - value installed, and let an already-running async pass install its result after - the value had moved on. A field validated only with `.ValidateAsync(...)` never - reaches the synchronous path that retires those, so the stale error stayed on - screen. The async producer is now retracted and its generation retired whenever - the value changes (issue #1262). -- Overlapping `EvaluateAsync` runs of the same async `ValidationRule` applied in - completion order, so a slow failing check that resolved after a fast passing - one reinstated an error the newer run had already cleared. Async passes are now - ordered per producer, matching `.ValidateAsync(...)` (issue #1262). -- A validation change raised between renders — a rule mounting, updating, or - retracting during reconciliation — was announced inline, in the middle of the - pass that caused it. Reconciliation now holds a deferral frame, so those - notifications land once it has finished (issue #1262). -- A deferred notification with no subscriber yet was dropped. A host flushes root - effects only after reconciliation, so a parent whose `UseValidationContext()` - subscription had not been created yet never learned that a child had - invalidated the shared context (issue #1262). -- A `FormField` that lost its context and swapped its content control in the same - update left the displaced editor's blur binding live, so the pooled control kept - marking the old field and kept its `ValidationContext` alive (issue #1262). The - displaced editor is now neutralized directly rather than through the root - mapping, which a remount can replace — leaving the mapping naming a control that - is no longer the one that left. -- Chaining two value overloads on one element — - `.Validate(f, v, Email()).Validate(f, v, MinLength(10))` — repainted forever. - Each call eagerly applied its own intermediate validator set under the same - producer, so every pass stripped the later message and put it straight back; - the net state never moved, but each write announced a change that scheduled - another identical pass. A render that ends with the same messages it started - with is now silent (issue #1262). -- `ValidationReconciler.ValidateFieldAsync` and the rule batch paths did not register - the fields they validated, so `MarkAllTouched()` and the validity summary skipped a - field validated only through them. Directly evaluated rules - (`rule.Evaluate(ctx)` / `rule.EvaluateAsync(ctx)`) register their field too - (issue #1262). -- A mounted rule swapped from `ValidationRuleAsync` to `ValidationRule` kept its async - generation entry, so the next value change treated it as async and retracted a - synchronous verdict that was still current (issue #1262). -- An async `ValidationRule` evaluated synchronously — `.Evaluate(ctx)` or - `EvaluateRules(...)` — recorded a passing verdict without ever running its - predicate, reporting an invalid field as valid. Those paths now throw, and - `EvaluateRulesAsync` is the batch path that runs them (issue #1262). -- A validator-only attachment inside a `FormField` — - `FormField(TextBox("Alice").Validate("name", Validate.Required()))` — was validated - against `null` and reported a required-field error for a control that plainly had - text. That overload supplies no value, and `null` is a legitimate one, so - `ValidationAttached` now records whether a value was attached and `FormField` - validates only when one was. The field is still registered (issue #1262). -- A mounted async `ValidationRule` whose predicate never completed was never released: - the predicate takes no cancellation token, so cancelling on update or unmount left a - suspended task holding the rule binding and its `ValidationContext`, with another - added on every re-render. The await is now cancellation-aware (issue #1262). -- Two directly-evaluated rules sharing a predicate method on one field collapsed into - a single slot and retracted each other. A rule's identity now includes its position - in the call (issue #1262). -- An async `ValidationRule` placed in the element tree never ran its predicate. - `ValidationRuleAsync` builds an element whose synchronous predicate is a - constant `true`, and the mount/update lifecycle evaluated that, so the rule - silently recorded a passing verdict. Mounted async rules now run through the - generation-guarded async path, with the in-flight pass cancelled on update and - on unmount (issue #1262). -- A mounted rule that left the tree, or moved to another context, had its messages - withdrawn but not its async generation entry, so a pass already running could - reinstall the verdict into a context the rule no longer belonged to — and a - long-lived context accumulated a stale entry per mount, since every mounted rule - gets a fresh identity. Both paths now retire the producer: generation and - messages together (issue #1262). -- A rule evaluated directly with `.Evaluate(ctx)` was identified by its message - text, so an interpolated message such as `$"Must be after {start}"` orphaned - the previous verdict on every change: errors accumulated and a now-passing - rule could not retract the one it had installed. Identity is now the field plus - the predicate's call site (issue #1262). -- A field carrying only `.ValidateAsync(field, value, …)` on an element built - outside a render pass — cached, memoized, or assembled in an event handler — was - never registered, so `MarkAllTouched()` and the validity summary skipped it. - `FormField` now registers the field when it mounts or updates such an element - (issue #1262). -- `ValidationContext.MarkAllTouched` bumped `Version` even when every registered - field was already touched, and re-running validators over an unchanged value - bumped it twice per pass; both are now silent when nothing changed (issue #1262). -- `FormField`'s default `ShowWhen.WhenTouched` could never display an error: the - guide promises errors appear "after the field is touched (focus then blur)", - but no focus or blur handler existed and `MarkTouched` was reachable only from - app code, so the documented `FormField` example stayed silent forever - (issue #1262). -- A validated control leaving the tree left its verdict behind. `.Validate(field, - value, …)` installs its message while the owning component renders, and nothing - tracked what became of it: a control behind a condition installed an error on the - pass that showed it and then simply stopped being rendered, so the context stayed - invalid over a field with no control — permanently, with no way to clear it short - of `ClearAll()`. The same held for a whole `FormField`, whose unmount cleared only - its blur binding. The contribution is now recorded against the mounted control and - withdrawn when it is unmounted, when the field moves, or when the attachment stops - producing. Withdrawal is conditional on still owning the slot, so replacing a - `FormField`'s content — which installs the incoming verdict before unmounting the - outgoing control — no longer lets the departing control erase the verdict that - replaced it (issue #1262). -- A chain whose later link moved the attachment to another field orphaned the - earlier link's verdict. Every link evaluates eagerly, because a chain that ran - only at its end would drop the earlier links' validators for a bare control, but - the attachment that survives carries only the final `FieldName` — so - `.Validate("a", x, …).Validate("b", y, …)` left field `a` permanently invalid - with no control associated with it. A link that moves the field now withdraws - what the earlier link installed (issue #1262). -- Two links on one field carrying different values repainted forever. Each link - recorded its own value, so the current-value map flipped to the first link's and - back to the second's every pass; both writes were real value changes, so the - frame announced one every time, which repainted, which churned again. Net-zero - suppression now covers values, touched flags and the registered set as well as - messages, so a pass that ends where it started is silent whatever it churned - (issue #1262). -- A validated control removed from a parent's children kept its verdict: removal - tears down through the pooling traversal, not the ordinary unmount, and only the - latter withdrew. Both paths now go through one helper (issue #1262). -- `ValidationContext` accumulated an ownership stamp per retired producer. Every - mounted rule gets a fresh `rule#N` identity, so a long-lived context that saw - rules mount and unmount grew one entry per mount without bound. A stamp now - exists exactly while its producer owns messages (issue #1262). -- A chain ending on an async link left its synchronous verdict with no lifetime - owner, so `.Validate(f, v, …).ValidateAsync(f, …)` on a bare control kept the - sync message after the control was unmounted. The async overloads now re-run - the merged synchronous validators when the chain already carries a value; the - async validators themselves are still attach-only (issue #1262). -- A root render that wrote a verdict and then threw never reached - reconciliation, so nothing withdrew what it had installed and the field stayed - in error for the life of the context. Claims left over when the next render - pass opens are now withdrawn rather than discarded (issue #1262). -- Re-baselining an edited field with `SetInitialValue` during a render was - silently suppressed: it flips `IsDirty` without touching messages, touched - flags or the current value, and the net-zero comparison did not look at - baselines. A subscriber rendering dirty state kept the stale one (issue #1262). -- The forms guide documented `ValidationContext.IsValidating`, which does not - exist, and said `Validate.MustAsync` runs automatically. The async section now - describes the real contract — attach-only, run it yourself through - `ValidationReconciler.ValidateFieldAsync` — and points at `ValidationRuleAsync` - for the cross-field case that *is* run for you (issue #1262). -- `Version` advanced on every render for a net-zero pass that churned a field's - *value*. Only message-only writes were held during a render, so a chain such as - `.Validate("f", "", …).Validate("f", "bb", …)` left `Changed` correctly silent - while `Version` grew without bound — breaking a `UseMemo` or `UseEffect` keyed - on it, which is the signal most likely to be used that way. Every render-frame - bump is now held and committed once, only if the pass ended somewhere different - from where it started (issue #1262). -- `ValidationReconciler.EvaluateRules(ctx, rules…)` mutated the context rule by - rule, so a batch containing an async rule installed the earlier verdicts before - throwing — a partial batch from a call that reported failure. The batch is now - rejected before anything is installed (issue #1262). -- A render that returned one validated control and also built and dropped another - naming the same field reported the mounted, invalid field as **valid**. Both - share one synchronous producer slot, and the dropped element held the newer - stamp, so withdrawing its unconsumed claim cleared the slot the mounted control - depended on. A slot a mounted control has adopted is now left alone. (Two - elements naming one field still share the slot, so the last writer's verdict - wins its contents — that is pre-existing, and only the erasure is fixed.) - (issue #1262) +- **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; and a rule batch + containing an async rule installed part of itself before throwing. + - **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. + - **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`. ### Security From 1ba7e0db9c9a50a699531db606008572a0dc5b29 Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Thu, 24 Sep 2026 16:51:43 -0700 Subject: [PATCH 59/67] Stop double-validating, report foreign cancellation, narrow the hot path Five review findings. FormField re-ran every synchronous validator that .Validate() had already run during the render, for the ordinary FormField(TextBox(...).Validate(f, v, ...)). The structural diff hid the duplicate notification but not the work, so an expensive or custom validator paid twice on every render. The reconcile-time pass is now a fallback: it skips when this render already produced a verdict for this attachment against this context, and the content control takes the claim moments later. That context qualifier is load-bearing, and the existing Issue1262_ExplicitProvide fixture is what proved it. An explicit .Provide(...) inside a component that also owns a hook context separates the two: the eager write went to the hook's context while FormField resolves the provided one, so a claim alone does not mean THIS context was validated. Without the comparison the explicitly provided context silently stopped being validated at all. RunAsyncRuleAsync caught every OperationCanceledException as lifecycle churn. The predicate takes no token of its own, so anything it cancels is the app's own fault being hidden - and the stale verdict was left in place with no diagnostic. Only our own token counts as lifecycle cancellation now; everything else reaches the diagnostic arm. SetInitialValue dropped a baseline that moved while the field stayed dirty - initial a, current b, then initial c. IsDirty never flips, so neither Version nor Changed moved, yet Reset(field) now returns c instead of a. The move itself counts, scoped to a baseline that already existed and actually changed so that first-time seeding and the identical per-render re-seed stay silent. The mount and update validation hooks were gated on `Attached is not null`, which is true for every Grid/Canvas/Flex-positioned element, so all of them paid an attached-state DP read for a verdict they never had. Gated on ValidationAttached itself; the update path tests both old and new so removal still retracts. Each behavioural fix reddens its own check: removing the claim peek doubles the counted validator runs (4 for 2 renders), catching every cancellation loses the foreign-cancellation report, and dropping the baseline comparison loses the re-baseline notification. The cancellation fixture carries a positive control - the same subscription must stay silent for lifecycle cancellation - because a sink that reports everything would satisfy the first check for the wrong reason. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 373c7212-34f6-4ddf-8ae9-59553c77cf48 --- .../Controls/Validation/ValidationContext.cs | 15 +- .../Validation/ValidationRenderScope.cs | 18 ++ src/Reactor/Core/Reconciler.Mount.cs | 11 +- src/Reactor/Core/Reconciler.Update.cs | 11 +- .../Core/V1Protocol/CompositeLifecycle.cs | 35 +++- .../Fixtures/ValidationCoverageFixtures.cs | 163 ++++++++++++++++++ .../SelfTest/SelfTestFixtureRegistry.cs | 4 + .../ValidationRenderScopeTests.cs | 59 +++++++ 8 files changed, 302 insertions(+), 14 deletions(-) diff --git a/src/Reactor/Controls/Validation/ValidationContext.cs b/src/Reactor/Controls/Validation/ValidationContext.cs index 2a7a4ff5f..57a5f4b80 100644 --- a/src/Reactor/Controls/Validation/ValidationContext.cs +++ b/src/Reactor/Controls/Validation/ValidationContext.cs @@ -1016,13 +1016,22 @@ public void SetInitialValue(string field, object? value) lock (_lock) { var wasDirty = IsDirtyLocked(field); + var hadBaseline = _initialValues.TryGetValue(field, out var previousBaseline); _initialValues[field] = value; if (!_currentValues.ContainsKey(field)) _currentValues[field] = value; - // Re-baselining an edited field flips IsDirty without touching messages or - // touched state, so subscribers have to hear about it too. - changed = IsDirtyLocked(field) != wasDirty; + // 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(); diff --git a/src/Reactor/Controls/Validation/ValidationRenderScope.cs b/src/Reactor/Controls/Validation/ValidationRenderScope.cs index 49af2746f..0400bfc32 100644 --- a/src/Reactor/Controls/Validation/ValidationRenderScope.cs +++ b/src/Reactor/Controls/Validation/ValidationRenderScope.cs @@ -87,6 +87,24 @@ internal static bool TryTakeOwnership(ValidationAttached attached, out Ownership 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. diff --git a/src/Reactor/Core/Reconciler.Mount.cs b/src/Reactor/Core/Reconciler.Mount.cs index 878005085..37cadb96e 100644 --- a/src/Reactor/Core/Reconciler.Mount.cs +++ b/src/Reactor/Core/Reconciler.Mount.cs @@ -153,10 +153,13 @@ public sealed partial class Reconciler 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. - if (element.Attached is not null && control is FrameworkElement valFe) - V1Protocol.CompositeLifecycle.TrackElementValidation( - valFe, element.GetAttached()); + // 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. diff --git a/src/Reactor/Core/Reconciler.Update.cs b/src/Reactor/Core/Reconciler.Update.cs index 701c89359..affe356f6 100644 --- a/src/Reactor/Core/Reconciler.Update.cs +++ b/src/Reactor/Core/Reconciler.Update.cs @@ -265,11 +265,14 @@ public sealed partial class Reconciler 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. - if ((newEl.Attached is not null || oldEl.Attached is not null) + // 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, newEl.GetAttached()); + 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/V1Protocol/CompositeLifecycle.cs b/src/Reactor/Core/V1Protocol/CompositeLifecycle.cs index 1c85bd583..a88e7f8d7 100644 --- a/src/Reactor/Core/V1Protocol/CompositeLifecycle.cs +++ b/src/Reactor/Core/V1Protocol/CompositeLifecycle.cs @@ -265,6 +265,25 @@ private static void ApplyAttachedValidation( 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; @@ -600,9 +619,19 @@ private static async Task RunAsyncRuleAsync( { await rule.EvaluateAsync(valCtx, binding.Producer, cts.Token); } - catch (global::System.OperationCanceledException) - { - // Superseded by a newer pass, or the rule left the tree. + 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 diff --git a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs index af8ff3c32..92b6f01a7 100644 --- a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs +++ b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs @@ -2250,4 +2250,167 @@ private async Task ChainValueChurnSettlesAsync() $"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); } + + H.Check("Issue1262_Cancel_ForeignCancellationReported", seen > 0, + $"reported={seen} names={names}"); + + // 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); } + H.Check("Issue1262_Cancel_LifecycleCancellationSilent", quietCount == 0, + $"reported={quietCount} names={quietNames}"); + + var done = H.CreateHost(); + done.Mount(c => TextBlock("Issue1262 cancellation 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 9d30d0026..63d5d03b6 100644 --- a/tests/Reactor.AppTests.Host/SelfTest/SelfTestFixtureRegistry.cs +++ b/tests/Reactor.AppTests.Host/SelfTest/SelfTestFixtureRegistry.cs @@ -827,6 +827,8 @@ internal static class SelfTestFixtureRegistry "ValCov_Issue1262_FormFieldNameMigration", "ValCov_Issue1262_AttachedValidationStops", "ValCov_Issue1262_UnmountWithdrawsVerdict", + "ValCov_Issue1262_ValidatorsRunOnce", + "ValCov_Issue1262_PredicateCancellationReported", // Controls coverage — MaskEngine, InputFormatter, AutoSuggest "ControlsCov_MaskEngineBasic", "ControlsCov_MaskEngineNavigation", @@ -2748,6 +2750,8 @@ internal static string[] StaleTierDeclarations() => "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), // Controls coverage "ControlsCov_MaskEngineBasic" => new ControlsCoverageFixtures.MaskEngineBasic(harness), "ControlsCov_MaskEngineNavigation" => new ControlsCoverageFixtures.MaskEngineNavigation(harness), diff --git a/tests/Reactor.Tests/ValidationRenderScopeTests.cs b/tests/Reactor.Tests/ValidationRenderScopeTests.cs index 821432a03..75ea48e72 100644 --- a/tests/Reactor.Tests/ValidationRenderScopeTests.cs +++ b/tests/Reactor.Tests/ValidationRenderScopeTests.cs @@ -2397,4 +2397,63 @@ public void RejectedRuleBatchInstallsNothing() 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")); + } } \ No newline at end of file From 66bf9003e138a9e0230604571e2949bc56d75bbf Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Thu, 24 Sep 2026 17:10:20 -0700 Subject: [PATCH 60/67] Skip the cancellation trace checks where EventSource cannot be observed The AOT selftest job caught this: EventListener callbacks for managed EventSource events do not flow under NativeAOT publish - IsEnabled() returns false on the emit side, so the listener observes nothing whatever the classification did. Same limitation NativeDockingReliabilityFixture documents and guards with the same RuntimeFeature.IsDynamicCodeSupported test. Both checks are guarded, not just the failing one. The positive control asserts that lifecycle cancellation stays SILENT, and a listener that can never observe anything satisfies that vacuously - it would keep passing while controlling for nothing, which is worse than not running. The JIT selftest run asserts both. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 373c7212-34f6-4ddf-8ae9-59553c77cf48 --- .../Fixtures/ValidationCoverageFixtures.cs | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs index 92b6f01a7..779e8129e 100644 --- a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs +++ b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs @@ -2373,8 +2373,17 @@ public override async Task RunAsync() int seen; string names; lock (reported) { seen = reported.Count; names = string.Join("|", reported); } - H.Check("Issue1262_Cancel_ForeignCancellationReported", seen > 0, - $"reported={seen} names={names}"); + // 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}"); + } // Positive control: the same subscription, same operation name, must stay // silent for the lifecycle cancellation it is supposed to ignore. A sink @@ -2405,8 +2414,14 @@ public override async Task RunAsync() int quietCount; string quietNames; lock (quiet) { quietCount = quiet.Count; quietNames = string.Join("|", quiet); } - H.Check("Issue1262_Cancel_LifecycleCancellationSilent", quietCount == 0, - $"reported={quietCount} names={quietNames}"); + // 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}"); + } var done = H.CreateHost(); done.Mount(c => TextBlock("Issue1262 cancellation done")); From 26f25eba371f9743021454cbbe6e2e041e4c0bfd Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Thu, 24 Sep 2026 17:33:59 -0700 Subject: [PATCH 61/67] Emit skips, not silence, when the cancellation traces cannot be observed Guarding the two checks left the fixture emitting nothing at all under AOT, and the harness fails that by design: a fixture that runs to completion without a single check or skip is indistinguishable from one that was never reached. It now emits an explicit skip with the reason, so the AOT run records why the checks did not assert instead of quietly appearing to pass. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 373c7212-34f6-4ddf-8ae9-59553c77cf48 --- .../SelfTest/Fixtures/ValidationCoverageFixtures.cs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs index 779e8129e..ec497d2b2 100644 --- a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs +++ b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs @@ -2384,6 +2384,14 @@ public override async Task RunAsync() 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 @@ -2422,6 +2430,11 @@ public override async Task RunAsync() 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")); From 2a122c70485faea9b817fdb1d7d9c7911d7b253f Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Fri, 25 Sep 2026 11:38:28 -0700 Subject: [PATCH 62/67] Make ShowWhen.AfterFirstSubmit reachable, and say what each policy waits for Found by building a sample app for every documented scenario and driving it over UIA. FormField called ShouldShowErrors without the submitAttempted argument, so it defaulted to false forever: ShowWhen.AfterFirstSubmit accepted the setting and then behaved exactly like ShowWhen.Never. Verified live before fixing - the probe read ctxHasError=True touched=True while the field rendered no border and no message. This is the same shape as the WhenTouched defect this PR already fixes, and it is pre-existing on main rather than introduced here. MarkAllTouched() is the submit signal the guide tells callers to send, so the context records it as SubmitAttempted and ResetAll() clears it. FormField and the mounted visualizer now pass it. Nothing else in the framework had any notion of a submit; only the manual .WithErrorStyling(...) path could supply the flag, by hand. That makes MarkAllTouched() observable on a context with nothing registered, where it used to be silent, so the test pinning that is updated rather than the fix weakened: SubmitAttempted is state a subscriber can render, and staying quiet would leave an AfterFirstSubmit visualizer blank after a submit it was told about. The flag only flips once, so the repaint stays bounded - which is the property that test was really protecting. Docs now name the signal each policy waits for, because two of them need one the app has to send and neither said so. WhenDirty measures against a baseline that only SetInitialValue records, so with no baseline it is silent forever - the same trap, one method away. Added to skills/forms.md, the reactor-forms SKILL, and the forms guide template. SubmitAttempted is new public API, so both api.txt copies are regenerated via --regen-api and verified byte-identical. Unplumbing submitAttempted reddens Issue1262_AFS_ShownAfterSubmit with the original symptom; WhenDirtyStaysSilentWithoutABaseline pins the documented precondition from both sides. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 373c7212-34f6-4ddf-8ae9-59553c77cf48 --- CHANGELOG.md | 16 +++- docs/_pipeline/templates/forms.md.dt | 7 ++ docs/guide/forms.md | 7 ++ .../reactor-dsl/references/reactor.api.txt | 1 + plugins/reactor/skills/reactor-forms/SKILL.md | 14 ++- skills/forms.md | 14 ++- skills/reactor.api.txt | 1 + .../Controls/Validation/ValidationContext.cs | 29 +++++- .../Core/V1Protocol/CompositeLifecycle.cs | 11 ++- .../Fixtures/ValidationCoverageFixtures.cs | 74 +++++++++++++++ .../SelfTest/SelfTestFixtureRegistry.cs | 2 + .../ValidationRenderScopeTests.cs | 95 ++++++++++++++++++- 12 files changed, 256 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b8d1438a3..d19efa759 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,10 @@ Conventions for contributors: 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 @@ -156,10 +160,20 @@ Conventions for contributors: 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. + - **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`. + 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()`. - **Wrong code and guidance in the shipped agent-kit skills (spec 064 §4, issue #1275).** Three gesture snippets in `reactor-input` used WinUI's nested diff --git a/docs/_pipeline/templates/forms.md.dt b/docs/_pipeline/templates/forms.md.dt index 85d58af70..0151dc919 100644 --- a/docs/_pipeline/templates/forms.md.dt +++ b/docs/_pipeline/templates/forms.md.dt @@ -245,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 | diff --git a/docs/guide/forms.md b/docs/guide/forms.md index f9bd4dd0e..64482b331 100644 --- a/docs/guide/forms.md +++ b/docs/guide/forms.md @@ -435,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 | diff --git a/plugins/reactor/skills/reactor-dsl/references/reactor.api.txt b/plugins/reactor/skills/reactor-dsl/references/reactor.api.txt index e8a486a4e..89ce347e9 100644 --- a/plugins/reactor/skills/reactor-dsl/references/reactor.api.txt +++ b/plugins/reactor/skills/reactor-dsl/references/reactor.api.txt @@ -6100,6 +6100,7 @@ ToString() → string new() 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 diff --git a/plugins/reactor/skills/reactor-forms/SKILL.md b/plugins/reactor/skills/reactor-forms/SKILL.md index 314fb92c4..7a4111c3d 100644 --- a/plugins/reactor/skills/reactor-forms/SKILL.md +++ b/plugins/reactor/skills/reactor-forms/SKILL.md @@ -199,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/forms.md b/skills/forms.md index f2eff64ed..e18ed2dfd 100644 --- a/skills/forms.md +++ b/skills/forms.md @@ -179,10 +179,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/reactor.api.txt b/skills/reactor.api.txt index e8a486a4e..89ce347e9 100644 --- a/skills/reactor.api.txt +++ b/skills/reactor.api.txt @@ -6100,6 +6100,7 @@ ToString() → string new() 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 diff --git a/src/Reactor/Controls/Validation/ValidationContext.cs b/src/Reactor/Controls/Validation/ValidationContext.cs index 57a5f4b80..b09af99f9 100644 --- a/src/Reactor/Controls/Validation/ValidationContext.cs +++ b/src/Reactor/Controls/Validation/ValidationContext.cs @@ -974,6 +974,14 @@ public void MarkTouched(string field) /// /// 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() { @@ -988,12 +996,25 @@ public void MarkAllTouched() foreach (var field in _registeredFields) _touchedFields.Add(field); - changed = _touchedFields.Count != touchedBefore; + 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; } + } + // ════════════════════════════════════════════════════════════════ // Dirty state // ════════════════════════════════════════════════════════════════ @@ -1442,8 +1463,12 @@ public bool IsDirty() bool changed; lock (_lock) { - changed = _touchedFields.Count > 0 || _messages.Count > 0 || _externalMessages.Count > 0; + 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(); _owned.Clear(); diff --git a/src/Reactor/Core/V1Protocol/CompositeLifecycle.cs b/src/Reactor/Core/V1Protocol/CompositeLifecycle.cs index a88e7f8d7..28a018fbc 100644 --- a/src/Reactor/Core/V1Protocol/CompositeLifecycle.cs +++ b/src/Reactor/Core/V1Protocol/CompositeLifecycle.cs @@ -379,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) { @@ -878,7 +882,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); @@ -901,7 +906,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/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs index ec497d2b2..28f1d0a32 100644 --- a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs +++ b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs @@ -2441,4 +2441,78 @@ public override async Task RunAsync() 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(); + } + } } \ 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 63d5d03b6..f5da88043 100644 --- a/tests/Reactor.AppTests.Host/SelfTest/SelfTestFixtureRegistry.cs +++ b/tests/Reactor.AppTests.Host/SelfTest/SelfTestFixtureRegistry.cs @@ -829,6 +829,7 @@ internal static class SelfTestFixtureRegistry "ValCov_Issue1262_UnmountWithdrawsVerdict", "ValCov_Issue1262_ValidatorsRunOnce", "ValCov_Issue1262_PredicateCancellationReported", + "ValCov_Issue1262_AfterFirstSubmitReveals", // Controls coverage — MaskEngine, InputFormatter, AutoSuggest "ControlsCov_MaskEngineBasic", "ControlsCov_MaskEngineNavigation", @@ -2752,6 +2753,7 @@ internal static string[] StaleTierDeclarations() => "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), // Controls coverage "ControlsCov_MaskEngineBasic" => new ControlsCoverageFixtures.MaskEngineBasic(harness), "ControlsCov_MaskEngineNavigation" => new ControlsCoverageFixtures.MaskEngineNavigation(harness), diff --git a/tests/Reactor.Tests/ValidationRenderScopeTests.cs b/tests/Reactor.Tests/ValidationRenderScopeTests.cs index 75ea48e72..d45094057 100644 --- a/tests/Reactor.Tests/ValidationRenderScopeTests.cs +++ b/tests/Reactor.Tests/ValidationRenderScopeTests.cs @@ -863,16 +863,25 @@ public void MarkAllTouched_Notifies_Once_And_Stays_Quiet_When_Already_Touched() } [Fact] - public void MarkAllTouched_With_No_Registered_Fields_Is_Silent() + 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(0, notifications); - Assert.Equal(0, ctx.Version); + 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] @@ -2456,4 +2465,84 @@ public void FirstBaselineSeedStaysSilent() 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)); + } } \ No newline at end of file From 5767c7865b24aaaa3f40b4243b443bf55e986315 Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Fri, 25 Sep 2026 12:48:21 -0700 Subject: [PATCH 63/67] Include the submit flag in the deferred snapshot, and stop overpromising cancellation Two findings, both consequences of the previous round. SubmitAttempted was missing from the net-zero comparison. Once every field is already touched, that flag is the entire delta MarkAllTouched() produces - so a submit raised from an effect during reconciliation looked net-zero, the notification was dropped, and the held Version bump was cancelled. A ShowWhen.AfterFirstSubmit field would then stay hidden after the very submit it was told about, which is the defect this PR just fixed reappearing through the suppression path. Same omission as the baselines a few rounds back, same fix: snapshot it and compare it. The async doc snippet claimed cancelling "stops waiting". It does not. Validate.MustAsync awaits the caller's predicate with no token and only calls ThrowIfCancellationRequested after it returns, so cancellation discards the result rather than abandoning the request. The comment now says that, and points at taking a token in the predicate for callers who want the work actually stopped. The neighbouring claim about ValidationRuleAsync is left alone: that path awaits through WaitAsync(token), which really does stop waiting. SubmitDuringARenderFrameIsNotSuppressedAsNetZero reddens when the flag is dropped from the comparison; RepeatedSubmitDuringARenderFrameStaysSuppressed pins the other side, so the fix cannot degrade into suppressing nothing. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 373c7212-34f6-4ddf-8ae9-59553c77cf48 --- docs/_pipeline/apps/forms/App.cs | 9 ++- docs/guide/forms.md | 9 ++- .../Controls/Validation/ValidationContext.cs | 8 +++ .../ValidationRenderScopeTests.cs | 60 +++++++++++++++++++ 4 files changed, 80 insertions(+), 6 deletions(-) diff --git a/docs/_pipeline/apps/forms/App.cs b/docs/_pipeline/apps/forms/App.cs index 9e06376d4..2cb126549 100644 --- a/docs/_pipeline/apps/forms/App.cs +++ b/docs/_pipeline/apps/forms/App.cs @@ -199,9 +199,12 @@ public override Element Render() [Validate.MustAsync(IsEmailFree, "Email is taken")], cts.Token); } - // Cancel on cleanup so a check for a value the user has already replaced - // stops waiting, and dispose the source with it — the effect allocates a - // fresh one per run. + // 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(); } diff --git a/docs/guide/forms.md b/docs/guide/forms.md index 64482b331..dcf71b864 100644 --- a/docs/guide/forms.md +++ b/docs/guide/forms.md @@ -823,9 +823,12 @@ class AsyncValidationDemo : Component [Validate.MustAsync(IsEmailFree, "Email is taken")], cts.Token); } - // Cancel on cleanup so a check for a value the user has already replaced - // stops waiting, and dispose the source with it — the effect allocates a - // fresh one per run. + // 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(); } diff --git a/src/Reactor/Controls/Validation/ValidationContext.cs b/src/Reactor/Controls/Validation/ValidationContext.cs index b09af99f9..c4bcf17f5 100644 --- a/src/Reactor/Controls/Validation/ValidationContext.cs +++ b/src/Reactor/Controls/Validation/ValidationContext.cs @@ -253,6 +253,7 @@ private static void AppendCounted(global::System.Text.StringBuilder sb, string? private Dictionary? _lastNotifiedValues; private Dictionary? _lastNotifiedInitials; private HashSet? _lastNotifiedTouched; + private bool _lastNotifiedSubmitAttempted; private int _lastNotifiedRegistered; private bool _frameTouchedNonMessageState; private bool _frameVersionPending; @@ -277,6 +278,12 @@ private bool NonMessageStateUnchangedLocked() 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) { @@ -301,6 +308,7 @@ private void CaptureNonMessageStateLocked() _lastNotifiedInitials = new Dictionary(_initialValues); _lastNotifiedTouched = new HashSet(_touchedFields, StringComparer.Ordinal); _lastNotifiedRegistered = _registeredFields.Count; + _lastNotifiedSubmitAttempted = _submitAttempted; } /// diff --git a/tests/Reactor.Tests/ValidationRenderScopeTests.cs b/tests/Reactor.Tests/ValidationRenderScopeTests.cs index d45094057..b6b22c2a9 100644 --- a/tests/Reactor.Tests/ValidationRenderScopeTests.cs +++ b/tests/Reactor.Tests/ValidationRenderScopeTests.cs @@ -2545,4 +2545,64 @@ public void WhenDirtyStaysSilentWithoutABaseline() 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); + } } \ No newline at end of file From 361d5dd1058c26b5c0a25dcbaf86a29af40f8efe Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Fri, 25 Sep 2026 13:38:32 -0700 Subject: [PATCH 64/67] Drop producer stamps with the field, and count registration as a change Two findings, both in the "previously missed" block rather than as inline threads, and both real. Clear, ClearInternal and Reset dropped _owned, _asyncGeneration and rule-set membership for a field but not its _producerStamp entry. That breaks the invariant this PR introduced and defended - a stamp exists exactly while its producer owns messages - and grows the map without bound for dynamically named fields. It is the same unbounded-growth defect already fixed on the retire path, reappearing on the clear path because three call sites each had to remember four removals and one of them was added later than the rest. Fixed with a single DropFieldProducerStateLocked helper the three now share, so a fourth caller cannot forget. Registration was not observable. RegisteredFields is public and MarkAllTouched iterates it, but five separate sites added to the set directly and none counted it as a change, so a subscriber rendering the field set could stay stale. All five now go through RegisterFieldLocked, which reports whether the field was actually new, and each path folds that into its own change decision. Registration is monotonic - nothing ever removes from the set - so this settles after the first pass that introduces a field rather than repainting forever. Two existing tests asserted zero notifications at exactly that first pass; both are updated with the reason, and both keep their real point, which is that repeating the pattern afterwards stays silent. A_Rule_Flipping gains an explicit re-evaluation loop so that property is asserted rather than implied. ClearingAFieldDropsItsProducerStamps cycles 100 dynamically named fields and reddens when the stamp removal is dropped; RegisteringAFieldIsObservable and the two updated settling tests redden when RegisterField goes silent again. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 373c7212-34f6-4ddf-8ae9-59553c77cf48 --- CHANGELOG.md | 7 +- .../Controls/Validation/ValidationContext.cs | 71 +++++++++++---- .../ValidationRenderScopeTests.cs | 86 +++++++++++++++++-- 3 files changed, 140 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d19efa759..89b3984a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -159,7 +159,12 @@ Conventions for contributors: - **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. + 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 diff --git a/src/Reactor/Controls/Validation/ValidationContext.cs b/src/Reactor/Controls/Validation/ValidationContext.cs index c4bcf17f5..d257b3f2e 100644 --- a/src/Reactor/Controls/Validation/ValidationContext.cs +++ b/src/Reactor/Controls/Validation/ValidationContext.cs @@ -353,12 +353,28 @@ private void BumpVersionLocked() /// 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. /// @@ -428,6 +444,27 @@ public void AddExternal(ValidationMessage message) // 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) + { + _owned.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. /// @@ -439,11 +476,7 @@ public void Clear(string field) changed = false; if (_messages.Remove(field)) changed = true; if (_externalMessages.Remove(field)) changed = true; - _owned.Remove(field); - // An async pass still in flight would otherwise repopulate what this just - // cleared: dropping the token makes its result stale on arrival. - _asyncGeneration.Remove(field); - InvalidateRuleSetsLocked(field); + DropFieldProducerStateLocked(field); if (changed) BumpVersionLocked(); } if (changed) RaiseChanged(messagesOnly: true); @@ -459,10 +492,7 @@ internal void ClearInternal(string field) lock (_lock) { changed = _messages.Remove(field); - _owned.Remove(field); - // As in Clear: a pending async pass must not repopulate what this dropped. - _asyncGeneration.Remove(field); - InvalidateRuleSetsLocked(field); + DropFieldProducerStateLocked(field); if (changed) BumpVersionLocked(); } if (changed) RaiseChanged(messagesOnly: true); @@ -698,7 +728,7 @@ internal int BeginAsyncValidation(string field, object? value) int token; lock (_lock) { - _registeredFields.Add(field); + var newField = RegisterFieldLocked(field); var known = _currentValues.TryGetValue(field, out var previous); changed = !known || !Equals(previous, value); @@ -710,6 +740,13 @@ internal int BeginAsyncValidation(string field, object? value) 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)) @@ -769,7 +806,7 @@ internal void ApplyValidation(string field, object? value, List notifications++; rule.Evaluate(ctx); - Assert.Equal(0, notifications); // passing, nothing to record + // 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(1, notifications); + Assert.Equal(2, notifications); Assert.False(ctx.IsValid()); passing = true; rule.Evaluate(ctx); - Assert.Equal(2, notifications); + 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] @@ -2605,4 +2617,68 @@ public void RepeatedSubmitDuringARenderFrameStaysSuppressed() 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); + } } \ No newline at end of file From 13c216a144b16d1ac6cdcf3f5dc60b42fb736807 Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Fri, 25 Sep 2026 15:35:33 -0700 Subject: [PATCH 65/67] Own messages by position, and settle claims when a render aborts Producer ownership was tracked by object reference, which cannot tell two contributions apart when they hold the same ValidationMessage instance. ValidationMessage is immutable and Add(ValidationMessage) is public, so a validator that caches its message - or a caller who adds one already installed - puts the same instance in a field twice under two different owners. Retiring either then matched both occurrences and removed the other's message, leaving the field spuriously valid. Reproduced as a test before changing anything. Ownership is now positional: a parallel owner list beside each field's messages, same length and order, with null marking an entry added directly. That replaces the instance-keyed map entirely, so ContainsReference is gone along with the delicate rule about not overwriting ownership on an unchanged pass - positions have no equal-but-distinct hazard, so the owner list is simply always rewritten to describe what is installed. Aborted renders leaked their claims. Both hosts catch a render exception, install the error fallback and return without calling Reconcile, so nothing consumed or retired what that render claimed; the cleanup relied on a later render opening a frame, and for a terminal fallback there is no later render. AbandonPendingClaims() is called from the two funnels every abort path goes through - ShowErrorFallback and RecoverFromHookOrder, in both hosts - rather than from the eight-odd individual return sites, which is the drift that caused the last two findings. Issue1262_Aborted_VerdictWritten asserted the old intermediate state, where the verdict survived the abort and was only cleaned up by the next render. That is the behaviour this fixes, so the fixture now asserts settlement at the abort itself and keeps its original point as a second check. The async doc sample discarded its validation task, so a uniqueness check that failed for a real reason - network down, service erroring - vanished silently and the field simply never got a verdict. It now awaits inside a local helper that separates expected cancellation from real failure and surfaces the latter. Each fix reddens its own check: breaking positional ownership reddens four producer-isolation tests, no-opping AbandonPendingClaims reddens the aborted claim test, and the shared-instance repro fails on the pre-fix matching. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 373c7212-34f6-4ddf-8ae9-59553c77cf48 --- CHANGELOG.md | 7 +- docs/_pipeline/apps/forms/App.cs | 32 ++++++- docs/guide/forms.md | 32 ++++++- .../Controls/Validation/ValidationContext.cs | 96 +++++++++---------- .../Validation/ValidationRenderScope.cs | 18 ++++ src/Reactor/Hosting/ReactorHost.cs | 7 ++ src/Reactor/Hosting/ReactorHostControl.cs | 7 ++ .../Fixtures/ValidationCoverageFixtures.cs | 17 ++-- .../ValidationRenderScopeTests.cs | 60 ++++++++++++ 9 files changed, 210 insertions(+), 66 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 89b3984a8..ce3f6563b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -147,8 +147,11 @@ Conventions for contributors: - **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; and a rule batch - containing an async rule installed part of itself before throwing. + 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 diff --git a/docs/_pipeline/apps/forms/App.cs b/docs/_pipeline/apps/forms/App.cs index 2cb126549..bb787cf9d 100644 --- a/docs/_pipeline/apps/forms/App.cs +++ b/docs/_pipeline/apps/forms/App.cs @@ -194,10 +194,12 @@ public override Element Render() var cts = new CancellationTokenSource(); if (email.Length > 0) { - _ = ValidationReconciler.ValidateFieldAsync( - ctx, "email", email, - [Validate.MustAsync(IsEmailFree, "Email is taken")], - cts.Token); + // 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. @@ -210,6 +212,28 @@ public override Element Render() 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) + { + // Anything else is a real failure. Surface it however your app + // reports background faults — here, as a message on the field so + // it cannot pass silently. + ctx.AddExternal("email", $"Could not check availability: {ex.Message}"); + } + } }, email); return VStack(12, diff --git a/docs/guide/forms.md b/docs/guide/forms.md index dcf71b864..7cb905000 100644 --- a/docs/guide/forms.md +++ b/docs/guide/forms.md @@ -818,10 +818,12 @@ class AsyncValidationDemo : Component var cts = new CancellationTokenSource(); if (email.Length > 0) { - _ = ValidationReconciler.ValidateFieldAsync( - ctx, "email", email, - [Validate.MustAsync(IsEmailFree, "Email is taken")], - cts.Token); + // 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. @@ -834,6 +836,28 @@ class AsyncValidationDemo : Component 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) + { + // Anything else is a real failure. Surface it however your app + // reports background faults — here, as a message on the field so + // it cannot pass silently. + ctx.AddExternal("email", $"Could not check availability: {ex.Message}"); + } + } }, email); return VStack(12, diff --git a/src/Reactor/Controls/Validation/ValidationContext.cs b/src/Reactor/Controls/Validation/ValidationContext.cs index d257b3f2e..cabf9adeb 100644 --- a/src/Reactor/Controls/Validation/ValidationContext.cs +++ b/src/Reactor/Controls/Validation/ValidationContext.cs @@ -11,7 +11,15 @@ public sealed class ValidationContext 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. - private readonly Dictionary>> _owned = new(); + // 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. @@ -408,6 +416,11 @@ public void Add(ValidationMessage message) _messages[message.Field] = list; } list.Add(message); + // 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); @@ -457,7 +470,7 @@ public void AddExternal(ValidationMessage message) /// private void DropFieldProducerStateLocked(string field) { - _owned.Remove(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. @@ -533,32 +546,41 @@ internal void ApplyOwned(string field, string producer, List private bool ApplyOwnedLocked(string field, string producer, List messages) { _messages.TryGetValue(field, out var current); - _owned.TryGetValue(field, out var byProducer); + _messageOwners.TryGetValue(field, out var owners); - List? previous = null; - if (byProducer is not null) - byProducer.TryGetValue(producer, out previous); - - var next = new List( - (current?.Count ?? 0) + messages.Count); + 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) { - foreach (var message in current) + for (var i = 0; i < current.Count; i++) { - if (previous is not null && ContainsReference(previous, message)) + // 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++]); + if (taken < messages.Count) + { + next.Add(messages[taken++]); + nextOwners.Add(producer); + } continue; } - next.Add(message); + 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) @@ -569,32 +591,15 @@ private bool ApplyOwnedLocked(string field, string producer, List>(StringComparer.Ordinal); - byProducer[producer] = messages; - } - } - else if (messages.Count == 0 && byProducer is not null) - { - byProducer.Remove(producer); - if (byProducer.Count == 0) _owned.Remove(field); - } + // 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 @@ -778,15 +783,6 @@ internal int BeginAsyncProducer(string field, string producer) internal const string SyncProducer = "sync"; internal const string AsyncProducer = "async"; - private static bool ContainsReference(List list, ValidationMessage message) - { - foreach (var candidate in list) - { - if (ReferenceEquals(candidate, message)) return true; - } - return false; - } - /// /// 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 @@ -864,7 +860,7 @@ public void ClearAll() changed = _messages.Count > 0 || _externalMessages.Count > 0; _messages.Clear(); _externalMessages.Clear(); - _owned.Clear(); + _messageOwners.Clear(); _producerStamp.Clear(); _asyncGeneration.Clear(); InvalidateRuleSetsLocked(null); @@ -1514,7 +1510,7 @@ public bool IsDirty() _submitAttempted = false; _messages.Clear(); _externalMessages.Clear(); - _owned.Clear(); + _messageOwners.Clear(); _producerStamp.Clear(); _asyncGeneration.Clear(); InvalidateRuleSetsLocked(null); diff --git a/src/Reactor/Controls/Validation/ValidationRenderScope.cs b/src/Reactor/Controls/Validation/ValidationRenderScope.cs index 0400bfc32..0f5e55082 100644 --- a/src/Reactor/Controls/Validation/ValidationRenderScope.cs +++ b/src/Reactor/Controls/Validation/ValidationRenderScope.cs @@ -228,6 +228,24 @@ private static Frame BeginCore(ValidationContext? inherited, bool isReconcile) /// 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. /// diff --git a/src/Reactor/Hosting/ReactorHost.cs b/src/Reactor/Hosting/ReactorHost.cs index 44754face..ab7e45fb9 100644 --- a/src/Reactor/Hosting/ReactorHost.cs +++ b/src/Reactor/Hosting/ReactorHost.cs @@ -521,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); @@ -995,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 32edb9abd..f7a76a5ea 100644 --- a/src/Reactor/Hosting/ReactorHostControl.cs +++ b/src/Reactor/Hosting/ReactorHostControl.cs @@ -365,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); @@ -631,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 28f1d0a32..98a53fd2d 100644 --- a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs +++ b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs @@ -2056,9 +2056,9 @@ private async Task SyncThenAsyncChainAsync() } // A root render that writes a verdict and then throws never reaches - // reconciliation, so nothing consumes or retires the claim it made. The next - // pass has to settle it rather than discard it, or the field stays in error - // for the lifetime of the context. + // 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(); @@ -2083,10 +2083,15 @@ private async Task AbortedRootRenderAsync() H.Check("Issue1262_Aborted_Resolved", ctx is not null, $"threw={threw}"); if (ctx is null) return; - H.Check("Issue1262_Aborted_VerdictWritten", ctx.GetMessages("aborted").Count == 1, - $"aborted={ctx.GetMessages("aborted").Count}"); + // 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()}"); - // The recovered tree no longer renders the field at all. + // 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")); diff --git a/tests/Reactor.Tests/ValidationRenderScopeTests.cs b/tests/Reactor.Tests/ValidationRenderScopeTests.cs index ffc98cdea..c76c9ee4b 100644 --- a/tests/Reactor.Tests/ValidationRenderScopeTests.cs +++ b/tests/Reactor.Tests/ValidationRenderScopeTests.cs @@ -2681,4 +2681,64 @@ public void ClearingAFieldDropsItsProducerStamps() 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 From 949fa3eff32a1d034ce2b9bf525062dc66c78f7f Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Fri, 25 Sep 2026 16:09:23 -0700 Subject: [PATCH 66/67] Treat focus moving inside a field as focus, not blur LostFocus is routed, so it also fired when focus moved between descendants of one editor - a NumberBox's text part to its spin buttons, a FormField whose content holds several focusable parts. The field was not blurred at all in that case, so marking it touched contradicted the documented "focus then blur" and could reveal an error while the user was still inside the control. LosingFocus carries the destination, which LostFocus does not: the new focus is not yet set when LostFocus fires, so it cannot make this distinction at all. A destination inside the same element is ignored; a null destination - focus leaving the window - is a real blur and still marks touched. The fixture took two attempts and the first was worth recording. It focused a NumberBox's inner text part, which already held focus, so no transition ever fired and "not touched" was true for the wrong reason - the mutant survived. It now uses content with two focusable parts, asserts through FocusManager that the move actually happened before asserting anything about touched state, and keeps the leave-the-field positive control. Removing the guard reddens it. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 373c7212-34f6-4ddf-8ae9-59553c77cf48 --- .../Core/V1Protocol/CompositeLifecycle.cs | 19 ++++- .../Fixtures/ValidationCoverageFixtures.cs | 81 +++++++++++++++++++ .../SelfTest/SelfTestFixtureRegistry.cs | 2 + 3 files changed, 100 insertions(+), 2 deletions(-) diff --git a/src/Reactor/Core/V1Protocol/CompositeLifecycle.cs b/src/Reactor/Core/V1Protocol/CompositeLifecycle.cs index 28a018fbc..121496f9a 100644 --- a/src/Reactor/Core/V1Protocol/CompositeLifecycle.cs +++ b/src/Reactor/Core/V1Protocol/CompositeLifecycle.cs @@ -750,8 +750,23 @@ private static void WireTouchedOnBlur(UIElement formFieldRoot, UIElement content var binding = new TouchBinding { Context = valCtx, FieldName = fieldName }; Reconciler.GetOrCreateReactorState(fe).ValidationTouchBinding = binding; ReplaceRootBinding(formFieldRoot, binding); - fe.LostFocus += (_, _) => - { + 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); }; diff --git a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs index 98a53fd2d..3e3bf5f69 100644 --- a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs +++ b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/ValidationCoverageFixtures.cs @@ -2520,4 +2520,85 @@ public override async Task RunAsync() 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 f5da88043..3c4fd3e9f 100644 --- a/tests/Reactor.AppTests.Host/SelfTest/SelfTestFixtureRegistry.cs +++ b/tests/Reactor.AppTests.Host/SelfTest/SelfTestFixtureRegistry.cs @@ -830,6 +830,7 @@ internal static class SelfTestFixtureRegistry "ValCov_Issue1262_ValidatorsRunOnce", "ValCov_Issue1262_PredicateCancellationReported", "ValCov_Issue1262_AfterFirstSubmitReveals", + "ValCov_Issue1262_InternalFocusMoveIsNotABlur", // Controls coverage — MaskEngine, InputFormatter, AutoSuggest "ControlsCov_MaskEngineBasic", "ControlsCov_MaskEngineNavigation", @@ -2754,6 +2755,7 @@ internal static string[] StaleTierDeclarations() => "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), From 148e2eb77132fecffe9c7f63ea6ccdf74191e4ad Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Fri, 25 Sep 2026 16:36:49 -0700 Subject: [PATCH 67/67] Narrow the sample catch to the framework's own shape Code quality flagged the bare catch in the async-validation sample. It is now filtered the way the framework filters its own app-callback boundary in RunAsyncRuleAsync: everything except OutOfMemoryException and StackOverflowException. Not narrowed to a list of expected exception types, which was the suggestion. The predicate belongs to the reader, so neither the framework nor the sample can know what it throws - an HttpRequestException list is right for one caller and silently wrong for the next, and the type someone forgot is exactly the failure that disappears. The point of the surrounding change was that these must not disappear. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 373c7212-34f6-4ddf-8ae9-59553c77cf48 --- docs/_pipeline/apps/forms/App.cs | 14 +++++++++++--- docs/guide/forms.md | 14 +++++++++++--- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/docs/_pipeline/apps/forms/App.cs b/docs/_pipeline/apps/forms/App.cs index bb787cf9d..83d84bedb 100644 --- a/docs/_pipeline/apps/forms/App.cs +++ b/docs/_pipeline/apps/forms/App.cs @@ -227,10 +227,18 @@ await ValidationReconciler.ValidateFieldAsync( // 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. Surface it however your app - // reports background faults — here, as a message on the field so - // it cannot pass silently. + // 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}"); } } diff --git a/docs/guide/forms.md b/docs/guide/forms.md index 7cb905000..666dda6e6 100644 --- a/docs/guide/forms.md +++ b/docs/guide/forms.md @@ -851,10 +851,18 @@ class AsyncValidationDemo : Component // 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. Surface it however your app - // reports background faults — here, as a message on the field so - // it cannot pass silently. + // 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}"); } }