Skip to content

Commit a611b23

Browse files
alxhubthePunderWoman
authored andcommitted
fix(core): run root effects in creation order (#60534)
Previously, the order in which root effects were executed was non-deterministic and relied on the order in which signal graph dirty notifications were propagated. With this commit, root effects are always run in creation order. PR Close #60534
1 parent 9d5a1a8 commit a611b23

3 files changed

Lines changed: 63 additions & 11 deletions

File tree

packages/core/src/render3/reactivity/effect.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -338,7 +338,7 @@ export function createRootEffect(
338338
node.scheduler = scheduler;
339339
node.notifier = notifier;
340340
node.zone = typeof Zone !== 'undefined' ? Zone.current : null;
341-
node.scheduler.schedule(node);
341+
node.scheduler.add(node);
342342
node.notifier.notify(NotificationSource.RootEffect);
343343
return node;
344344
}

packages/core/src/render3/reactivity/root_effect_scheduler.ts

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,15 @@ export interface SchedulableEffect {
1818
zone: {
1919
run<T>(fn: () => T): T;
2020
} | null;
21+
dirty: boolean;
2122
}
2223

2324
/**
2425
* A scheduler which manages the execution of effects.
2526
*/
2627
export abstract class EffectScheduler {
28+
abstract add(e: SchedulableEffect): void;
29+
2730
/**
2831
* Schedule the given effect to be executed at a later time.
2932
*
@@ -52,11 +55,19 @@ export abstract class EffectScheduler {
5255
* when.
5356
*/
5457
export class ZoneAwareEffectScheduler implements EffectScheduler {
55-
private queuedEffectCount = 0;
58+
private dirtyEffectCount = 0;
5659
private queues = new Map<Zone | null, Set<SchedulableEffect>>();
5760

58-
schedule(handle: SchedulableEffect): void {
61+
add(handle: SchedulableEffect): void {
5962
this.enqueue(handle);
63+
this.schedule(handle);
64+
}
65+
66+
schedule(handle: SchedulableEffect): void {
67+
if (!handle.dirty) {
68+
return;
69+
}
70+
this.dirtyEffectCount++;
6071
}
6172

6273
remove(handle: SchedulableEffect): void {
@@ -67,7 +78,9 @@ export class ZoneAwareEffectScheduler implements EffectScheduler {
6778
}
6879

6980
queue.delete(handle);
70-
this.queuedEffectCount--;
81+
if (handle.dirty) {
82+
this.dirtyEffectCount--;
83+
}
7184
}
7285

7386
private enqueue(handle: SchedulableEffect): void {
@@ -80,7 +93,6 @@ export class ZoneAwareEffectScheduler implements EffectScheduler {
8093
if (queue.has(handle)) {
8194
return;
8295
}
83-
this.queuedEffectCount++;
8496
queue.add(handle);
8597
}
8698

@@ -91,25 +103,37 @@ export class ZoneAwareEffectScheduler implements EffectScheduler {
91103
* ordering guarantee between effects scheduled in different zones.
92104
*/
93105
flush(): void {
94-
while (this.queuedEffectCount > 0) {
106+
while (this.dirtyEffectCount > 0) {
107+
let ranOneEffect = false;
95108
for (const [zone, queue] of this.queues) {
96109
// `zone` here must be defined.
97110
if (zone === null) {
98-
this.flushQueue(queue);
111+
ranOneEffect ||= this.flushQueue(queue);
99112
} else {
100-
zone.run(() => this.flushQueue(queue));
113+
ranOneEffect ||= zone.run(() => this.flushQueue(queue));
101114
}
102115
}
116+
117+
// Safeguard against infinite looping if somehow our dirty effect count gets out of sync with
118+
// the dirty flag across all the effects.
119+
if (!ranOneEffect) {
120+
this.dirtyEffectCount = 0;
121+
}
103122
}
104123
}
105124

106-
private flushQueue(queue: Set<SchedulableEffect>): void {
125+
private flushQueue(queue: Set<SchedulableEffect>): boolean {
126+
let ranOneEffect = false;
107127
for (const handle of queue) {
108-
queue.delete(handle);
109-
this.queuedEffectCount--;
128+
if (!handle.dirty) {
129+
continue;
130+
}
131+
this.dirtyEffectCount--;
132+
ranOneEffect = true;
110133

111134
// TODO: what happens if this throws an error?
112135
handle.run();
113136
}
137+
return ranOneEffect;
114138
}
115139
}

packages/core/test/render3/reactivity_spec.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,34 @@ describe('reactivity', () => {
313313
expect(log).toEqual([0, 1]);
314314
});
315315

316+
it('should run root effects in creation order independent of dirty order', async () => {
317+
TestBed.configureTestingModule({
318+
providers: [provideExperimentalZonelessChangeDetection()],
319+
});
320+
const appRef = TestBed.inject(ApplicationRef);
321+
322+
const sourceA = signal(0);
323+
const sourceB = signal(0);
324+
325+
const log: string[] = [];
326+
327+
// Creation order: A, B
328+
effect(() => log.push(`A: ${sourceA()}`), {injector: appRef.injector});
329+
effect(() => log.push(`B: ${sourceB()}`), {injector: appRef.injector});
330+
await appRef.whenStable();
331+
332+
expect(log).toEqual(['A: 0', 'B: 0']);
333+
log.length = 0;
334+
335+
// Dirty order: B, A
336+
sourceB.set(1);
337+
sourceA.set(2);
338+
await appRef.whenStable();
339+
340+
// Effects should still run in A, B creation order.
341+
expect(log).toEqual(['A: 2', 'B: 1']);
342+
});
343+
316344
it('should check components made dirty from markForCheck() from an effect', async () => {
317345
TestBed.configureTestingModule({
318346
providers: [provideExperimentalZonelessChangeDetection()],

0 commit comments

Comments
 (0)