From 356ec6508b3fa3ada8dd623b308800a7cea06c5b Mon Sep 17 00:00:00 2001 From: Andrew Scott Date: Thu, 18 Apr 2024 10:20:37 -0700 Subject: [PATCH] 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 --- .../scheduling/zoneless_scheduling_impl.ts | 43 ++++++++++++------- .../test/change_detection_scheduler_spec.ts | 31 +++++++++++++ 2 files changed, 59 insertions(+), 15 deletions(-) diff --git a/packages/core/src/change_detection/scheduling/zoneless_scheduling_impl.ts b/packages/core/src/change_detection/scheduling/zoneless_scheduling_impl.ts index 6d375dd7d89..27d8d8ecf7e 100644 --- a/packages/core/src/change_detection/scheduling/zoneless_scheduling_impl.ts +++ b/packages/core/src/change_detection/scheduling/zoneless_scheduling_impl.ts @@ -6,6 +6,8 @@ * found in the LICENSE file at https://angular.io/license */ +import {Subscription} from 'rxjs'; + import {ApplicationRef} from '../../application/application_ref'; import {Injectable} from '../../di/injectable'; import {inject} from '../../di/injector_compatibility'; @@ -43,29 +45,40 @@ function trackMicrotaskNotificationForDebugging() { @Injectable({providedIn: 'root'}) export class ChangeDetectionSchedulerImpl implements ChangeDetectionScheduler { - private appRef = inject(ApplicationRef); - private taskService = inject(PendingTasks); - private pendingRenderTaskId: number|null = null; - private shouldRefreshViews = false; + private readonly appRef = inject(ApplicationRef); + private readonly taskService = inject(PendingTasks); private readonly ngZone = inject(NgZone); - runningTick = false; - private cancelScheduledCallback: null|(() => void) = null; private readonly zonelessEnabled = inject(ZONELESS_ENABLED); private readonly disableScheduling = inject(ZONELESS_SCHEDULER_DISABLED, {optional: true}) ?? false; private readonly zoneIsDefined = typeof Zone !== 'undefined' && !!Zone.root.run; private readonly schedulerTickApplyArgs = [{data: {'__scheduler_tick__': true}}]; - private readonly afterTickSubscription = this.appRef.afterTick.subscribe(() => { - // If the scheduler isn't running a tick but the application ticked, that means - // someone called ApplicationRef.tick manually. In this case, we should cancel - // any change detections that had been scheduled so we don't run an extra one. - if (!this.runningTick) { - this.cleanup(); - } - }); + private readonly subscriptions = new Subscription(); + + private cancelScheduledCallback: null|(() => void) = null; + private shouldRefreshViews = false; + private pendingRenderTaskId: number|null = null; private useMicrotaskScheduler = false; + runningTick = false; constructor() { + this.subscriptions.add(this.appRef.afterTick.subscribe(() => { + // If the scheduler isn't running a tick but the application ticked, that means + // someone called ApplicationRef.tick manually. In this case, we should cancel + // any change detections that had been scheduled so we don't run an extra one. + if (!this.runningTick) { + this.cleanup(); + } + })); + this.subscriptions.add(this.ngZone.onUnstable.subscribe(() => { + // If the zone becomes unstable when we're not running tick (this happens from the zone.run), + // we should cancel any scheduled change detection here because at this point we + // know that the zone will stabilize at some point and run change detection itself. + if (!this.runningTick) { + this.cleanup(); + } + })); + // TODO(atscott): These conditions will need to change when zoneless is the default // Instead, they should flip to checking if ZoneJS scheduling is provided this.disableScheduling ||= !this.zonelessEnabled && @@ -197,7 +210,7 @@ export class ChangeDetectionSchedulerImpl implements ChangeDetectionScheduler { } ngOnDestroy() { - this.afterTickSubscription.unsubscribe(); + this.subscriptions.unsubscribe(); this.cleanup(); } diff --git a/packages/core/test/change_detection_scheduler_spec.ts b/packages/core/test/change_detection_scheduler_spec.ts index dfa15739197..ffc009dde51 100644 --- a/packages/core/test/change_detection_scheduler_spec.ts +++ b/packages/core/test/change_detection_scheduler_spec.ts @@ -771,4 +771,35 @@ describe('Angular with scheduler and ZoneJS', () => { await fixture.whenStable(); expect(ticks).toBe(1); }); + + it('does not cause double change detection with run coalescing when both schedulers are notified', + async () => { + if (isNode) { + return; + } + + TestBed.configureTestingModule({ + providers: + [provideZoneChangeDetection({runCoalescing: true, ignoreChangesOutsideZone: false})] + }); + @Component({template: '{{thing()}}', standalone: true}) + class App { + thing = signal('initial'); + } + const fixture = TestBed.createComponent(App); + await fixture.whenStable(); + + let ticks = 0; + TestBed.runInInjectionContext(() => { + afterRender(() => { + ticks++; + }); + }); + // notifies the zoneless scheduler + fixture.componentInstance.thing.set('new'); + // notifies the zone scheduler + TestBed.inject(NgZone).run(() => {}); + await fixture.whenStable(); + expect(ticks).toBe(1); + }); });