Skip to content

Commit 12181b9

Browse files
committed
refactor(core): Use single source of truth for ApplicationRef.isStable (angular#53576)
This commit updates the `ApplicationRef.isStable` implementation to use a single `Observable` to manage the state. This simplifies the mental model quite a bit and removes the need for rx operators like `distinctUntilChanged` and `combineLatest`. PR Close angular#53576
1 parent d49333e commit 12181b9

File tree

17 files changed

+94
-1091
lines changed

17 files changed

+94
-1091
lines changed

goldens/size-tracking/integration-payloads.json

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,21 @@
22
"cli-hello-world": {
33
"uncompressed": {
44
"runtime": 908,
5-
"main": 134468,
5+
"main": 127322,
66
"polyfills": 33792
77
}
88
},
99
"cli-hello-world-ivy-i18n": {
1010
"uncompressed": {
1111
"runtime": 926,
12-
"main": 131777,
12+
"main": 125263,
1313
"polyfills": 34676
1414
}
1515
},
1616
"cli-hello-world-lazy": {
1717
"uncompressed": {
1818
"runtime": 2734,
19-
"main": 238106,
19+
"main": 231349,
2020
"polyfills": 33810,
2121
"src_app_lazy_lazy_routes_ts": 487
2222
}
@@ -49,14 +49,14 @@
4949
"standalone-bootstrap": {
5050
"uncompressed": {
5151
"runtime": 918,
52-
"main": 91485,
52+
"main": 85055,
5353
"polyfills": 33802
5454
}
5555
},
5656
"defer": {
5757
"uncompressed": {
5858
"runtime": 2689,
59-
"main": 121811,
59+
"main": 115193,
6060
"polyfills": 33807,
6161
"src_app_defer_component_ts": 450
6262
}

packages/core/src/application/application_ref.ts

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@
99
import '../util/ng_jit_mode';
1010

1111
import {setThrowInvalidWriteToSignalError} from '@angular/core/primitives/signals';
12-
import {combineLatest, Observable, of} from 'rxjs';
13-
import {distinctUntilChanged, first, map} from 'rxjs/operators';
12+
import {Observable} from 'rxjs';
13+
import {first, map} from 'rxjs/operators';
1414

1515
import {getCompilerFacade, JitCompilerUsage} from '../compiler/compiler_facade';
1616
import {Console} from '../console';
@@ -38,7 +38,7 @@ import {publishDefaultGlobalUtils as _publishDefaultGlobalUtils} from '../render
3838
import {ViewRef as InternalViewRef} from '../render3/view_ref';
3939
import {TESTABILITY} from '../testability/testability';
4040
import {isPromise} from '../util/lang';
41-
import {NgZone, ZONE_IS_STABLE_OBSERVABLE} from '../zone/ng_zone';
41+
import {NgZone} from '../zone/ng_zone';
4242

4343
import {ApplicationInitStatus} from './application_init';
4444

@@ -323,9 +323,6 @@ export class ApplicationRef {
323323
/** @internal */
324324
_views: InternalViewRef<unknown>[] = [];
325325
private readonly internalErrorHandler = inject(INTERNAL_APPLICATION_ERROR_HANDLER);
326-
private readonly zoneIsStable = inject(ZONE_IS_STABLE_OBSERVABLE, {optional: true}) ?? of(true);
327-
private readonly noPendingTasks =
328-
inject(PendingTasks).hasPendingTasks.pipe(map(pending => !pending));
329326

330327
/**
331328
* Indicates whether this instance was destroyed.
@@ -349,11 +346,7 @@ export class ApplicationRef {
349346
* Returns an Observable that indicates when the application is stable or unstable.
350347
*/
351348
public readonly isStable: Observable<boolean> =
352-
combineLatest([this.zoneIsStable, this.noPendingTasks])
353-
.pipe(
354-
map(indicators => indicators.every(stable => stable)),
355-
distinctUntilChanged(),
356-
);
349+
inject(PendingTasks).hasPendingTasks.pipe(map(pending => !pending));
357350

358351
private readonly _injector = inject(EnvironmentInjector);
359352
/**

packages/core/src/change_detection/scheduling.ts

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,9 @@ import {ApplicationRef} from '../application/application_ref';
1212
import {ENVIRONMENT_INITIALIZER, EnvironmentProviders, inject, Injectable, InjectionToken, makeEnvironmentProviders, StaticProvider} from '../di';
1313
import {ErrorHandler, INTERNAL_APPLICATION_ERROR_HANDLER} from '../error_handler';
1414
import {RuntimeError, RuntimeErrorCode} from '../errors';
15+
import {PendingTasks} from '../pending_tasks';
1516
import {NgZone} from '../zone';
16-
import {InternalNgZoneOptions, isStableFactory, ZONE_IS_STABLE_OBSERVABLE} from '../zone/ng_zone';
17+
import {InternalNgZoneOptions} from '../zone/ng_zone';
1718

1819
@Injectable({providedIn: 'root'})
1920
export class NgZoneChangeDetectionScheduler {
@@ -68,8 +69,17 @@ export function internalProvideZoneChangeDetection(ngZoneFactory: () => NgZone):
6869
return () => ngZoneChangeDetectionScheduler!.initialize();
6970
},
7071
},
72+
{
73+
provide: ENVIRONMENT_INITIALIZER,
74+
multi: true,
75+
useFactory: () => {
76+
const service = inject(ZoneStablePendingTask);
77+
return () => {
78+
service.initialize();
79+
};
80+
}
81+
},
7182
{provide: INTERNAL_APPLICATION_ERROR_HANDLER, useFactory: ngZoneApplicationErrorHandlerFactory},
72-
{provide: ZONE_IS_STABLE_OBSERVABLE, useFactory: isStableFactory},
7383
];
7484
}
7585

@@ -171,3 +181,48 @@ export function getNgZoneOptions(options?: NgZoneOptions): InternalNgZoneOptions
171181
shouldCoalesceRunChangeDetection: options?.runCoalescing ?? false,
172182
};
173183
}
184+
185+
@Injectable({providedIn: 'root'})
186+
export class ZoneStablePendingTask {
187+
private readonly subscription = new Subscription();
188+
private initialized = false;
189+
private readonly zone = inject(NgZone);
190+
private readonly pendingTasks = inject(PendingTasks);
191+
192+
initialize() {
193+
if (this.initialized) {
194+
return;
195+
}
196+
this.initialized = true;
197+
198+
let task: number|null = null;
199+
if (!this.zone.isStable && !this.zone.hasPendingMacrotasks && !this.zone.hasPendingMicrotasks) {
200+
task = this.pendingTasks.add();
201+
}
202+
203+
this.zone.runOutsideAngular(() => {
204+
this.subscription.add(this.zone.onStable.subscribe(() => {
205+
NgZone.assertNotInAngularZone();
206+
207+
// Check whether there are no pending macro/micro tasks in the next tick
208+
// to allow for NgZone to update the state.
209+
queueMicrotask(() => {
210+
if (task !== null && !this.zone.hasPendingMacrotasks && !this.zone.hasPendingMicrotasks) {
211+
this.pendingTasks.remove(task);
212+
task = null;
213+
}
214+
});
215+
}));
216+
});
217+
218+
this.subscription.add(this.zone.onUnstable.subscribe(() => {
219+
NgZone.assertInAngularZone();
220+
task ??= this.pendingTasks.add();
221+
}));
222+
}
223+
224+
225+
ngOnDestroy() {
226+
this.subscription.unsubscribe();
227+
}
228+
}

packages/core/src/pending_tasks.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,24 +25,31 @@ import {OnDestroy} from './interface/lifecycle_hooks';
2525
export class PendingTasks implements OnDestroy {
2626
private taskId = 0;
2727
private pendingTasks = new Set<number>();
28+
private get _hasPendingTasks() {
29+
return this.hasPendingTasks.value;
30+
}
2831
hasPendingTasks = new BehaviorSubject<boolean>(false);
2932

3033
add(): number {
31-
this.hasPendingTasks.next(true);
34+
if (!this._hasPendingTasks) {
35+
this.hasPendingTasks.next(true);
36+
}
3237
const taskId = this.taskId++;
3338
this.pendingTasks.add(taskId);
3439
return taskId;
3540
}
3641

3742
remove(taskId: number): void {
3843
this.pendingTasks.delete(taskId);
39-
if (this.pendingTasks.size === 0) {
44+
if (this.pendingTasks.size === 0 && this._hasPendingTasks) {
4045
this.hasPendingTasks.next(false);
4146
}
4247
}
4348

4449
ngOnDestroy(): void {
4550
this.pendingTasks.clear();
46-
this.hasPendingTasks.next(false);
51+
if (this._hasPendingTasks) {
52+
this.hasPendingTasks.next(false);
53+
}
4754
}
4855
}

packages/core/src/render3/after_render_hooks.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {RuntimeError, RuntimeErrorCode} from '../errors';
1414
import {DestroyRef} from '../linker/destroy_ref';
1515
import {assertGreaterThan} from '../util/assert';
1616
import {performanceMarkFeature} from '../util/performance';
17-
import {NgZone} from '../zone';
17+
import {NgZone} from '../zone/ng_zone';
1818

1919
import {isPlatformBrowser} from './util/misc_utils';
2020

packages/core/src/zone/ng_zone.ts

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

9-
import {merge, Observable, Observer, Subscription} from 'rxjs';
10-
import {share} from 'rxjs/operators';
119

12-
import {inject, InjectionToken} from '../di';
1310
import {RuntimeError, RuntimeErrorCode} from '../errors';
1411
import {EventEmitter} from '../event_emitter';
1512
import {global} from '../util/global';
@@ -526,59 +523,6 @@ export class NoopNgZone implements NgZone {
526523
}
527524
}
528525

529-
/**
530-
* Token used to drive ApplicationRef.isStable
531-
*/
532-
export const ZONE_IS_STABLE_OBSERVABLE =
533-
new InjectionToken<Observable<boolean>>(ngDevMode ? 'zone isStable Observable' : '');
534-
535-
export function isStableFactory() {
536-
const zone = inject(NgZone);
537-
let _stable = true;
538-
const isCurrentlyStable = new Observable<boolean>((observer: Observer<boolean>) => {
539-
_stable = zone.isStable && !zone.hasPendingMacrotasks && !zone.hasPendingMicrotasks;
540-
zone.runOutsideAngular(() => {
541-
observer.next(_stable);
542-
observer.complete();
543-
});
544-
});
545-
546-
const isStable = new Observable<boolean>((observer: Observer<boolean>) => {
547-
// Create the subscription to onStable outside the Angular Zone so that
548-
// the callback is run outside the Angular Zone.
549-
let stableSub: Subscription;
550-
zone.runOutsideAngular(() => {
551-
stableSub = zone.onStable.subscribe(() => {
552-
NgZone.assertNotInAngularZone();
553-
554-
// Check whether there are no pending macro/micro tasks in the next tick
555-
// to allow for NgZone to update the state.
556-
queueMicrotask(() => {
557-
if (!_stable && !zone.hasPendingMacrotasks && !zone.hasPendingMicrotasks) {
558-
_stable = true;
559-
observer.next(true);
560-
}
561-
});
562-
});
563-
});
564-
565-
const unstableSub: Subscription = zone.onUnstable.subscribe(() => {
566-
NgZone.assertInAngularZone();
567-
if (_stable) {
568-
_stable = false;
569-
zone.runOutsideAngular(() => {
570-
observer.next(false);
571-
});
572-
}
573-
});
574-
575-
return () => {
576-
stableSub.unsubscribe();
577-
unstableSub.unsubscribe();
578-
};
579-
});
580-
return merge(isCurrentlyStable, isStable.pipe(share()));
581-
}
582526

583527
function shouldBeIgnoredByZone(applyArgs: unknown): boolean {
584528
if (!Array.isArray(applyArgs)) {

0 commit comments

Comments
 (0)