Skip to content

Signal forms: right after a value change, a debounced async validator reports the old result instead of pending #71636

Open

Description

@Flarette

Which @angular/* package(s) are the source of the bug?

forms, core

Is this a regression?

No

Description

A field with validateAsync({debounce}) or validateHttp({debounce}) doesn't report pending() right after its value changes. Until Angular runs effects, the field keeps the async result for the previous value. Code that changes a value and then reads the form's state or calls submit() in the same task acts on that old result.

Without debounce, the same field reports pending() at once.

Why it matters.

  • WebMCP's tool execute sets the value and submits in the same task. By reading the code, an AI agent that corrects an invalid value gets "Form submission failed" with the error for the old value.
  • An app that sets a value and submits in one handler, for example from a keyboard shortcut, sees the same failure.
  • The async operations guide says that "validation always reflects the current value".

The cause is in debounced() in @angular/core, so any app that reads a chain(debounced(...)) resource right after a write sees the same stale state. Templates rarely see it, because effects run before views refresh.

Example. A username field whose async check has already reported taken for the current value:

import {Component, resource, signal} from '@angular/core';
import {form, FormField, submit, validateAsync} from '@angular/forms/signals';

@Component({
  selector: 'username-form',
  imports: [FormField],
  template: `<input [formField]="f.username" />`,
})
export class UsernameForm {
  readonly f = form(signal({username: 'taken-name'}), (p) => {
    validateAsync(p.username, {
      params: ({value}) => value(),
      debounce: 300,
      factory: (username) =>
        resource({
          params: username,
          loader: ({params}) => fetch(`/api/username-available?u=${params}`).then((r) => r.json()),
        }),
      onSuccess: (available) => (available ? undefined : {kind: 'taken'}),
      onError: () => undefined,
    });
  });

  // Called from a keyboard shortcut, or the same thing happens through a WebMCP tool call.
  async useSuggestedName() {
    this.f.username().value.set('free-name');
    await submit(this.f, async () => {
      // Save the form.
    });
  }
}

Current behavior, right after value.set('free-name'):

pending()   false
invalid()   true    (the 'taken' error for 'taken-name')
submit()    doesn't run the action

Expected behavior (my assumption):

pending()   true
invalid()   false
submit()    runs the action, as it does for the same validator without debounce

This assumes that a debounced validator should behave like one without debounce, except for the delay before it runs. The debounced() guide supports that: it says that status() "tells you whether a new value is still pending" (debounced.md).

Please provide a link to a minimal reproduction of the bug

Failing specs in triage PR #71637, which changes no library code:

pnpm bazel test //packages/forms/signals/test/node:test --test_arg=--filter="validateAsync debounce window" --test_output=all
pnpm bazel test //packages/core/test/resource:resource --test_arg=--filter="debounced status before effects run" --test_output=all

Please provide the exception or error you saw

There's no error. The specs fail with:

validateAsync debounce window with debounce: a value change is pending at once and submit() isn't blocked
    state read synchronously after value.set():
        Expected $.pending = false to equal true.
        Expected $.invalid = true to equal false.
    submit() ran the action for the new VALID value: Expected false to be true.
debounced status before effects run reports loading as soon as the source changes
    state right after source.set(): Expected $.status = 'resolved' to equal 'loading'.
debounced status before effects run makes a chained resource report loading as soon as the source changes
    chained resource status right after source.set(): Expected 'resolved' to be 'loading'.

Please provide the environment you discovered this bug in (run ng version)

angular/angular main at 6d32550fbbae49851a680b3dc1281d8b899352e7 (2026-10-26), built from source
Test targets: //packages/forms/signals/test/node:test and //packages/core/test/resource:resource
OS: macOS (darwin arm64)

Anything else?

Affected areas:

  • validateAsync() and validateHttp() with debounce, when state is read or submit() is called in the same task as the write.
  • WebMCP tool calls on signal forms with debounced async validators.
  • Any app code that reads a chain(debounced(...)) resource right after the source changes.
  • Not affected: async validators without debounce, and templates, which render after effects run.

Related work. #70884 is about the same chain(debounced(...)) wrapper in validateAsync(). #71414 tries to fix #70884 in forms, but it's stuck: the review on Oct 25 requested changes, and said that the gap isn't specific to signal forms and that keeping the previous settled value while debouncing causes stale results. #71414 also doesn't fix this window. The fix I recommend follows that review: it changes debounced() in core.

Fix options and tradeoffs. The PR has code for each option.

Option What changes Tradeoffs
A (recommended): debounced() reports loading as soon as its source changes The linkedSignal in debounced() returns loading with the previous value when the source changes, and the effect checks whether a wait is active Fixes every chain(debounced(...)) user. With it applied, the specs and the core resource and signal forms suites pass. Synchronous wait functions show loading until effects flush. Factories with the #70884 pattern throw as soon as the value changes, instead of after effects run
B: validateAsync() treats a params mismatch as pending Throw the LOADING marker while the debounced value differs from the params Forms only. Needs a new private export from core
C: WebMCP waits for the app to be stable before submitting One await in WebMCP's execute Mitigation only. Other code that reads pending() right after a write still sees the old result

Where to look in the code:

  • validateAsync() wraps the params in ɵchain(debounced(...)).
  • debounced() keeps its previous state when the source changes, and only its effect moves it to loading.
  • The triage note has the root cause, the measurements, and the full code for each option.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions