Skip to content

Commit 356ec65

Browse files
atscottAndrewKushnir
authored andcommitted
refactor(core): Do not duplicate change detection with run coalescing (part 2) (#55403)
This commit prevents doubling change detections when the zoneless scheduler is notified first, followed by the zone becomeing unstable (effectively "scheduling" zone-based change detection). When run coalescing is enabled, this would otherwise result in the zoneless scheduler running change detection first and then change detection running again because of the run coalescing since both scheduler use the same timing function (and then it would be FIFO). PR Close #55403
1 parent bf8814c commit 356ec65

2 files changed

Lines changed: 59 additions & 15 deletions

File tree

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

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

9+
import {Subscription} from 'rxjs';
10+
911
import {ApplicationRef} from '../../application/application_ref';
1012
import {Injectable} from '../../di/injectable';
1113
import {inject} from '../../di/injector_compatibility';
@@ -43,29 +45,40 @@ function trackMicrotaskNotificationForDebugging() {
4345

4446
@Injectable({providedIn: 'root'})
4547
export class ChangeDetectionSchedulerImpl implements ChangeDetectionScheduler {
46-
private appRef = inject(ApplicationRef);
47-
private taskService = inject(PendingTasks);
48-
private pendingRenderTaskId: number|null = null;
49-
private shouldRefreshViews = false;
48+
private readonly appRef = inject(ApplicationRef);
49+
private readonly taskService = inject(PendingTasks);
5050
private readonly ngZone = inject(NgZone);
51-
runningTick = false;
52-
private cancelScheduledCallback: null|(() => void) = null;
5351
private readonly zonelessEnabled = inject(ZONELESS_ENABLED);
5452
private readonly disableScheduling =
5553
inject(ZONELESS_SCHEDULER_DISABLED, {optional: true}) ?? false;
5654
private readonly zoneIsDefined = typeof Zone !== 'undefined' && !!Zone.root.run;
5755
private readonly schedulerTickApplyArgs = [{data: {'__scheduler_tick__': true}}];
58-
private readonly afterTickSubscription = this.appRef.afterTick.subscribe(() => {
59-
// If the scheduler isn't running a tick but the application ticked, that means
60-
// someone called ApplicationRef.tick manually. In this case, we should cancel
61-
// any change detections that had been scheduled so we don't run an extra one.
62-
if (!this.runningTick) {
63-
this.cleanup();
64-
}
65-
});
56+
private readonly subscriptions = new Subscription();
57+
58+
private cancelScheduledCallback: null|(() => void) = null;
59+
private shouldRefreshViews = false;
60+
private pendingRenderTaskId: number|null = null;
6661
private useMicrotaskScheduler = false;
62+
runningTick = false;
6763

6864
constructor() {
65+
this.subscriptions.add(this.appRef.afterTick.subscribe(() => {
66+
// If the scheduler isn't running a tick but the application ticked, that means
67+
// someone called ApplicationRef.tick manually. In this case, we should cancel
68+
// any change detections that had been scheduled so we don't run an extra one.
69+
if (!this.runningTick) {
70+
this.cleanup();
71+
}
72+
}));
73+
this.subscriptions.add(this.ngZone.onUnstable.subscribe(() => {
74+
// If the zone becomes unstable when we're not running tick (this happens from the zone.run),
75+
// we should cancel any scheduled change detection here because at this point we
76+
// know that the zone will stabilize at some point and run change detection itself.
77+
if (!this.runningTick) {
78+
this.cleanup();
79+
}
80+
}));
81+
6982
// TODO(atscott): These conditions will need to change when zoneless is the default
7083
// Instead, they should flip to checking if ZoneJS scheduling is provided
7184
this.disableScheduling ||= !this.zonelessEnabled &&
@@ -197,7 +210,7 @@ export class ChangeDetectionSchedulerImpl implements ChangeDetectionScheduler {
197210
}
198211

199212
ngOnDestroy() {
200-
this.afterTickSubscription.unsubscribe();
213+
this.subscriptions.unsubscribe();
201214
this.cleanup();
202215
}
203216

‎packages/core/test/change_detection_scheduler_spec.ts‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -771,4 +771,35 @@ describe('Angular with scheduler and ZoneJS', () => {
771771
await fixture.whenStable();
772772
expect(ticks).toBe(1);
773773
});
774+
775+
it('does not cause double change detection with run coalescing when both schedulers are notified',
776+
async () => {
777+
if (isNode) {
778+
return;
779+
}
780+
781+
TestBed.configureTestingModule({
782+
providers:
783+
[provideZoneChangeDetection({runCoalescing: true, ignoreChangesOutsideZone: false})]
784+
});
785+
@Component({template: '{{thing()}}', standalone: true})
786+
class App {
787+
thing = signal('initial');
788+
}
789+
const fixture = TestBed.createComponent(App);
790+
await fixture.whenStable();
791+
792+
let ticks = 0;
793+
TestBed.runInInjectionContext(() => {
794+
afterRender(() => {
795+
ticks++;
796+
});
797+
});
798+
// notifies the zoneless scheduler
799+
fixture.componentInstance.thing.set('new');
800+
// notifies the zone scheduler
801+
TestBed.inject(NgZone).run(() => {});
802+
await fixture.whenStable();
803+
expect(ticks).toBe(1);
804+
});
774805
});

0 commit comments

Comments
 (0)