Skip to content

fix(forms): flush debounced descendant values when a field is touched - #71150

Merged
thePunderWoman merged 1 commit into
angular:mainfrom
MeAkib:forms/submit-flush-child-debounce
Nov 11, 2026
Merged

thePunderWoman merged 1 commit into
angular:mainfrom
MeAkib:forms/submit-flush-child-debounce

Conversation

@MeAkib

@MeAkib MeAkib commented Oct 3, 2026

Copy link
Copy Markdown
Contributor

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:test that fail without the fix: one touches the root, the other calls submit().

Fixes #71149

@pullapprove
pullapprove Bot requested a review from atscott October 3, 2026 01:28
@ngbot ngbot Bot added this to the Backlog milestone Oct 3, 2026

@JeanMeche JeanMeche left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. The hidden/disabled direct child is flushed.
  2. 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.

@MeAkib
MeAkib requested a review from JeanMeche October 3, 2026 12:49

@JeanMeche JeanMeche left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. Lazy Materialization of Skipped Branches: Removing the early return on shouldSkipValidation() forces eager traversal (this.structure.children()) of all disabled, hidden, or readonly subtrees during markAsTouched(), which eagerly materializes inactive fields that were meant to remain lazy.
  2. Reactivity & State Ordering: Invoking flushSync() before markAsTouched() updates value while touched is still false, briefly exposing an intermediate state to reactive consumers.
  3. 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.
  4. Squash Commits: Please squash the fixup! commit prior to merge.

I've left inline comments with additional details!

@MeAkib
MeAkib force-pushed the forms/submit-flush-child-debounce branch from 09f70ad to b9d0f13 Compare October 24, 2026 11:34
@MeAkib
MeAkib requested a review from JeanMeche October 24, 2026 11:36
@Flarette

Copy link
Copy Markdown
Contributor

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, submit() ran markAllAsTouched(), which called the public markAsTouched() on every field that wasn't hidden, disabled, or readonly (api/structure.ts L455-467). That method flushed the field it touched (field/node.ts L244-249). #67453 replaced the walk with a single node.markAsTouched() (api/structure.ts L492), and its new recursion, markAsTouchedInternal(), touches descendants without flushing them (field/node.ts L272-296). The guide still describes the old behavior: "All fields are marked as touched before validation, which ensures all debounced values sync immediately" (form-logic.md L432).

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 submit(). This PR flushes them, and their descendants that already exist. That looks like the better behavior to me, but it may be worth a line in the commit message so it reads as deliberate.

I reproduced the bug on main at ce30bb0 with both debounce(path, 10_000) and debounce(path, 'blur'): the action passed to submit(form, action) read the old value. submit(form.child, action) and form.child().markAsTouched() both wrote the pending value first. With b9d0f13 applied, the same specs pass.

@JeanMeche JeanMeche left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AGENT: Thanks for updating this @MeAkib! Left a design note inline regarding the trade-off of flushing inactive (shouldSkipValidation()) fields versus the pre-#67453 early return behavior.

@MeAkib
MeAkib force-pushed the forms/submit-flush-child-debounce branch from b9d0f13 to 38a1a94 Compare October 25, 2026 12:30
@MeAkib
MeAkib force-pushed the forms/submit-flush-child-debounce branch 2 times, most recently from f71725a to a55a545 Compare October 28, 2026 19:24
@MeAkib

MeAkib commented Oct 28, 2026

Copy link
Copy Markdown
Contributor Author

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.

@JeanMeche
JeanMeche removed the request for review from atscott November 11, 2026 21:26
@JeanMeche JeanMeche added action: merge The PR is ready for merge by the caretaker target: patch This PR is targeted for the next patch release labels Nov 11, 2026
`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
@JeanMeche
JeanMeche force-pushed the forms/submit-flush-child-debounce branch from a55a545 to 22acaf1 Compare November 11, 2026 21:27
@thePunderWoman
thePunderWoman merged commit cf37344 into angular:main Nov 11, 2026
23 checks passed
@thePunderWoman

Copy link
Copy Markdown
Contributor

This PR was merged into the repository. The changes were merged into the following branches:

@MeAkib
MeAkib deleted the forms/submit-flush-child-debounce branch November 13, 2026 00:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

action: merge The PR is ready for merge by the caretaker area: forms target: patch This PR is targeted for the next patch release

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Signal forms: submit() runs the action before debounced child values are synced

4 participants