FazBrowse GitHub Viewer | Trending |
URL:
| Home
Tools: [Download Repo ZIP]   [Original HTTPS Page]

fix(core): lazy-initialize debounced state to prevent computation cycle · angular/angular@50e599e · GitHub

Commit 50e599e

Browse files
authored andcommitted
fix(core): lazy-initialize debounced state to prevent computation cycle
When building a debounced resource, we previously eagerly started tracking the 'source' signal state by instantiating a regular signal. However, if this 'debounced' primitive is initialized in a computation reactive graph (like signal forms 'validateAsync'), reading the current UI source dependency eagerly can induce a cycle if we haven't finished calculating the graph node yet. This fix uses a 'linkedSignal' block to define the eager 'source' instead. Because linkedSignals are lazy by default, this bypasses the initial eager evaluation, allowing the containing reactive graph to finish forming first without losing our timing logic inside the ambient effect().
1 parent e01573f commit 50e599e

2 files changed

Lines changed: 90 additions & 12 deletions

File tree

‎packages/core/src/resource/debounce.ts‎

Lines changed: 31 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import {assertInInjectionContext, inject, Injector} from '../di';
1010
import {DestroyRef} from '../linker';
1111
import {effect} from '../render3/reactivity/effect';
12+
import {linkedSignal} from '../render3/reactivity/linked_signal';
1213
import {signal} from '../render3/reactivity/signal';
1314
import {untracked} from '../render3/reactivity/untracked';
1415
import {Resource, ResourceSnapshot, type DebouncedOptions} from './api';
@@ -43,23 +44,41 @@ export function debounced<T>(
4344
}
4445
const injector = options?.injector ?? inject(Injector);
4546

46-
const state = signal<ResourceSnapshot<T>>({
47-
status: 'resolved',
48-
value: untracked(() => {
47+
let active: Promise<void> | void | undefined;
48+
let pendingValue: T | undefined;
49+
50+
injector.get(DestroyRef).onDestroy(() => {
51+
active = undefined;
52+
});
53+
54+
const state = linkedSignal<
55+
{value: T; thrown: false} | {error: unknown; thrown: true},
56+
ResourceSnapshot<T>
57+
>({
58+
source: () => {
4959
try {
5060
setInParamsFunction(true);
51-
return source();
61+
return {value: source(), thrown: false};
62+
} catch (err) {
63+
rethrowFatalErrors(err);
64+
return {error: err, thrown: true};
5265
} finally {
5366
setInParamsFunction(false);
5467
}
55-
}),
56-
});
57-
58-
let active: Promise<void> | void | undefined;
59-
let pendingValue: T | undefined;
68+
},
69+
computation: (res, previous) => {
70+
// If we already have a state from the effect or a previous read, keep it!
71+
// The effect is responsible for timing and state transitions.
72+
if (previous !== undefined) {
73+
return previous.value;
74+
}
6075

61-
injector.get(DestroyRef).onDestroy(() => {
62-
active = undefined;
76+
// On the very first evaluation, determine the initial state synchronously.
77+
if (res.thrown) {
78+
return {status: 'error', error: res.error as Error};
79+
}
80+
return {status: 'resolved', value: res.value};
81+
},
6382
});
6483

6584
effect(
@@ -82,7 +101,7 @@ export function debounced<T>(
82101

83102
// Check if the value is the same as the previous one.
84103
const equal = options?.equal ?? Object.is;
85-
if (currentState.status === 'reloading') {
104+
if (currentState.status === 'reloading' || currentState.status === 'loading') {
86105
if (equal(value, pendingValue!)) return;
87106
} else if (currentState.status === 'resolved') {
88107
if (equal(value, currentState.value!)) return;
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import {ChangeDetectionStrategy, Component, debounced, resource, signal} from '@angular/core';
10+
import {TestBed} from '@angular/core/testing';
11+
12+
import {form, FormField, validateAsync} from '../../public_api';
13+
14+
describe('debounced inside validateAsync bug', () => {
15+
it('should not throw a cycle error when using debounced in validateAsync factory', async () => {
16+
@Component({
17+
selector: 'debounce-bug',
18+
changeDetection: ChangeDetectionStrategy.OnPush,
19+
template: ` <input [formField]="form.hello" /> `,
20+
imports: [FormField],
21+
})
22+
class DebounceBug {
23+
protected readonly model = signal({
24+
hello: 'world',
25+
});
26+
27+
protected readonly form = form(this.model, (path) => {
28+
validateAsync(path.hello, {
29+
params: ({value}) => value(),
30+
factory: (params) => {
31+
const debounce = debounced(params, 300);
32+
return resource({
33+
params: ({chain}) => chain(debounce),
34+
loader: async ({params}) => {
35+
return new Promise<string>((resolve) =>
36+
setTimeout(() => {
37+
resolve('hi');
38+
}, 400),
39+
);
40+
},
41+
});
42+
},
43+
onSuccess: (response) => null,
44+
onError: (error) => null,
45+
});
46+
});
47+
}
48+
49+
const fixture = TestBed.createComponent(DebounceBug);
50+
fixture.detectChanges();
51+
await fixture.whenStable();
52+
53+
// In a "zoneless and async-first" testing environment, just need to change something and wait
54+
const input = fixture.nativeElement.querySelector('input');
55+
input.value = 'hello!';
56+
input.dispatchEvent(new Event('input'));
57+
await fixture.whenStable();
58+
});
59+
});

0 commit comments

Comments
 (0)

Back | FazBrowse Home | New Git URL