Skip to content

Commit 06be803

Browse files
atscottAndrewKushnir
authored andcommitted
fix(core): Microtask scheduling should be used after any application synchronization
Previously, Angular would switch from the macrotask to a microtask scheduler _only_ when the scheduler was the trigger for the synchronization. This microtask scheduling is to ensure patterns such as `Promise.resolve().then(() => updateAppStateAgain())` _during_ synchronization are caught and synchronized again within the same event loop (guaranteeing that they aren't split across multiple browser paints). The microtask scheduler should be used after any tick, not just from those than run within the scheduler to always account for the promises within synchronization. This is encountered most frequently during bootstrap, which triggers the tick directly. In this change we exempt `TestBed.tick` and `ComponentFixture.detectChanges` from this behavior. Doing so would affect the timing of stability and tests are quite sensitive to this (e.g. `fixture.whenStable`). It is somewhat unfortunate that we have "special" test-only behavior. However, it is important to acknowledge that this only affects the test-only APIs as well. Any code in the application under test that triggers `ApplicationRef.tick` directly would still use the microtask scheduling behavior. fixes angular#65444
1 parent ae0c590 commit 06be803

File tree

5 files changed

+41
-12
lines changed

5 files changed

+41
-12
lines changed

packages/core/src/application/application_ref.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -293,7 +293,8 @@ export class ApplicationRef {
293293
// Eventually the hostView of the fixture should just attach to ApplicationRef.
294294
private allTestViews: Set<InternalViewRef<unknown>> = new Set();
295295
private autoDetectTestViews: Set<InternalViewRef<unknown>> = new Set();
296-
private includeAllTestViews = false;
296+
/** @internal */
297+
includeAllTestViews = false;
297298
/** @internal */
298299
afterTick = new Subject<void>();
299300
/** @internal */

packages/core/src/change_detection/scheduling/zoneless_scheduling_impl.ts

Lines changed: 33 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -82,12 +82,28 @@ export class ChangeDetectionSchedulerImpl implements ChangeDetectionScheduler {
8282
constructor() {
8383
this.subscriptions.add(
8484
this.appRef.afterTick.subscribe(() => {
85+
// Prevent stabilization if cleanup causes the last task to be removed
86+
// before we can switch to the microtask scheduler.
87+
const task = this.taskService.add();
8588
// If the scheduler isn't running a tick but the application ticked, that means
8689
// someone called ApplicationRef.tick manually. In this case, we should cancel
8790
// any change detections that had been scheduled so we don't run an extra one.
8891
if (!this.runningTick) {
8992
this.cleanup();
93+
// Ticks that happen when ZoneJS is present do not get the microtask scheduling treatment.
94+
// ZoneJS is responsible for rerunning change detection on microtask queue empty.
95+
// Ticks initiated from tests also do not get microtask treatment so those ticks
96+
// do not affect stability timing, which tests are quite sensitive to.
97+
// TODO(atscott): we really should not use microtask scheduler
98+
// _ever_ when ZoneJS is enabled because ZoneJS is responsible for rerunning change
99+
// detection on microtask queue empty. This change breaks some tests
100+
if (!this.zonelessEnabled || this.appRef.includeAllTestViews) {
101+
this.taskService.remove(task);
102+
return;
103+
}
90104
}
105+
this.switchToMicrotaskScheduler();
106+
this.taskService.remove(task);
91107
}),
92108
);
93109
this.subscriptions.add(
@@ -102,6 +118,22 @@ export class ChangeDetectionSchedulerImpl implements ChangeDetectionScheduler {
102118
);
103119
}
104120

121+
// If we're notified of a change within 1 microtask of running change
122+
// detection, run another round in the same event loop. This allows code
123+
// which uses Promise.resolve (see NgModel) to avoid
124+
// ExpressionChanged...Error to still be reflected in a single browser
125+
// paint, even if that spans multiple rounds of change detection.
126+
private switchToMicrotaskScheduler(): void {
127+
this.ngZone.runOutsideAngular(() => {
128+
const task = this.taskService.add();
129+
this.useMicrotaskScheduler = true;
130+
queueMicrotask(() => {
131+
this.useMicrotaskScheduler = false;
132+
this.taskService.remove(task);
133+
});
134+
});
135+
}
136+
105137
notify(source: NotificationSource): void {
106138
if (!this.zonelessEnabled && source === NotificationSource.Listener) {
107139
// When the notification comes from a listener, we skip the notification unless the
@@ -264,21 +296,11 @@ export class ChangeDetectionSchedulerImpl implements ChangeDetectionScheduler {
264296
this.schedulerTickApplyArgs,
265297
);
266298
} catch (e: unknown) {
267-
this.taskService.remove(task);
268299
this.applicationErrorHandler(e);
269300
} finally {
301+
this.taskService.remove(task);
270302
this.cleanup();
271303
}
272-
// If we're notified of a change within 1 microtask of running change
273-
// detection, run another round in the same event loop. This allows code
274-
// which uses Promise.resolve (see NgModel) to avoid
275-
// ExpressionChanged...Error to still be reflected in a single browser
276-
// paint, even if that spans multiple rounds of change detection.
277-
this.useMicrotaskScheduler = true;
278-
scheduleCallbackWithMicrotask(() => {
279-
this.useMicrotaskScheduler = false;
280-
this.taskService.remove(task);
281-
});
282304
}
283305

284306
ngOnDestroy() {

packages/core/test/acceptance/pending_tasks_spec.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,9 @@ describe('public PendingTasks', () => {
7878
// stability is delayed until a tick happens
7979
await expectAsync(applicationRefIsStable(appRef)).toBeResolvedTo(false);
8080
TestBed.inject(ApplicationRef).tick();
81+
// Stability is not synchronous after a tick. We wait for a microtask
82+
// in case there is a Promise inside tick that requires tick again
83+
await Promise.resolve();
8184
await expectAsync(applicationRefIsStable(appRef)).toBeResolvedTo(true);
8285
});
8386

packages/core/test/render3/reactivity_spec.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ describe('reactivity', () => {
9191
expect(isStable).toEqual([true, false]);
9292

9393
appRef.tick();
94+
await appRef.whenStable();
9495

9596
expect(isStable).toEqual([true, false, true]);
9697
});

packages/core/testing/src/test_bed.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -880,6 +880,8 @@ export class TestBedImpl implements TestBed {
880880
// The behavior should be that TestBed.tick, ComponentFixture.detectChanges, and ApplicationRef.tick all result in the test fixtures
881881
// getting synchronized, regardless of whether they are autoDetect: true.
882882
// Automatic scheduling (zone or zoneless) will call _tick which will _not_ include fixtures with autoDetect: false
883+
// If this does get changed, we will need a new flag for the scheduler to use to omit the microtask scheduling
884+
// from a tick initiated by tests.
883885
(appRef as any).includeAllTestViews = true;
884886
appRef.tick();
885887
} finally {

0 commit comments

Comments
 (0)