fix(core): Allow hybrid CD scheduling to support multiple "Angular zones" (#57267)

This commit updates the inside/outside NgZone detection of the hybrid CD
scheduling to track the actual instance of the NgZone being used rather
than the name "Angular" (how `isInsideAngularZone` works). This allows
the scheduling to work correctly when there are multiple versions of
Angular running on the page.

fixes #57261

PR Close #57267
This commit is contained in:
Andrew Scott
2024-08-05 08:48:30 -07:00
committed by Andrew Kushnir
parent 3439cc2049
commit 769b6e1973
14 changed files with 175 additions and 6 deletions
@@ -20,7 +20,7 @@ import {
scheduleCallbackWithRafRace,
} from '../../util/callback_scheduler';
import {performanceMarkFeature} from '../../util/performance';
import {NgZone, NoopNgZone} from '../../zone/ng_zone';
import {NgZone, NgZonePrivate, NoopNgZone, angularZoneInstanceIdProperty} from '../../zone/ng_zone';
import {
ChangeDetectionScheduler,
@@ -64,6 +64,9 @@ export class ChangeDetectionSchedulerImpl implements ChangeDetectionScheduler {
private readonly zoneIsDefined = typeof Zone !== 'undefined' && !!Zone.root.run;
private readonly schedulerTickApplyArgs = [{data: {'__scheduler_tick__': true}}];
private readonly subscriptions = new Subscription();
private readonly angularZoneId = this.zoneIsDefined
? (this.ngZone as NgZonePrivate)._inner?.get(angularZoneInstanceIdProperty)
: null;
private cancelScheduledCallback: null | (() => void) = null;
private shouldRefreshViews = false;
@@ -176,7 +179,11 @@ export class ChangeDetectionSchedulerImpl implements ChangeDetectionScheduler {
}
// If we're inside the zone don't bother with scheduler. Zone will stabilize
// eventually and run change detection.
if (!this.zonelessEnabled && this.zoneIsDefined && NgZone.isInAngularZone()) {
if (
!this.zonelessEnabled &&
this.zoneIsDefined &&
Zone.current.get(angularZoneInstanceIdProperty + this.angularZoneId)
) {
return false;
}
+13 -3
View File
@@ -18,6 +18,11 @@ import {AsyncStackTaggingZoneSpec} from './async-stack-tagging';
// ERROR - [JSC_UNDEFINED_VARIABLE] variable Zone is undeclared
declare const Zone: any;
const isAngularZoneProperty = 'isAngularZone';
export const angularZoneInstanceIdProperty = isAngularZoneProperty + '_ID';
let ngZoneInstanceId = 0;
/**
* An injectable service for executing work inside or outside of the Angular zone.
*
@@ -173,7 +178,7 @@ export class NgZone {
*/
static isInAngularZone(): boolean {
// Zone needs to be checked, because this method might be called even when NoopNgZone is used.
return typeof Zone !== 'undefined' && Zone.current.get('isAngularZone') === true;
return typeof Zone !== 'undefined' && Zone.current.get(isAngularZoneProperty) === true;
}
/**
@@ -266,7 +271,7 @@ export class NgZone {
const EMPTY_PAYLOAD = {};
interface NgZonePrivate extends NgZone {
export interface NgZonePrivate extends NgZone {
_outer: Zone;
_inner: Zone;
_nesting: number;
@@ -394,9 +399,14 @@ function forkInnerZoneWithAngularBehavior(zone: NgZonePrivate) {
const delayChangeDetectionForEventsDelegate = () => {
delayChangeDetectionForEvents(zone);
};
const instanceId = ngZoneInstanceId++;
zone._inner = zone._inner.fork({
name: 'angular',
properties: <any>{'isAngularZone': true},
properties: <any>{
[isAngularZoneProperty]: true,
[angularZoneInstanceIdProperty]: instanceId,
[angularZoneInstanceIdProperty + instanceId]: true,
},
onInvokeTask: (
delegate: ZoneDelegate,
current: Zone,
@@ -653,6 +653,9 @@
{
"name": "allocLFrame"
},
{
"name": "angularZoneInstanceIdProperty"
},
{
"name": "animate"
},
@@ -1118,6 +1121,9 @@
{
"name": "invokeQuery"
},
{
"name": "isAngularZoneProperty"
},
{
"name": "isApplicationBootstrapConfig"
},
@@ -1259,6 +1265,9 @@
{
"name": "ngOnChangesSetInput"
},
{
"name": "ngZoneInstanceId"
},
{
"name": "nonNull"
},
@@ -710,6 +710,9 @@
{
"name": "allocLFrame"
},
{
"name": "angularZoneInstanceIdProperty"
},
{
"name": "animate"
},
@@ -1187,6 +1190,9 @@
{
"name": "invokeQuery"
},
{
"name": "isAngularZoneProperty"
},
{
"name": "isApplicationBootstrapConfig"
},
@@ -1325,6 +1331,9 @@
{
"name": "ngOnChangesSetInput"
},
{
"name": "ngZoneInstanceId"
},
{
"name": "noSideEffects"
},
@@ -536,6 +536,9 @@
{
"name": "allocLFrame"
},
{
"name": "angularZoneInstanceIdProperty"
},
{
"name": "appendChild"
},
@@ -941,6 +944,9 @@
{
"name": "invokeHostBindingsInCreationMode"
},
{
"name": "isAngularZoneProperty"
},
{
"name": "isApplicationBootstrapConfig"
},
@@ -1067,6 +1073,9 @@
{
"name": "ngOnChangesSetInput"
},
{
"name": "ngZoneInstanceId"
},
{
"name": "noSideEffects"
},
@@ -602,6 +602,9 @@
{
"name": "allocLFrame"
},
{
"name": "angularZoneInstanceIdProperty"
},
{
"name": "appendChild"
},
@@ -2153,6 +2156,9 @@
{
"name": "invokeTriggerCleanupFns"
},
{
"name": "isAngularZoneProperty"
},
{
"name": "isApplicationBootstrapConfig"
},
@@ -2291,6 +2297,9 @@
{
"name": "ngOnChangesSetInput"
},
{
"name": "ngZoneInstanceId"
},
{
"name": "nonNull"
},
@@ -758,6 +758,9 @@
{
"name": "allocLFrame"
},
{
"name": "angularZoneInstanceIdProperty"
},
{
"name": "appendChild"
},
@@ -1364,6 +1367,9 @@
{
"name": "isAbstractControlOptions"
},
{
"name": "isAngularZoneProperty"
},
{
"name": "isApplicationBootstrapConfig"
},
@@ -1589,6 +1595,9 @@
{
"name": "ngOnChangesSetInput"
},
{
"name": "ngZoneInstanceId"
},
{
"name": "noSideEffects"
},
@@ -740,6 +740,9 @@
{
"name": "allocLFrame"
},
{
"name": "angularZoneInstanceIdProperty"
},
{
"name": "appendChild"
},
@@ -1322,6 +1325,9 @@
{
"name": "invokeHostBindingsInCreationMode"
},
{
"name": "isAngularZoneProperty"
},
{
"name": "isApplicationBootstrapConfig"
},
@@ -1556,6 +1562,9 @@
{
"name": "ngOnChangesSetInput"
},
{
"name": "ngZoneInstanceId"
},
{
"name": "noSideEffects"
},
@@ -398,6 +398,9 @@
{
"name": "allocLFrame"
},
{
"name": "angularZoneInstanceIdProperty"
},
{
"name": "applyNodes"
},
@@ -749,6 +752,9 @@
{
"name": "invokeHostBindingsInCreationMode"
},
{
"name": "isAngularZoneProperty"
},
{
"name": "isApplicationBootstrapConfig"
},
@@ -839,6 +845,9 @@
{
"name": "ngOnChangesSetInput"
},
{
"name": "ngZoneInstanceId"
},
{
"name": "noop"
},
@@ -587,6 +587,9 @@
{
"name": "allocLFrame"
},
{
"name": "angularZoneInstanceIdProperty"
},
{
"name": "appendChild"
},
@@ -1019,6 +1022,9 @@
{
"name": "invokeHostBindingsInCreationMode"
},
{
"name": "isAngularZoneProperty"
},
{
"name": "isApplicationBootstrapConfig"
},
@@ -1175,6 +1181,9 @@
{
"name": "ngOnChangesSetInput"
},
{
"name": "ngZoneInstanceId"
},
{
"name": "nonNull"
},
@@ -878,6 +878,9 @@
{
"name": "allowSanitizationBypassAndThrow"
},
{
"name": "angularZoneInstanceIdProperty"
},
{
"name": "appendChild"
},
@@ -1553,6 +1556,9 @@
{
"name": "invokeHostBindingsInCreationMode"
},
{
"name": "isAngularZoneProperty"
},
{
"name": "isApplicationBootstrapConfig"
},
@@ -1784,6 +1790,9 @@
{
"name": "ngOnChangesSetInput"
},
{
"name": "ngZoneInstanceId"
},
{
"name": "noMatch"
},
@@ -482,6 +482,9 @@
{
"name": "allocLFrame"
},
{
"name": "angularZoneInstanceIdProperty"
},
{
"name": "appendChild"
},
@@ -839,6 +842,9 @@
{
"name": "invokeHostBindingsInCreationMode"
},
{
"name": "isAngularZoneProperty"
},
{
"name": "isApplicationBootstrapConfig"
},
@@ -944,6 +950,9 @@
{
"name": "ngOnChangesSetInput"
},
{
"name": "ngZoneInstanceId"
},
{
"name": "nonNull"
},
@@ -629,6 +629,9 @@
{
"name": "allocLFrame"
},
{
"name": "angularZoneInstanceIdProperty"
},
{
"name": "appendChild"
},
@@ -1124,6 +1127,9 @@
{
"name": "invokeHostBindingsInCreationMode"
},
{
"name": "isAngularZoneProperty"
},
{
"name": "isApplicationBootstrapConfig"
},
@@ -1283,6 +1289,9 @@
{
"name": "ngOnChangesSetInput"
},
{
"name": "ngZoneInstanceId"
},
{
"name": "noSideEffects"
},
@@ -16,7 +16,6 @@ import {
Component,
createComponent,
destroyPlatform,
Directive,
ElementRef,
EnvironmentInjector,
ErrorHandler,
@@ -43,6 +42,7 @@ import {filter, take, tap} from 'rxjs/operators';
import {RuntimeError, RuntimeErrorCode} from '../src/errors';
import {scheduleCallbackWithRafRace} from '../src/util/callback_scheduler';
import {ChangeDetectionSchedulerImpl} from '../src/change_detection/scheduling/zoneless_scheduling_impl';
import {global} from '../src/util/global';
function isStable(injector = TestBed.inject(EnvironmentInjector)): boolean {
@@ -737,6 +737,59 @@ describe('Angular with scheduler and ZoneJS', () => {
expect(fixture.nativeElement.innerText).toContain('new');
});
it('updating signal in another "Angular" zone schedules update when in hybrid mode', async () => {
@Component({template: '{{thing()}}', standalone: true})
class App {
thing = signal('initial');
}
const fixture = TestBed.createComponent(App);
await fixture.whenStable();
expect(fixture.nativeElement.innerText).toContain('initial');
const differentAngularZone: NgZone = Zone.root.run(() => new NgZone({}));
differentAngularZone.run(() => {
fixture.componentInstance.thing.set('new');
});
expect(fixture.isStable()).toBe(false);
await fixture.whenStable();
expect(fixture.nativeElement.innerText).toContain('new');
});
it('updating signal in a child zone of Angular does not schedule extra CD', async () => {
@Component({template: '{{thing()}}', standalone: true})
class App {
thing = signal('initial');
}
const fixture = TestBed.createComponent(App);
await fixture.whenStable();
expect(fixture.nativeElement.innerText).toContain('initial');
const childZone = TestBed.inject(NgZone).run(() => Zone.current.fork({name: 'child'}));
childZone.run(() => {
fixture.componentInstance.thing.set('new');
expect(TestBed.inject(ChangeDetectionSchedulerImpl)['cancelScheduledCallback']).toBeNull();
});
});
it('updating signal in a child Angular zone of Angular does not schedule extra CD', async () => {
@Component({template: '{{thing()}}', standalone: true})
class App {
thing = signal('initial');
}
const fixture = TestBed.createComponent(App);
await fixture.whenStable();
expect(fixture.nativeElement.innerText).toContain('initial');
const childAngularZone = TestBed.inject(NgZone).run(() => new NgZone({}));
childAngularZone.run(() => {
fixture.componentInstance.thing.set('new');
expect(TestBed.inject(ChangeDetectionSchedulerImpl)['cancelScheduledCallback']).toBeNull();
});
});
it('should not run change detection twice if notified during AppRef.tick', async () => {
TestBed.configureTestingModule({
providers: [