Skip to content

Signal forms: a custom debouncer's abort listener runs late, inside a computation #71631

Open

Description

@Flarette

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

forms

Is this a regression?

No

Description

In short. Signal forms pass an AbortSignal to a custom debouncer. It should fire when the user
moves on before the debounce finishes. Today it fires at the wrong time, and its listeners run
inside a linkedSignal computation. An abort listener that touches a signal breaks:

  • Writing a signal throws NG0600, so the write is lost.
  • Reading a signal can make the form drop the value the user typed.
  • A debounce that already finished is aborted on the next blur, so the listener runs anyway.

Why it matters. The obvious use of a custom debouncer is a "searching" indicator, and its abort
listener writes a signal. In such an app, every blur after a finished debounce logs NG0600, and a
programmatic model write can leave the indicator stuck on. The built-in debouncers,
debounce(path, ms) and debounce(path, 'blur'), aren't affected.

Failure mode in user code.

@Component({
  imports: [FormField],
  template: `
    <input [formField]="f.query" />
    @if (searching()) {
      <span>Searching…</span>
    }
  `,
})
export class Search {
  readonly searching = signal(false);
  readonly model = signal({query: ''});
  readonly f = form(this.model, (p) => {
    debounce(p.query, (_ctx, abortSignal) => {
      this.searching.set(true);
      return new Promise<void>((resolve) => {
        const timer = setTimeout(() => {
          this.searching.set(false);
          resolve();
        }, 300);
        abortSignal.addEventListener('abort', () => {
          clearTimeout(timer);
          this.searching.set(false);
          resolve();
        });
      });
    });
  });
}

Starting state: model().query is '', searching() is false, and no debounce is pending.

Current behavior:

1. User types "ng"       searching() → true, model().query → '' (debounce pending)
2. 300 ms pass           searching() → false, model().query → 'ng' (debounce finished)
3. User tabs out (blur)  the finished debounce is aborted; the listener's
                         searching.set(false) throws
                         NG0600: Writing to signals is not allowed in a `computed`.
                         The error is reported as uncaught.

Expected behavior:

1. User types "ng"       searching() → true, model().query → '' (debounce pending)
2. 300 ms pass           searching() → false, model().query → 'ng' (debounce finished)
3. User tabs out (blur)  nothing to abort; no error

Two more failure modes, each covered by a repro spec:

  • Stuck indicator. Type "ng", then set model in code, for example in an Escape key handler,
    and blur before 300 ms pass. The listener clears the timer, then throws on searching.set(false)
    before resolve(). searching() stays true.
  • Lost value. If an abort listener reads a signal while it runs inside the computation, that
    signal becomes a dependency of the internal pendingSync. When it changes, the next read aborts the live debounce. The input shows
    what the user typed, but the model keeps the old value after blur.

Assumptions: expected vs current.

Expected (from the docs and the code's own comments) Current
The abort fires when the value changes before the debounce resolves (guide, pendingSync doc comment). A direct model write only marks pendingSync dirty. The abort fires at the next keystroke, touch, or reset.
A debounce that finished is never aborted. The finished controller stays in pendingSync and is aborted at the next read.
Abort listeners run like any event listener, outside a reactive context, so they can read and write signals. They run inside the linkedSignal computation, with dependency tracking on.

Affected areas.

  • FieldNode in packages/forms/signals/src/field/node.ts: pendingSync, debounceSync(),
    flushSync(), and _reset().
  • Custom debouncers passed to debounce(path, fn) whose abort listeners read or write signals.
  • The guide's description of the abort timing in async-operations.md.
  • Tests: an NG0600 caught by dispatchEvent surfaces as an uncaught error and can fail an
    unrelated spec that runs later.

Tradeoffs of the fix.

  • The recommended fix keeps today's timing for direct model writes and rewords the guide. An
    immediate abort needs an effect per debounced field, and effects run asynchronously anyway.
  • Replacing the linkedSignal with a plain field removes one linkedSignal from every field,
    debounced or not. It changes one flow: touching after a direct write now reaches sync(). That's
    a no-op for plain fields, but on a compatForm() field it calls FormControl.setValue() and emits
    valueChanges again, unless the fix also skips that sync().
  • Keeping the linkedSignal gives a smaller diff, but user listeners still run during a
    computation.

Details.

Please provide a link to a minimal reproduction of the bug

debounce_abort.spec.ts
in the triage PR. Each spec asserts the expected behavior, so 6 of them fail today:

pnpm bazel test //packages/forms/signals/test/node:test --test_output=all

Please provide the exception or error you saw

Error: NG0600: Writing to signals is not allowed in a `computed`. Signal: 'debouncing'.

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

angular/angular main at ce30bb0889 (2026-10-16), Bazel target //packages/forms/signals/test/node:test.
The affected source files are unchanged on main at 3e173fc844 (2026-10-24).

Anything else?

I searched for an existing issue or PR and found none.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions