Skip to content

test(forms): add repro specs for late debounce aborts - #71627

Closed
Flarette wants to merge 2 commits into
angular:mainfrom
Flarette:triage/forms-debounce-abort-signal
Closed

Flarette wants to merge 2 commits into
angular:mainfrom
Flarette:triage/forms-debounce-abort-signal

Conversation

@Flarette

@Flarette Flarette commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

Issue #71631 | Triage notes | Repro specs

Why the change

A custom debouncer's abort listener runs late and inside a linkedSignal computation, so a listener that touches a signal throws NG0600 or drops the user's value; this draft adds failing repro specs and asks you to choose the fix before I write it.

Special things to note

  • This is a triage draft with no fix. 7 specs fail on purpose, so CI is red: the 6 new repros and
    the existing direct-write spec in debounce.spec.ts, which now actually resolves its debounce.
  • After you answer the questions below, I'll push the fix to this branch and delete packages/forms/signals/triage/.
  • The built-in debouncers, debounce(path, ms) and debounce(path, 'blur'), aren't affected.

Change outline

What the branch adds:

packages/forms/signals/
├── test/node/api/debounce.spec.ts     # existing direct-write spec now resolves; fails until the fix
├── test/node/debounce_abort.spec.ts   # 6 failing repros, 1 passing control spec
└── triage/debounce-abort-signal.md    # observed output, root cause, measured fix options

Where the abort runs today, in the normal type, wait, blur flow:

user types "ng"
  debounceSync()
    pendingSync.set(controller)
debouncer resolves
  sync()                                  value = "ng"; pendingSync still holds controller
user blurs
  markAsTouched()
    flushSync()
      pendingSync()                       linkedSignal recomputes now, with tracking on
        computation(previous = controller)
          controller.abort()              aborts a debounce that already finished
            abort listener                user code, inside a computation
              searching.set(false)        NG0600, caught by dispatchEvent, reported as uncaught

Recommended fix: make pendingSync a plain field

Abort explicitly and untracked, and clear the field when a debounce completes
(src/field/node.ts):

-  private readonly pendingSync: WritableSignal<AbortController | undefined> = linkedSignal({
-    source: () => this.value(),
-    computation: (_source, previous) => {
-      previous?.value?.abort();
-      return undefined;
-    },
-  });
+  private pendingSync: AbortController | undefined = undefined;

   private _reset(value?: unknown) {
-    this.pendingSync()?.abort();
+    this.abortPendingSync();

   flushSync() {
-    const pending = this.pendingSync();
-    if (pending && !pending.signal.aborted) {
-      pending.abort();
+    if (this.pendingSync) {
+      this.abortPendingSync();
       this.sync();
     }
   }

+  private abortPendingSync(): void {
+    const pending = this.pendingSync;
+    this.pendingSync = undefined;
+    // Abort listeners are user code, so keep their signal reads and writes out of any reactive
+    // context.
+    untracked(() => pending?.abort());
+  }

   private async debounceSync() {
-    const debouncer = untracked(() => {
-      this.pendingSync()?.abort();
-      return this.nodeState.debouncer();
-    });
+    this.abortPendingSync();
+    const debouncer = untracked(this.nodeState.debouncer);
     if (debouncer) {
       const controller = new AbortController();
       const promise = debouncer(controller.signal);
       if (promise) {
-        this.pendingSync.set(controller);
+        const valueBefore = untracked(this.value);
+        this.pendingSync = controller;
         await promise;
-        if (controller.signal.aborted) {
+        if (controller.signal.aborted || this.structure.isOrphaned()) {
           return; // Do not sync if the debounce was aborted.
         }
+        this.pendingSync = undefined;
+        if (!Object.is(untracked(this.value), valueBefore)) {
+          return; // Do not sync over a value that was set directly during the debounce.
+        }
       }
     }

Measured as a temporary patch: the full node target and the full Chromium suite pass, except the
spec for an immediate abort on a direct write (decision 1). It also removes one linkedSignal from
every FieldNode, debounced or not.

Alternative: keep the linkedSignal and make it safe

   computation: (_source, previous) => {
-    previous?.value?.abort();
+    untracked(() => previous?.value?.abort());
     return undefined;
   },

   await promise;
-  if (controller.signal.aborted) {
+  if (controller.signal.aborted || this.structure.isOrphaned()) {
     return; // Do not sync if the debounce was aborted.
   }
+  // Reading `pendingSync` aborts `controller` if `value` changed while it waited.
+  if (untracked(this.pendingSync) !== controller) {
+    return;
+  }
+  // The debounce is done, so a later read of `pendingSync` must not abort it.
+  this.pendingSync.set(undefined);

Measured with the same result. The diff is smaller, but user listeners still run during a
computation. The orphan check must come first: reading pendingSync reads value, which throws
NG01904 on a removed array item.

Design decisions

  1. When the abort fires. The guide and the pendingSync doc comment say it fires when the value
    changes before the debounce resolves. Both fixes keep today's timing for direct model writes: the
    abort fires at the next keystroke, touch, or reset.

    • Recommended: keep the timing and reword the guide.
    • Alternative: abort right away. That needs an effect per debounced field, and effects run
      asynchronously, so it still isn't immediate.

    Current guide text (async-operations.md:472):

    The `abortSignal` fires when the field is touched, or when its value changes before the
    debounce resolves.
    

    Proposed guide text:

    The `abortSignal` fires when the field is touched or reset, or when a newer control value
    arrives before the debounce resolves. If the model is written directly, it fires at the next
    of those events, or not at all if the debounce resolves first.
    
  2. Plain field or linkedSignal. Recommended: plain field. No user code runs inside a
    computation, and nothing aborts a finished debounce by construction.

  3. A debounce that resolves after a direct model write. Today its sync() writes the current
    value back, which is a no-op for plain fields. On a compat field it calls
    FormControl.setValue() and emits valueChanges again. The valueBefore check skips that
    sync(). One flow changes with the plain field: touching after a direct write now reaches
    sync() in flushSync(), where today the recompute returns undefined and skips it. The same
    check would keep that flow as it is today; the measured patch has it only in debounceSync().

    The existing spec "should be ignored if value is directly set before it resolves" already states
    this contract, but it awaited resolve without calling it, so it never reached the late resolve.
    This PR fixes the spec and checks that the late debounce doesn't write the model:

    debounce by function should be ignored if value is directly set before it resolves
        value.set() calls from the late debounce:
            Unexpected $[0] = [ '2000 N Shoreline Blvd' ] in array.
    

    Both measured fixes skip that sync(), so they make it pass.

Questions for maintainers

  1. Is the current timing acceptable if the guide is reworded, or should a direct model write abort
    the debounce right away?
  2. Can pendingSync become a plain field?
  3. Should a debounce that resolves after a direct model write skip its sync(), as the existing
    spec's name says? If so, should flushSync() skip it too?
  4. Should abort listeners be able to read and write signals freely? Both fixes assume yes and run
    them in untracked().

PR Checklist

Please check if your PR fulfills the following requirements:

PR Type

What kind of change does this PR introduce?

  • Bugfix
  • Feature
  • Code style update (formatting, local variables)
  • Refactoring (no functional changes, no api changes)
  • Build related changes
  • CI related changes
  • Documentation content changes
  • angular.dev application / infrastructure changes
  • Other... Please describe: triage. Failing repro specs and triage notes, with no library changes.

What is the current behavior?

Issue Number: #71631

What is the new behavior?

No behavior changes yet. This PR adds failing repro specs and triage notes. The fix will replace the
notes on this branch once the questions above are answered.

Does this PR introduce a breaking change?

  • Yes
  • No

A custom debouncer's abort listener runs late and inside the
`pendingSync` linkedSignal computation. Signal writes in the listener
throw NG0600, signal reads can drop the typed value, and a finished
debounce is aborted on the next touch.

These specs assert the expected behavior and fail on purpose. The
triage notes explain the root cause and the measured fix options. A
fix will replace the notes once maintainers pick an option.
The spec "should be ignored if value is directly set before it
resolves" awaited `debounceResult.resolve` without calling it, so the
debounce never resolved and the spec never reached the late resolve.

Call `resolve()` and check that the late debounce doesn't write the
model. The check fails today: the late `sync()` calls `value.set()`
with the value that's already current. The triage notes explain how
this relates to the open questions on the fix.
@JeanMeche

Copy link
Copy Markdown
Member

Going to close the PRs as they're won't me merged as is. Let's move the discussions to the issues.

@JeanMeche JeanMeche closed this Oct 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants