Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
70 commits
Select commit Hold shift + click to select a range
2d2e4f1
Fix validation context: .Validate() never ran outside FormField
azchohfi Sep 23, 2026
0e2b35c
Address code-quality finding: drop implicit filter in MarkAllTouched
azchohfi Sep 23, 2026
a96d707
Address Copilot review: rule render loop, atomic apply, binding lifetime
azchohfi Sep 23, 2026
9264584
Address Copilot review round 2: deferred notifications, Reset delta, …
azchohfi Sep 23, 2026
09548a9
Address code-quality findings on the new validation fixtures
azchohfi Sep 23, 2026
0c98d76
Address Copilot review round 3: seed-then-notify loop, async rule dou…
azchohfi Sep 23, 2026
e93c21c
Address Copilot review round 4: standalone host dispatcher, async ato…
azchohfi Sep 23, 2026
56f0ca3
Address Copilot review round 5: per-producer message ownership
azchohfi Sep 23, 2026
2a2ed35
Address Copilot review round 6: async generation on clear, per-rule i…
azchohfi Sep 23, 2026
a690695
Make ApplyOwnedLocked's producer-map nullability explicit
azchohfi Sep 23, 2026
36f0701
Make async validation tokens context-wide so a clear cannot reuse one
azchohfi Sep 24, 2026
60fd57f
Retract rule verdicts when a rule is removed, moved, or unmounted
azchohfi Sep 24, 2026
87860d4
Invalidate async verdicts on a synchronous value change; document Val…
azchohfi Sep 24, 2026
e96b123
Use LINQ Where for retracted-producer filter
azchohfi Sep 24, 2026
b5347f7
Retire async validation when the value changes via NotifyValueChanged
azchohfi Sep 24, 2026
70aaacf
Merge origin/main
azchohfi Sep 24, 2026
1fe7557
Order async rules per producer, defer reconcile-time validation changes
azchohfi Sep 24, 2026
0b48e00
Register async-only validated fields when FormField mounts them
azchohfi Sep 24, 2026
798d671
Stop announcing a render pass that churns messages back to where it s…
azchohfi Sep 24, 2026
bf24393
Regenerate docs for the Reconcile snippet
azchohfi Sep 24, 2026
68e3623
Keep Version stable across a net-zero pass; keep message order signif…
azchohfi Sep 24, 2026
af3a833
Use Where for the external-only field collection
azchohfi Sep 24, 2026
a840223
Retract every async producer on a value change, not just the field slot
azchohfi Sep 24, 2026
62b81b8
Run mounted async rules; identify direct rules by call site
azchohfi Sep 24, 2026
6bf1fd5
Reject sync evaluation of async rules; retire rule producers properly
azchohfi Sep 24, 2026
dbefbd9
Address code-quality feedback on the async rule dispatch
azchohfi Sep 24, 2026
96992f7
Length-prefix the validation snapshot; order async rule batches
azchohfi Sep 24, 2026
a3cac22
Order rule batches by generation instead of a semaphore
azchohfi Sep 24, 2026
dfd6733
Commit rule sets atomically; record the value in ValidateFieldAsync
azchohfi Sep 24, 2026
d63b0fe
Issue async generations atomically with the value they validate
azchohfi Sep 24, 2026
70c921b
Scope rule-set ownership to an explicit set id
azchohfi Sep 24, 2026
ac80c02
Release hung async rules; key direct rules by position
azchohfi Sep 24, 2026
83da873
Add cancellation to the batch APIs; clear pass slots atomically
azchohfi Sep 24, 2026
de71c8a
Advance rule-set generations under the commit lock
azchohfi Sep 24, 2026
f0b1791
Store validation lifecycle bindings on native attached state
azchohfi Sep 24, 2026
ac5020e
Move rule-set bookkeeping onto the context lock
azchohfi Sep 24, 2026
962494a
Stop FormField validating value-less attachments against null
azchohfi Sep 24, 2026
55066ff
Use Where for the departed rule-set producer filter
azchohfi Sep 24, 2026
c0c0ba4
Correct the docs on validator-only attachments in FormField
azchohfi Sep 24, 2026
d278a0d
Retire rule-set async tokens inside the ownership transaction
azchohfi Sep 24, 2026
d10e912
Clear validation bindings on full detach
azchohfi Sep 24, 2026
25b862a
Retire the producer on rule detach; rerun merged validator chains
azchohfi Sep 24, 2026
07a1557
Compute rule verdicts before mutating context; cover off-thread mutation
azchohfi Sep 24, 2026
d3b5b36
Retire pending rule-set commits when clearing or resetting state
azchohfi Sep 24, 2026
66f6493
Narrow the off-thread fixture catch
azchohfi Sep 24, 2026
41a0e68
Retire stale rule sets and field verdicts when values or fields move
azchohfi Sep 24, 2026
41ab378
Use Where for the affected rule-set filter
azchohfi Sep 24, 2026
1b3aad2
Withdraw attached validation whenever it stops applying
azchohfi Sep 24, 2026
e2a0124
Take the verdict with the control that leaves the tree
azchohfi Sep 24, 2026
145e99e
Make a validated control's claim exact, and its withdrawal reachable
azchohfi Sep 24, 2026
c0658a3
Keep a root render's validation claim alive into reconciliation
azchohfi Sep 24, 2026
c2dca0e
Carry the sync claim through an async link, and settle aborted claims
azchohfi Sep 24, 2026
bc40ab2
Compare baselines too, and correct the async validation guide
azchohfi Sep 24, 2026
6a61979
Hold Version through a render, reject rule batches whole, keep adopte…
azchohfi Sep 24, 2026
4dc717c
Dispose the CancellationTokenSource in the async-validation snippet
azchohfi Sep 24, 2026
f3676cb
Attribute the off-thread render check by thread, not by counting
azchohfi Sep 24, 2026
cf91afa
Correct the off-thread fixture's header claim
azchohfi Sep 24, 2026
29830b0
Filter the async preflight explicitly
azchohfi Sep 24, 2026
7021b33
Collapse the changelog to one entry per section
azchohfi Sep 24, 2026
c307dc3
Merge main into the validation-context fix
azchohfi Sep 24, 2026
1ba7e0d
Stop double-validating, report foreign cancellation, narrow the hot path
azchohfi Sep 24, 2026
66bf900
Skip the cancellation trace checks where EventSource cannot be observed
azchohfi Sep 25, 2026
26f25eb
Emit skips, not silence, when the cancellation traces cannot be observed
azchohfi Sep 25, 2026
2a122c7
Make ShowWhen.AfterFirstSubmit reachable, and say what each policy wa…
azchohfi Sep 25, 2026
5767c78
Include the submit flag in the deferred snapshot, and stop overpromis…
azchohfi Sep 25, 2026
361d5dd
Drop producer stamps with the field, and count registration as a change
azchohfi Sep 25, 2026
13c216a
Own messages by position, and settle claims when a render aborts
azchohfi Sep 25, 2026
949fa3e
Treat focus moving inside a field as focus, not blur
azchohfi Sep 25, 2026
5e8f312
Merge remote-tracking branch 'origin/main' into azchohfi-fix-validati…
azchohfi Sep 25, 2026
148e2eb
Narrow the sample catch to the framework's own shape
azchohfi Sep 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 114 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,30 @@ Conventions for contributors:

