Repository navigation
Description
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
modelin code, for example in an Escape key handler,
and blur before 300 ms pass. The listener clears the timer, then throws onsearching.set(false)
beforeresolve().searching()staystrue. - Lost value. If an abort listener reads a signal while it runs inside the computation, that
signal becomes a dependency of the internalpendingSync. 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.
FieldNodeinpackages/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
dispatchEventsurfaces 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 aneffectper debounced field, and effects run asynchronously anyway. - Replacing the
linkedSignalwith a plain field removes onelinkedSignalfrom every field,
debounced or not. It changes one flow: touching after a direct write now reachessync(). That's
a no-op for plain fields, but on acompatForm()field it callsFormControl.setValue()and emits
valueChangesagain, unless the fix also skips thatsync(). - Keeping the
linkedSignalgives a smaller diff, but user listeners still run during a
computation.
Details.
- Triage PR with the recommended fix, design decisions, and questions: test(forms): add repro specs for late debounce aborts #71627
- Triage notes:
observed output, root cause, and the measured fix options. pendingSync,
flushSync()anddebounceSync(),
linkedSignalcomputation,
guide text,
built-in debouncers.
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=allPlease 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
Type
Projects
- StatusShow more project fieldsNo status
Activity
fix(forms): isolate debounce abort listeners from reactive tracking