Skip to content

test(forms): reproduce stale debounced async validation results - #71637

Closed
Flarette wants to merge 1 commit into
angular:mainfrom
Flarette:triage/forms-debounce-pending-window
Closed

Flarette wants to merge 1 commit into
angular:mainfrom
Flarette:triage/forms-debounce-pending-window

Conversation

@Flarette

Copy link
Copy Markdown
Contributor

Issue #71636 | Triage note | Forms spec | Core spec | Stuck fix #71414

Why the change

This adds failing specs showing that a debounced async validator keeps its old result instead of reporting pending right after a value change, so that we can agree on a fix in @angular/core before I write it.

Special things to note

Change outline

What this PR adds

packages/core/test/resource/debounce_status_spec.ts                # 2 failing specs: the root cause
packages/forms/signals/
├── test/node/validate_async_debounce_window.spec.ts               # 1 failing spec, 1 passing control spec
└── triage/debounce-pending-window.md                              # spec output, root cause, measurements, fix options

Where the stale state comes from

f().value.set('VALID')                       # the validator settled on 'INVALID' before
  validateAsync params                       → 'VALID'
  debounced(params, 50)
    linkedSignal computation                 # previous state exists, so it's kept: resolved 'INVALID'
    effect (runs later)                      # sets loading and starts the timer
  computed(() => ɵchain(debounced))          → 'INVALID', because chain() only throws while loading
  resource request                           # unchanged, so status stays resolved with the old result

State read in the same task as value.set('VALID'), today:

debounced status    resolved ('INVALID')
field pending()     false
field invalid()     true, the old result
submit()            the action doesn't run

After option A:

debounced status    loading (value 'INVALID')
field pending()     true
field invalid()     false
submit()            the action runs, as it does without debounce

Recommended fix: debounced() reports loading as soon as its source changes

 // packages/core/src/resource/debounce.ts, debounced()
+  const equal = options?.equal ?? Object.is;
+
   const state = linkedSignal<...>({
     source: () => { /* unchanged */ },
     computation: (res, previous) => {
-      // If we already have a state from the effect or a previous read, keep it!
-      // The effect is responsible for timing and state transitions.
       if (previous !== undefined) {
-        return previous.value;
+        // The effect owns timing, but a changed source is pending from the moment it changes.
+        const prev = previous.value;
+        if (!res.thrown && prev.status === 'resolved' && !equal(res.value, prev.value)) {
+          return {status: 'loading', value: prev.value};
+        }
+        return prev;
       }
       /* first evaluation unchanged */
     },
   });

   effect(() => {
     /* ...read the source, unchanged... */
     const currentState = untracked(state);
-    const equal = options?.equal ?? Object.is;
-    if (currentState.status === 'reloading' || currentState.status === 'loading') {
+    // Compare with the value being waited on, or else with the settled value.
+    if (active !== undefined) {
       if (equal(value, pendingValue!)) return;
     } else if (currentState.status === 'resolved') {
       if (equal(value, currentState.value!)) return;
     }
  • The effect guard has to change. The state can now be loading before any wait has started. With the old guard, a source that changes to undefined, which forms uses when validation is skipped, would match pendingValue and stay loading forever.
  • The fix is in core. Forms doesn't change. Any app that reads a chain(debounced(...)) resource right after a write gets loading as well.
  • Synchronous waits. A wait function that returns undefined shows loading until effects flush. Templates don't see it.
  • Validate async signal leaks resource internals #70884 throws sooner. For a snapshot-based factory over chain(debounced(...)), a read in the same task as the write throws LOADING with option A, and returns the stale value today. Both throw after TestBed.tick(). Factories built with resource() aren't affected.

Alternatives

Option Change Tradeoff
B: validateAsync() treats a params mismatch as pending Throw ResourceParamsStatus.LOADING while the debounced value differs from the params Forms only. Needs a new private export from core. Not measured
C: WebMCP waits for the app to be stable before submitting await injector.get(ApplicationRef).whenStable() in execute Mitigation only. It also waits for unrelated work

Decisions for maintainers

  1. debounced() contract. Should status() turn loading at the moment the source changes? The debounced() guide says that it "tells you whether a new value is still pending".
  2. Order with Validate async signal leaks resource internals #70884. Should option A land on its own, so that the Validate async signal leaks resource internals #70884 pattern fails a little sooner but in the same way, or together with the snapshot-based chaining primitive from the fix(forms): safely chain debounced async validation resources #71414 review?
  3. Synchronous waits. Is a brief loading state acceptable for a wait function that returns undefined? The alternative is to report loading only for a numeric wait, which leaves custom async wait functions with today's window.

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 a triage note, with no library changes.

Does this PR introduce a breaking change?

  • Yes
  • No

With validateAsync({debounce}), a value change keeps the previous async
result until effects run: pending() is false, invalid() reflects the
old value, and a submit() in the same task acts on it. The cause is in
core debounced(), which turns loading only inside its effect, so any
chain(debounced(...)) user sees the same stale state.

Add forms and core specs that read state in the same task as the write,
plus a triage note with the root cause, measurements, and fix options.
The specs fail until a fix lands. The note is for review and isn't
meant to be merged.
@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.

Validate async signal leaks resource internals

2 participants