### Added

- **Validation now works on any control, not only inside `FormField`** — the
Forms guide's "Validation Context" example needed new surface to work as
written (issue #1262):
- `ValidationContext.Changed`, raised when the context's observable state
changes (a message appearing or disappearing, a field becoming touched, a
reset). `UseValidationContext()` subscribes to it, so mutating the context
from an event handler repaints the form.
- `ValidationReconciler.EvaluateRulesAsync(…)`, the asynchronous counterpart to
`EvaluateRules` for rules built with `ValidationRuleAsync`. Rules run in
order, so the resulting message order matches the order given.
- `EvaluateRules(ctx, setId, …)` and `EvaluateRulesAsync(ctx, setId, …)`, named
rule sets: the call owns that set, so a rule that disappears from it has its
message withdrawn. The identity is explicit because ownership is destructive
— two unrelated callers sharing one context must not silently retract each
other's rules.
- `FormField` marks its field touched when the editor loses focus, so the
default `ShowWhen.WhenTouched` reveals errors on blur as the guide describes.
Nothing in the framework called `MarkTouched` before, leaving that default
unreachable unless the app marked fields by hand (spec 011 §1E.1).
- `ValidationContext.SubmitAttempted`, recorded by `MarkAllTouched()` and
cleared by `ResetAll()`. This is what `ShowWhen.AfterFirstSubmit` waits for;
the framework had no notion of a submit before, so that policy could never
display anything.

- **Framework mechanics are searchable in the ReactorGallery index (spec 064,
issue #1275).** `find-ui --source reactor` answered "what is control X" but not
"how does mechanism Y work": `UseState hook` and `key down event handler`
Expand All @@ -54,6 +78,17 @@ Conventions for contributors:

### Changed

- **`.Validate(fieldName, value, validators…)` now runs its validators during the
render that calls it**, instead of only when a `FormField` mounts the element —
the only consumer that ever ran them. Results are therefore readable by the
same `Render()` that produced them, which is what the documented
`When(ctx.HasError(…), …)` pattern requires. `UseValidationContext()` also
publishes a component-local context to the rendered subtree automatically, so
`FormField`, the visualizers and nested components no longer need an explicit
`.Provide(ValidationContexts.Current, ctx)`; an explicit provide still takes
precedence. The validator-only `.Validate(fieldName, validators…)` overload is
unchanged and still attach-only. (spec 011 §1A.5, issue #1262)

- **The search index emits every clean `SampleCard` on a page, not just the first
(spec 064 §3.2, issue #1275).** Regenerate after changing *any* card on a
gallery page, not only the opening one.
Expand Down Expand Up @@ -114,6 +149,85 @@ Conventions for contributors:

### Fixed

- **The Forms guide's "Validation Context" example now works as written**
(issue #1262). Clicking **Register** on an empty form submitted successfully
with no errors, because five defects compounded: `.Validate()` was inert
outside `FormField`, the context was never provided to the subtree, reading it
in `Render()` was always a pass stale, mutating it scheduled no re-render, and
`ShowWhen.WhenTouched` was unreachable because nothing ever called
`MarkTouched`. Making that path work surfaced a long tail of validation bugs,
all fixed here:
- **Verdicts outliving their control.** A validated control behind a condition,
a whole `FormField`, or a child removed from a parent left its message in the
context, so a form stayed invalid over a field with no control — permanently,
with no way to clear it short of `ClearAll()`. The same happened when a field
name changed, when a rule moved to another context, and when a render threw
after validating.
- **Repaint loops.** Chaining two value overloads on one field rewrote the same
state every pass and announced a change each time; a failing `ValidationRule`
cleared and re-added its message on every reconcile, driving the reconciler
into its re-entrancy limit. A pass that ends where it started is now silent —
including for `Version`, so a `UseMemo` or `UseEffect` keyed on it no longer
re-runs forever.
- **Async rules that silently passed.** A rule built with `ValidationRuleAsync`
never ran its predicate when placed in the element tree, and recorded a
passing verdict when evaluated synchronously — reporting an invalid field as
valid. Mounted rules now dispatch asynchronously; the synchronous paths throw
instead.
- **Async ordering and lifetime.** Overlapping checks applied in completion
order, so a slow failing check could reinstate an error a newer run had
cleared; a verdict about a replaced value survived the change; and a
predicate that never completed held its rule and its `ValidationContext`
alive forever, with another added on every re-render. Passes are now ordered
per producer and cancellable.
- **Rule identity.** Rules were identified by their message text, so an
interpolated message such as `$"Must be after {start}"` orphaned the previous
verdict on every change and errors accumulated; two rules sharing a predicate
retracted each other. Identity is now the field plus the predicate's call
site and position.
- **Fields that were never registered.** A field validated only through
`ValidateFieldAsync`, the rule batches, or `.ValidateAsync(…)` on an element
built outside a render pass was skipped by `MarkAllTouched()` and the
validity summary.
- **Wrong verdicts.** A validator-only attachment inside a `FormField` was
validated against `null`, reporting a required-field error for a control that
plainly had text; `NotifyValueChanged` discarded `AddExternal` messages on
every call rather than only when the value actually moved; a rule batch
containing an async rule installed part of itself before throwing; and
retiring one producer removed another's message whenever both held the same
immutable `ValidationMessage` instance, which a caching validator or a direct
`Add(...)` makes reachable — leaving the field spuriously valid.
- **Missed repaints.** A change raised while a render was in flight was
announced inline, in the middle of the pass that caused it; deferring it
then dropped it when no subscriber existed yet, because a host flushes root
effects only after reconciliation. A parent rendering `ctx.IsValid()` could
therefore never learn that a child had invalidated the shared context.
Notifications are now held for the pass and delivered afterwards, to
whichever subscribers exist by then.
- **Leaks.** A `FormField` that swapped its content control left the displaced
editor's blur binding live, so a pooled control kept marking the old field
and kept its `ValidationContext` alive; and a long-lived context grew one
bookkeeping entry per mounted rule without bound, or per cleared field when
fields are named dynamically.
- **Registration was invisible.** `RegisteredFields` is public and
`MarkAllTouched()` iterates it, but registering a field raised no
notification, so a subscriber rendering the field set could stay stale.
Registration now counts as a change in every path that performs one.
- **Display policies that could never fire.** `FormField`'s default
`ShowWhen.WhenTouched` showed nothing because nothing called `MarkTouched`,
and `ShowWhen.AfterFirstSubmit` showed nothing because no caller supplied the
submit flag — both behaved exactly like `ShowWhen.Never` while silently
accepting the setting. Blur now marks touched, and `MarkAllTouched()` records
the submit attempt that `AfterFirstSubmit` waits for.
- **Documentation.** The guide documented `ValidationContext.IsValidating`,
which does not exist, and said `Validate.MustAsync` runs automatically. The
async section now states the real contract — attach-only, driven from an
effect through `ValidationReconciler.ValidateFieldAsync`. The `ShowWhen`
reference now also names the signal each policy waits for, since two of them
need one the app has to send: `WhenDirty` measures against the baseline only
`SetInitialValue` records, and `AfterFirstSubmit` waits for
`MarkAllTouched()`.

- **The Visual Studio preview failed to start every session.** The extension was built
against a newer `System.Text.Json` than Visual Studio binds extensions to, so it failed
to load at runtime. It now tracks the `Microsoft.VisualStudio.SDK` baseline, with a test
Expand Down
87 changes: 87 additions & 0 deletions docs/_pipeline/apps/forms/App.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -170,6 +171,91 @@ public override Element Render()
}
// </snippet:validation-context>

// <snippet:async-validation>
class AsyncValidationDemo : Component
{
static async Task<bool> 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();
Comment thread
github-code-quality[bot] marked this conversation as resolved.
Fixed
Comment thread
github-code-quality[bot] marked this conversation as resolved.
Fixed
Comment thread
azchohfi marked this conversation as resolved.
Comment thread
azchohfi marked this conversation as resolved.
if (email.Length > 0)
{
// Observed, not discarded. `_ = SomeTask()` drops the returned task on
// the floor, so a uniqueness check that fails for a real reason — the
// network is down, the service 500s — vanishes silently and the field
// just never gets a verdict. Await it inside a local async helper and
// handle the two outcomes separately.
_ = RunCheckAsync(cts.Token);
}
// Cancel on cleanup so the superseded check cannot install its verdict, and
// dispose the source with it — the effect allocates a fresh one per run.
// Note this does not interrupt work already in flight: `Validate.MustAsync`
// awaits your predicate without a token and only observes cancellation once
// it returns. Take a `CancellationToken` in the predicate itself if you need
// the request abandoned rather than its result discarded.
return () =>
{
try { cts.Cancel(); }
finally { cts.Dispose(); }
};

async Task RunCheckAsync(CancellationToken token)
{
try
{
await ValidationReconciler.ValidateFieldAsync(
ctx, "email", email,
[Validate.MustAsync<string>(IsEmailFree, "Email is taken")],
token);
}
catch (OperationCanceledException)
{
// Expected: the user typed again and this check was superseded.
}
catch (Exception ex)
when (ex is not OutOfMemoryException and not StackOverflowException)
{
// Anything else is a real failure — the network is down, the service
// erroring. Surface it however your app reports background faults;
// here, as a message on the field so it cannot pass silently.
//
// Deliberately broad rather than a list of expected exception types:
// the predicate is yours, so the framework cannot know what it can
// throw, and enumerating types means the one you forgot disappears.
// The filter excludes only the two that must never be caught. This
// is the same shape the framework itself uses for app callbacks
// (see CompositeLifecycle.RunAsyncRuleAsync).
ctx.AddExternal("email", $"Could not check availability: {ex.Message}");
}
Comment thread
github-code-quality[bot] marked this conversation as resolved.
Fixed
}
}, 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);
}
}
// </snippet:async-validation>

// <snippet:form-field>
class FormFieldDemo : Component
{
Expand Down Expand Up @@ -422,6 +508,7 @@ public override Element Render()
Component<ValidationDemo>(),
Component<KeepSubmitReachableDemo>(),
Component<ValidationContextDemo>(),
Component<AsyncValidationDemo>(),
Component<FormFieldDemo>(),
Component<MaskedInputDemo>(),
Component<InputFormattersDemo>(),
Expand Down
79 changes: 74 additions & 5 deletions docs/_pipeline/templates/forms.md.dt
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,52 @@ Key pieces:
- **`ctx.IsValid()`** returns `true` when no error-severity messages exist.
- **`ctx.MarkAllTouched()`** reveals all errors on submit attempt.

### How validation runs

Four behaviours make the example above work without any extra wiring. They are
worth knowing because they explain *when* a result becomes visible.

**Validators run during render, not after it.** The value-carrying
`.Validate(fieldName, value, …)` overload evaluates its validators immediately
and writes the result into the enclosing context. C# evaluates arguments left to
right, so the `.Validate(…)` call above produces the verdict that the later
`When(ctx.HasError("email"), …)` sibling reads — in the *same* pass. Put your
error display after the field it describes and it will never lag a render behind.

**The context is provided to the subtree for you.** When
`UseValidationContext()` creates a component-local context (nothing up the tree
provided one), that context is published to whatever the component returns, so
`FormField`, `ValidationVisualizer`, and nested components find it.

Writing `.Provide(ValidationContexts.Current, ctx)` yourself still works, and the
value you provide is what descendants resolve. It does **not** redirect the
providing component's own `.Validate()` calls: those already ran while the
element tree was being built, against the context `UseValidationContext()`
returned — the same reason `UseContext` can't observe a value the same component
provides. To collect several components' fields into one context, provide it from
a parent and let each child call `UseValidationContext()`.

**Mutating the context repaints the form.** `ctx.MarkAllTouched()` changes no
component state, so nothing else would schedule a render; the component that
called `UseValidationContext()` re-renders when the context changes. Re-running
the same validators over an unchanged value is silent, so this does not loop.

**`FormField` marks its field touched on blur.** That is what makes the default
`ShowWhen.WhenTouched` work: focus the editor, move away, and the error replaces
the description. Outside `FormField` — the hand-rolled example above — decide for
yourself when a field counts as touched, typically `ctx.MarkAllTouched()` on a
failed submit.

> **Attach-only cases.** `.Validate(fieldName, validators…)` without a value has
> nothing to check: it records the validators and nothing else. Inside a
> `FormField` the field is still registered on mount, so `MarkAllTouched()`
> covers it, but the validators are not run — they would be checking `null`
> against a control that has a value. On a bare control the attachment is inert:
> no verdict and no registration. A *value-carrying* attachment built outside a
> render pass — cached in a field, assembled inside an event handler — has no
> context to reach at attach time, and that one `FormField` does run when it
> mounts. Pass the value if you want validators to run.
Comment thread
azchohfi marked this conversation as resolved.

## FormField Helper

`FormField()` wraps a control with a label, required indicator, description
Expand All @@ -199,6 +245,13 @@ its content. Errors appear below the field after the field is touched (focus
then blur). The `ShowWhen` parameter controls when errors become visible:
`WhenTouched` (default), `WhenDirty`, `AfterFirstSubmit`, `Always`, or `Never`.

Two of those need a signal you have to send yourself. `WhenDirty` compares
against a baseline, so it stays silent until `SetInitialValue(field, value)`
records one. `AfterFirstSubmit` waits for `MarkAllTouched()` — the call the
submit handler above already makes — and `ResetAll()` puts it back. `ShowWhen`
gates display only: `IsValid()` and `GetMessages()` are current from the first
render whichever policy you pick.

## Built-in Validators

| Validator | Purpose |
Expand Down Expand Up @@ -392,11 +445,27 @@ surface.
### Validating async (uniqueness checks)

`Validate.MustAsync<T>(...)` runs a predicate that returns
`Task<bool>`. 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<bool>`. 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

Expand Down
3 changes: 3 additions & 0 deletions docs/guide/architecture-overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
Loading
Loading