Skip to content

Commit e3bdab1

Browse files
atscottpkozlowski-opensource
authored andcommitted
refactor(core): Pending effects should make the application unstable (#53835)
In zone-full applications, this is already true because effects are scheduled inside a microtask and tracked by ZoneJS. When not using zones, this should stay consistent on principle and for testability reasons. A general pattern with zoneless testing will be to update state and `await` some promise (i.e. `fixture.whenStable`, which will be linked to `ApplicationRef.isStable`). PR Close #53835
1 parent 342737e commit e3bdab1

File tree

3 files changed

+42
-9
lines changed

3 files changed

+42
-9
lines changed

packages/core/src/render3/after_render_hooks.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,11 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9-
import {assertNotInReactiveContext} from '../core_reactivity_export_internal';
109
import {assertInInjectionContext, Injector, ɵɵdefineInjectable} from '../di';
1110
import {inject} from '../di/injector_compatibility';
1211
import {ErrorHandler} from '../error_handler';
1312
import {DestroyRef} from '../linker/destroy_ref';
13+
import {assertNotInReactiveContext} from '../render3/reactivity/asserts';
1414
import {performanceMarkFeature} from '../util/performance';
1515
import {NgZone} from '../zone/ng_zone';
1616

@@ -129,7 +129,7 @@ export interface InternalAfterNextRenderOptions {
129129
injector?: Injector;
130130
/**
131131
* When true, the hook will execute both on client and on the server.
132-
*
132+
*
133133
* When false or undefined, the hook only executes in the browser.
134134
*/
135135
runOnServer?: boolean;

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

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {FLAGS, LViewFlags, EFFECTS_TO_SCHEDULE} from '../interfaces/view';
2121

2222
import {assertNotInReactiveContext} from './asserts';
2323
import {performanceMarkFeature} from '../../util/performance';
24+
import {PendingTasks} from '../../pending_tasks';
2425

2526

2627
/**
@@ -82,18 +83,21 @@ export abstract class EffectScheduler {
8283
* when.
8384
*/
8485
export class ZoneAwareEffectScheduler implements EffectScheduler {
85-
private hasQueuedFlush = false;
8686
private queuedEffectCount = 0;
8787
private queues = new Map<Zone|null, Set<SchedulableEffect>>();
88+
private readonly pendingTasks = inject(PendingTasks);
89+
private taskId: number|null = null;
8890

8991
scheduleEffect(handle: SchedulableEffect): void {
9092
this.enqueue(handle);
9193

92-
if (!this.hasQueuedFlush) {
93-
queueMicrotask(() => this.flush());
94-
// Leave `hasQueuedFlush` as `true` so we don't queue another microtask if more effects are
95-
// scheduled during flushing. We are guaranteed to empty the whole queue during flush.
96-
this.hasQueuedFlush = false;
94+
if (this.taskId === null) {
95+
const taskId = this.taskId = this.pendingTasks.add();
96+
queueMicrotask(() => {
97+
this.flush();
98+
this.pendingTasks.remove(taskId);
99+
this.taskId = null;
100+
});
97101
}
98102
}
99103

packages/core/test/render3/reactivity_spec.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,12 @@
77
*/
88

99
import {AsyncPipe} from '@angular/common';
10-
import {AfterViewInit, Component, computed, ContentChildren, createComponent, createEnvironmentInjector, destroyPlatform, effect, EnvironmentInjector, ErrorHandler, inject, Injectable, Injector, Input, NgZone, OnChanges, QueryList, signal, SimpleChanges, ViewChild, ViewContainerRef} from '@angular/core';
10+
import {AfterViewInit, ApplicationRef, Component, computed, ContentChildren, createComponent, createEnvironmentInjector, destroyPlatform, effect, EnvironmentInjector, ErrorHandler, inject, Injectable, Injector, Input, NgZone, OnChanges, QueryList, signal, SimpleChanges, ViewChild, ViewContainerRef} from '@angular/core';
1111
import {toObservable} from '@angular/core/rxjs-interop';
1212
import {TestBed} from '@angular/core/testing';
1313
import {bootstrapApplication} from '@angular/platform-browser';
1414
import {withBody} from '@angular/private/testing';
15+
import {filter, firstValueFrom, map} from 'rxjs';
1516

1617
describe('effects', () => {
1718
beforeEach(destroyPlatform);
@@ -44,6 +45,34 @@ describe('effects', () => {
4445
expect(log).not.toEqual(['angular', 'angular']);
4546
}));
4647

48+
it('should contribute to application stableness when an effect is pending', async () => {
49+
const someSignal = signal('initial');
50+
51+
@Component({
52+
standalone: true,
53+
template: '',
54+
})
55+
class App {
56+
unused = effect(() => someSignal());
57+
}
58+
59+
const appRef = TestBed.inject(ApplicationRef);
60+
const componentRef =
61+
createComponent(App, {environmentInjector: TestBed.inject(EnvironmentInjector)});
62+
// Effect is not scheduled until change detection runs for the component
63+
await expectAsync(firstValueFrom(appRef.isStable)).toBeResolvedTo(true);
64+
65+
componentRef.changeDetectorRef.detectChanges();
66+
const stableEmits: boolean[] = [];
67+
const p = firstValueFrom(appRef.isStable.pipe(
68+
map(stable => {
69+
stableEmits.push(stable);
70+
return stableEmits;
71+
}),
72+
filter(emits => emits.length === 2)));
73+
await expectAsync(p).toBeResolvedTo([false, true]);
74+
});
75+
4776
it('should propagate errors to the ErrorHandler', () => {
4877
let run = false;
4978

0 commit comments

Comments
 (0)