Repository navigation
fix(forms): flush debounced descendant values when a field is touched - #71150
Conversation
JeanMeche
left a comment
There was a problem hiding this comment.
AGENT: Great catch on syncing pending values when a field is touched!
However, there is an inconsistency introduced by putting child.flushSync() directly in the descendant loop. If a child control skips validation (for example, if it's hidden or disabled), child.markAsTouchedInternal() will return early and will not iterate over its own descendants. Because of the unconditional child.flushSync() in the parent's loop, this results in:
- The hidden/disabled direct child is flushed.
- The grandchildren of that hidden/disabled child are not flushed.
To fix this inconsistency and fulfill the goal of flushing exactly when a node is marked as touched, we can move this.flushSync() directly into markAsTouchedInternal() right after marking the state, and remove the manual flush from the markAsTouched wrapper.
Here is what that would look like:
markAsTouched(options?: MarkAsTouchedOptions): void {
if (this.structure.isOrphaned()) {
return;
}
untracked(() => {
this.markAsTouchedInternal(options);
// Removed this.flushSync() here
});
}
markAsTouchedInternal(options?: MarkAsTouchedOptions): void {
if (this.structure.isOrphaned()) {
return;
}
if (this.validationState.shouldSkipValidation()) {
return; // Hidden/Disabled fields correctly bail out BEFORE flushing
}
this.nodeState.markAsTouched();
this.flushSync(); // Flush happens EXACTLY when marked as touched
if (options?.skipDescendants) {
return;
}
for (const child of this.structure.children()) {
child.markAsTouchedInternal();
// Removed child.flushSync() here
}
}This ensures that hidden/disabled fields (and their descendants) correctly bail out before flushing, keeping the tree consistent.
JeanMeche
left a comment
There was a problem hiding this comment.
AGENT: Thanks for tackling #71149! Ensuring that debounced descendant inputs flush their pending values before submission actions execute makes total sense and fixes the issue when submitting via Enter while inputs remain focused.
A few items to consider for this PR:
- Lazy Materialization of Skipped Branches: Removing the early return on
shouldSkipValidation()forces eager traversal (this.structure.children()) of all disabled, hidden, or readonly subtrees duringmarkAsTouched(), which eagerly materializes inactive fields that were meant to remain lazy. - Reactivity & State Ordering: Invoking
flushSync()beforemarkAsTouched()updatesvaluewhiletouchedis stillfalse, briefly exposing an intermediate state to reactive consumers. - Edge Case Test Coverage: Adding tests for invalid debounced values on submit (verifying the action is prevented and errors surfaced), duration-based debounces, and multiple sibling fields.
- Squash Commits: Please squash the
fixup!commit prior to merge.
I've left inline comments with additional details!
09f70ad to
b9d0f13
Compare
|
This fixes a regression from #67453 (merged 2026-03-16), so it may be worth saying so in the commit message. #71149 currently says it isn't a regression. Before #67453, With this PR, the guide's statement is true again. One difference from the behavior before #67453: the old walk never reached hidden, disabled, or readonly fields, so their pending values stayed unsynced on I reproduced the bug on |
b9d0f13 to
38a1a94
Compare
f71725a to
a55a545
Compare
|
Thanks @Flarette, both very helpful. I confirmed the regression: before #67453, markAllAsTouched() called the public markAsTouched() on each active field, which flushed it. I've updated the commit message to say so, and to note that flushing disabled/hidden/readonly fields is deliberate. Good to know about markStructuresLive(). I'll keep the materializedChildren() walk so these subtrees stay lazy once #70627 is fixed. |
`markAsTouched()` synchronized a pending debounced value only on the field it was called on. Its descendants were marked as touched, but their pending values stayed unsynced. `submit()` marks the root as touched before running the action, so submitting with Enter while a debounced child still had focus ran the action with the child's old value. Clicking a submit button hid the bug, because the click blurs the input first. This is a regression from angular#67453. Before it, `submit()` called the public `markAsTouched()` on each active field, which flushed it. Flush each field's pending value right after marking it as touched. Disabled, hidden and readonly fields still aren't marked as touched, but unlike before angular#67453 they are flushed too, so their pending value reaches the model before the action reads it. Below them only fields that already exist are visited, since a field that was never created can't have a pending value. Tested with new specs in `//packages/forms/signals/test/node:test`. The specs for descendants, `submit()`, an invalid flushed value, a duration debounce and sibling fields fail without the fix; another checks that touching a form doesn't create the fields of a hidden subtree. Fixes angular#71149
a55a545 to
22acaf1
Compare
markAsTouched()synchronized a pending debounced value only on the field it was called on. Its descendants were marked as touched, but their pending values stayed unsynced.submit()marks the root as touched before running the action, so submitting with Enter while a debounced child still had focus ran the action with the child's old value. Clicking a submit button hid the bug, because the click blurs the input first.Flush each descendant's pending value as it is marked as touched.
Tested with new specs in
//packages/forms/signals/test/node:testthat fail without the fix: one touches the root, the other callssubmit().Fixes #71149