diff --git a/goldens/public-api/core/index.api.md b/goldens/public-api/core/index.api.md index d97a16a580d3..a53793cf74cd 100644 --- a/goldens/public-api/core/index.api.md +++ b/goldens/public-api/core/index.api.md @@ -1223,10 +1223,10 @@ export class NgProbeToken { // @public export class NgZone { - constructor({ enableLongStackTrace, shouldCoalesceEventChangeDetection, shouldCoalesceRunChangeDetection, }: { - enableLongStackTrace?: boolean | undefined; - shouldCoalesceEventChangeDetection?: boolean | undefined; - shouldCoalesceRunChangeDetection?: boolean | undefined; + constructor(options: { + enableLongStackTrace?: boolean; + shouldCoalesceEventChangeDetection?: boolean; + shouldCoalesceRunChangeDetection?: boolean; }); static assertInAngularZone(): void; static assertNotInAngularZone(): void; diff --git a/packages/core/src/change_detection/scheduling/flags.ts b/packages/core/src/change_detection/scheduling/flags.ts new file mode 100644 index 000000000000..e37ddd4aa71a --- /dev/null +++ b/packages/core/src/change_detection/scheduling/flags.ts @@ -0,0 +1,9 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +export const SCHEDULE_IN_ROOT_ZONE_DEFAULT = true; diff --git a/packages/core/src/change_detection/scheduling/ng_zone_scheduling.ts b/packages/core/src/change_detection/scheduling/ng_zone_scheduling.ts index 6b795477ee51..99e9a28aeebd 100644 --- a/packages/core/src/change_detection/scheduling/ng_zone_scheduling.ts +++ b/packages/core/src/change_detection/scheduling/ng_zone_scheduling.ts @@ -28,7 +28,9 @@ import { ChangeDetectionScheduler, ZONELESS_SCHEDULER_DISABLED, ZONELESS_ENABLED, + SCHEDULE_IN_ROOT_ZONE, } from './zoneless_scheduling'; +import {SCHEDULE_IN_ROOT_ZONE_DEFAULT} from './flags'; @Injectable({providedIn: 'root'}) export class NgZoneChangeDetectionScheduler { @@ -75,11 +77,14 @@ export const PROVIDED_NG_ZONE = new InjectionToken( export function internalProvideZoneChangeDetection({ ngZoneFactory, ignoreChangesOutsideZone, + scheduleInRootZone, }: { ngZoneFactory?: () => NgZone; ignoreChangesOutsideZone?: boolean; + scheduleInRootZone?: boolean; }): StaticProvider[] { - ngZoneFactory ??= () => new NgZone(getNgZoneOptions()); + ngZoneFactory ??= () => + new NgZone({...getNgZoneOptions(), scheduleInRootZone} as InternalNgZoneOptions); return [ {provide: NgZone, useFactory: ngZoneFactory}, { @@ -115,6 +120,10 @@ export function internalProvideZoneChangeDetection({ // Always disable scheduler whenever explicitly disabled, even if another place called // `provideZoneChangeDetection` without the 'ignore' option. ignoreChangesOutsideZone === true ? {provide: ZONELESS_SCHEDULER_DISABLED, useValue: true} : [], + { + provide: SCHEDULE_IN_ROOT_ZONE, + useValue: scheduleInRootZone ?? SCHEDULE_IN_ROOT_ZONE_DEFAULT, + }, ]; } @@ -140,15 +149,18 @@ export function internalProvideZoneChangeDetection({ */ export function provideZoneChangeDetection(options?: NgZoneOptions): EnvironmentProviders { const ignoreChangesOutsideZone = options?.ignoreChangesOutsideZone; + const scheduleInRootZone = (options as any)?.scheduleInRootZone; const zoneProviders = internalProvideZoneChangeDetection({ ngZoneFactory: () => { const ngZoneOptions = getNgZoneOptions(options); + ngZoneOptions.scheduleInRootZone = scheduleInRootZone; if (ngZoneOptions.shouldCoalesceEventChangeDetection) { performanceMarkFeature('NgZone_CoalesceEvent'); } return new NgZone(ngZoneOptions); }, ignoreChangesOutsideZone, + scheduleInRootZone, }); return makeEnvironmentProviders([ {provide: PROVIDED_NG_ZONE, useValue: true}, diff --git a/packages/core/src/change_detection/scheduling/zoneless_scheduling.ts b/packages/core/src/change_detection/scheduling/zoneless_scheduling.ts index 4cfb5fbe3f9b..fd06ac209a06 100644 --- a/packages/core/src/change_detection/scheduling/zoneless_scheduling.ts +++ b/packages/core/src/change_detection/scheduling/zoneless_scheduling.ts @@ -68,3 +68,8 @@ export const PROVIDED_ZONELESS = new InjectionToken( export const ZONELESS_SCHEDULER_DISABLED = new InjectionToken( typeof ngDevMode === 'undefined' || ngDevMode ? 'scheduler disabled' : '', ); + +// TODO(atscott): Remove in v19. Scheduler should be done with runOutsideAngular. +export const SCHEDULE_IN_ROOT_ZONE = new InjectionToken( + typeof ngDevMode === 'undefined' || ngDevMode ? 'run changes outside zone in root' : '', +); 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 dc25d6ba730e..ade6e448da0d 100644 --- a/packages/core/src/change_detection/scheduling/zoneless_scheduling_impl.ts +++ b/packages/core/src/change_detection/scheduling/zoneless_scheduling_impl.ts @@ -28,6 +28,7 @@ import { ZONELESS_ENABLED, PROVIDED_ZONELESS, ZONELESS_SCHEDULER_DISABLED, + SCHEDULE_IN_ROOT_ZONE, } from './zoneless_scheduling'; const CONSECUTIVE_MICROTASK_NOTIFICATION_LIMIT = 100; @@ -67,6 +68,10 @@ export class ChangeDetectionSchedulerImpl implements ChangeDetectionScheduler { private readonly angularZoneId = this.zoneIsDefined ? (this.ngZone as NgZonePrivate)._inner?.get(angularZoneInstanceIdProperty) : null; + private readonly scheduleInRootZone = + !this.zonelessEnabled && + this.zoneIsDefined && + (inject(SCHEDULE_IN_ROOT_ZONE, {optional: true}) ?? false); private cancelScheduledCallback: null | (() => void) = null; private shouldRefreshViews = false; @@ -156,16 +161,14 @@ export class ChangeDetectionSchedulerImpl implements ChangeDetectionScheduler { ? scheduleCallbackWithMicrotask : scheduleCallbackWithRafRace; this.pendingRenderTaskId = this.taskService.add(); - if (this.zoneIsDefined) { - Zone.root.run(() => { - this.cancelScheduledCallback = scheduleCallback(() => { - this.tick(this.shouldRefreshViews); - }); - }); + if (this.scheduleInRootZone) { + this.cancelScheduledCallback = Zone.root.run(() => + scheduleCallback(() => this.tick(this.shouldRefreshViews)), + ); } else { - this.cancelScheduledCallback = scheduleCallback(() => { - this.tick(this.shouldRefreshViews); - }); + this.cancelScheduledCallback = this.ngZone.runOutsideAngular(() => + scheduleCallback(() => this.tick(this.shouldRefreshViews)), + ); } } @@ -316,6 +319,7 @@ export function provideExperimentalZonelessChangeDetection(): EnvironmentProvide {provide: ChangeDetectionScheduler, useExisting: ChangeDetectionSchedulerImpl}, {provide: NgZone, useClass: NoopNgZone}, {provide: ZONELESS_ENABLED, useValue: true}, + {provide: SCHEDULE_IN_ROOT_ZONE, useValue: false}, typeof ngDevMode === 'undefined' || ngDevMode ? [{provide: PROVIDED_ZONELESS, useValue: true}] : [], diff --git a/packages/core/src/platform/platform_ref.ts b/packages/core/src/platform/platform_ref.ts index a9e5870dcae3..c5df64698605 100644 --- a/packages/core/src/platform/platform_ref.ts +++ b/packages/core/src/platform/platform_ref.ts @@ -56,14 +56,15 @@ export class PlatformRef { moduleFactory: NgModuleFactory, options?: BootstrapOptions, ): Promise> { + const scheduleInRootZone = (options as any)?.scheduleInRootZone; const ngZoneFactory = () => - getNgZone( - options?.ngZone, - getNgZoneOptions({ + getNgZone(options?.ngZone, { + ...getNgZoneOptions({ eventCoalescing: options?.ngZoneEventCoalescing, runCoalescing: options?.ngZoneRunCoalescing, }), - ); + scheduleInRootZone, + }); const ignoreChangesOutsideZone = options?.ignoreChangesOutsideZone; const allAppProviders = [ internalProvideZoneChangeDetection({ diff --git a/packages/core/src/zone/ng_zone.ts b/packages/core/src/zone/ng_zone.ts index cb74a4eba1a5..b806edb91959 100644 --- a/packages/core/src/zone/ng_zone.ts +++ b/packages/core/src/zone/ng_zone.ts @@ -6,10 +6,10 @@ * found in the LICENSE file at https://angular.io/license */ +import {SCHEDULE_IN_ROOT_ZONE_DEFAULT} from '../change_detection/scheduling/flags'; import {RuntimeError, RuntimeErrorCode} from '../errors'; import {EventEmitter} from '../event_emitter'; import {scheduleCallbackWithRafRace} from '../util/callback_scheduler'; -import {global} from '../util/global'; import {noop} from '../util/noop'; import {AsyncStackTaggingZoneSpec} from './async-stack-tagging'; @@ -130,11 +130,18 @@ export class NgZone { */ readonly onError: EventEmitter = new EventEmitter(false); - constructor({ - enableLongStackTrace = false, - shouldCoalesceEventChangeDetection = false, - shouldCoalesceRunChangeDetection = false, + constructor(options: { + enableLongStackTrace?: boolean; + shouldCoalesceEventChangeDetection?: boolean; + shouldCoalesceRunChangeDetection?: boolean; }) { + const { + enableLongStackTrace = false, + shouldCoalesceEventChangeDetection = false, + shouldCoalesceRunChangeDetection = false, + scheduleInRootZone = SCHEDULE_IN_ROOT_ZONE_DEFAULT, + } = options as InternalNgZoneOptions; + if (typeof Zone == 'undefined') { throw new RuntimeError( RuntimeErrorCode.MISSING_ZONEJS, @@ -170,6 +177,7 @@ export class NgZone { !shouldCoalesceRunChangeDetection && shouldCoalesceEventChangeDetection; self.shouldCoalesceRunChangeDetection = shouldCoalesceRunChangeDetection; self.callbackScheduled = false; + self.scheduleInRootZone = scheduleInRootZone; forkInnerZoneWithAngularBehavior(self); } @@ -330,6 +338,11 @@ export interface NgZonePrivate extends NgZone { * */ shouldCoalesceRunChangeDetection: boolean; + + /** + * Whether to schedule the coalesced change detection in the root zone + */ + scheduleInRootZone: boolean; } function checkStable(zone: NgZonePrivate) { @@ -383,7 +396,7 @@ function delayChangeDetectionForEvents(zone: NgZonePrivate) { return; } zone.callbackScheduled = true; - Zone.root.run(() => { + function scheduleCheckStable() { scheduleCallbackWithRafRace(() => { zone.callbackScheduled = false; updateMicroTaskStatus(zone); @@ -391,7 +404,16 @@ function delayChangeDetectionForEvents(zone: NgZonePrivate) { checkStable(zone); zone.isCheckStableRunning = false; }); - }); + } + if (zone.scheduleInRootZone) { + Zone.root.run(() => { + scheduleCheckStable(); + }); + } else { + zone._outer.run(() => { + scheduleCheckStable(); + }); + } updateMicroTaskStatus(zone); } @@ -574,9 +596,10 @@ function hasApplyArgsData(applyArgs: unknown, key: string) { // Set of options recognized by the NgZone. export interface InternalNgZoneOptions { - enableLongStackTrace: boolean; - shouldCoalesceEventChangeDetection: boolean; - shouldCoalesceRunChangeDetection: boolean; + enableLongStackTrace?: boolean; + shouldCoalesceEventChangeDetection?: boolean; + shouldCoalesceRunChangeDetection?: boolean; + scheduleInRootZone?: boolean; } export function getNgZone( diff --git a/packages/core/test/bundling/animations-standalone/bundle.golden_symbols.json b/packages/core/test/bundling/animations-standalone/bundle.golden_symbols.json index caa1cb9631c6..20bf56ed9165 100644 --- a/packages/core/test/bundling/animations-standalone/bundle.golden_symbols.json +++ b/packages/core/test/bundling/animations-standalone/bundle.golden_symbols.json @@ -458,6 +458,9 @@ { "name": "RuntimeError" }, + { + "name": "SCHEDULE_IN_ROOT_ZONE" + }, { "name": "SELF_TOKEN_REGEX" }, diff --git a/packages/core/test/bundling/animations/bundle.golden_symbols.json b/packages/core/test/bundling/animations/bundle.golden_symbols.json index c7846d06cc91..d726b32e14e0 100644 --- a/packages/core/test/bundling/animations/bundle.golden_symbols.json +++ b/packages/core/test/bundling/animations/bundle.golden_symbols.json @@ -497,6 +497,9 @@ { "name": "RuntimeError" }, + { + "name": "SCHEDULE_IN_ROOT_ZONE" + }, { "name": "SELF_TOKEN_REGEX" }, diff --git a/packages/core/test/bundling/cyclic_import/bundle.golden_symbols.json b/packages/core/test/bundling/cyclic_import/bundle.golden_symbols.json index 0081a9013214..6264b4ec787c 100644 --- a/packages/core/test/bundling/cyclic_import/bundle.golden_symbols.json +++ b/packages/core/test/bundling/cyclic_import/bundle.golden_symbols.json @@ -380,6 +380,9 @@ { "name": "RuntimeError" }, + { + "name": "SCHEDULE_IN_ROOT_ZONE" + }, { "name": "SIGNAL" }, diff --git a/packages/core/test/bundling/defer/bundle.golden_symbols.json b/packages/core/test/bundling/defer/bundle.golden_symbols.json index 1d498fb1c7c5..25db52e0bc07 100644 --- a/packages/core/test/bundling/defer/bundle.golden_symbols.json +++ b/packages/core/test/bundling/defer/bundle.golden_symbols.json @@ -428,6 +428,9 @@ { "name": "RuntimeError" }, + { + "name": "SCHEDULE_IN_ROOT_ZONE" + }, { "name": "SIGNAL" }, @@ -1460,6 +1463,9 @@ { "name": "init_fields" }, + { + "name": "init_flags" + }, { "name": "init_forward_ref" }, diff --git a/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json b/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json index 1391e35ffaa7..fb0ebb81ebd2 100644 --- a/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json +++ b/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json @@ -536,6 +536,9 @@ { "name": "RuntimeError" }, + { + "name": "SCHEDULE_IN_ROOT_ZONE" + }, { "name": "SIGNAL" }, diff --git a/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json b/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json index 1523e6b2eade..c36df448f8df 100644 --- a/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json +++ b/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json @@ -518,6 +518,9 @@ { "name": "RuntimeError" }, + { + "name": "SCHEDULE_IN_ROOT_ZONE" + }, { "name": "SIGNAL" }, diff --git a/packages/core/test/bundling/hello_world/bundle.golden_symbols.json b/packages/core/test/bundling/hello_world/bundle.golden_symbols.json index 2a0732999b60..fbadc7937f25 100644 --- a/packages/core/test/bundling/hello_world/bundle.golden_symbols.json +++ b/packages/core/test/bundling/hello_world/bundle.golden_symbols.json @@ -287,6 +287,9 @@ { "name": "RuntimeError" }, + { + "name": "SCHEDULE_IN_ROOT_ZONE" + }, { "name": "SIGNAL" }, diff --git a/packages/core/test/bundling/hydration/bundle.golden_symbols.json b/packages/core/test/bundling/hydration/bundle.golden_symbols.json index ed0ee787c94a..c996a9ba0c54 100644 --- a/packages/core/test/bundling/hydration/bundle.golden_symbols.json +++ b/packages/core/test/bundling/hydration/bundle.golden_symbols.json @@ -431,6 +431,9 @@ { "name": "RuntimeError" }, + { + "name": "SCHEDULE_IN_ROOT_ZONE" + }, { "name": "SIGNAL" }, diff --git a/packages/core/test/bundling/router/bundle.golden_symbols.json b/packages/core/test/bundling/router/bundle.golden_symbols.json index a4a0f8cffba1..829f1fc5e472 100644 --- a/packages/core/test/bundling/router/bundle.golden_symbols.json +++ b/packages/core/test/bundling/router/bundle.golden_symbols.json @@ -644,6 +644,9 @@ { "name": "SAFE_URL_PATTERN" }, + { + "name": "SCHEDULE_IN_ROOT_ZONE" + }, { "name": "SEGMENT_RE" }, diff --git a/packages/core/test/bundling/standalone_bootstrap/bundle.golden_symbols.json b/packages/core/test/bundling/standalone_bootstrap/bundle.golden_symbols.json index 67197b70a769..dd547bbab845 100644 --- a/packages/core/test/bundling/standalone_bootstrap/bundle.golden_symbols.json +++ b/packages/core/test/bundling/standalone_bootstrap/bundle.golden_symbols.json @@ -347,6 +347,9 @@ { "name": "RuntimeError" }, + { + "name": "SCHEDULE_IN_ROOT_ZONE" + }, { "name": "SIGNAL" }, diff --git a/packages/core/test/bundling/todo/bundle.golden_symbols.json b/packages/core/test/bundling/todo/bundle.golden_symbols.json index ea74ff755b67..720f6949ea58 100644 --- a/packages/core/test/bundling/todo/bundle.golden_symbols.json +++ b/packages/core/test/bundling/todo/bundle.golden_symbols.json @@ -407,6 +407,9 @@ { "name": "RuntimeError" }, + { + "name": "SCHEDULE_IN_ROOT_ZONE" + }, { "name": "SIGNAL" }, diff --git a/packages/core/test/change_detection_scheduler_spec.ts b/packages/core/test/change_detection_scheduler_spec.ts index a81af495e66b..b2fe012b8ba0 100644 --- a/packages/core/test/change_detection_scheduler_spec.ts +++ b/packages/core/test/change_detection_scheduler_spec.ts @@ -34,7 +34,14 @@ import { ViewContainerRef, } from '@angular/core'; import {toSignal} from '@angular/core/rxjs-interop'; -import {ComponentFixture, ComponentFixtureAutoDetect, TestBed} from '@angular/core/testing'; +import { + ComponentFixture, + ComponentFixtureAutoDetect, + TestBed, + fakeAsync, + flush, + tick, +} from '@angular/core/testing'; import {bootstrapApplication} from '@angular/platform-browser'; import {withBody} from '@angular/private/testing'; import {BehaviorSubject, firstValueFrom} from 'rxjs'; @@ -648,6 +655,47 @@ describe('Angular with zoneless enabled', () => { (Zone.root as any)._zoneDelegate.handleError = previousHandle; }); + + it('runs inside fakeAsync zone', fakeAsync(() => { + let didRun = false; + @Component({standalone: true, template: ''}) + class App { + ngOnInit() { + didRun = true; + } + } + + TestBed.createComponent(App); + expect(didRun).toBe(false); + tick(); + expect(didRun).toBe(true); + + didRun = false; + TestBed.createComponent(App); + expect(didRun).toBe(false); + flush(); + expect(didRun).toBe(true); + })); + + it('can run inside fakeAsync zone', fakeAsync(() => { + let didRun = false; + @Component({standalone: true, template: ''}) + class App { + ngDoCheck() { + didRun = true; + } + } + + // create component runs inside the zone and triggers CD as a result + const fixture = TestBed.createComponent(App); + didRun = false; + + // schedules change detection + fixture.debugElement.injector.get(ChangeDetectorRef).markForCheck(); + expect(didRun).toBe(false); + tick(); + expect(didRun).toBe(true); + })); }); describe('Angular with scheduler and ZoneJS', () => { @@ -882,4 +930,27 @@ describe('Angular with scheduler and ZoneJS', () => { await fixture.whenStable(); expect(ticks).toBe(1); }); + + it('can run inside fakeAsync zone', fakeAsync(() => { + TestBed.configureTestingModule({ + providers: [provideZoneChangeDetection({scheduleInRootZone: false} as any)], + }); + let didRun = false; + @Component({standalone: true, template: ''}) + class App { + ngDoCheck() { + didRun = true; + } + } + + // create component runs inside the zone and triggers CD as a result + const fixture = TestBed.createComponent(App); + didRun = false; + + // schedules change detection + fixture.debugElement.injector.get(ChangeDetectorRef).markForCheck(); + expect(didRun).toBe(false); + tick(); + expect(didRun).toBe(true); + })); }); diff --git a/packages/core/test/zone/ng_zone_spec.ts b/packages/core/test/zone/ng_zone_spec.ts index 652950c093e4..44c7e60509b6 100644 --- a/packages/core/test/zone/ng_zone_spec.ts +++ b/packages/core/test/zone/ng_zone_spec.ts @@ -6,8 +6,22 @@ * found in the LICENSE file at https://angular.io/license */ -import {EventEmitter, NgZone} from '@angular/core'; -import {fakeAsync, flushMicrotasks, inject, waitForAsync} from '@angular/core/testing'; +import { + Component, + EventEmitter, + NgZone, + afterRender, + provideZoneChangeDetection, +} from '@angular/core'; +import { + TestBed, + fakeAsync, + flush, + flushMicrotasks, + inject, + tick, + waitForAsync, +} from '@angular/core/testing'; import {Log} from '@angular/core/testing/src/testing_internal'; import {firstValueFrom} from 'rxjs'; @@ -914,6 +928,33 @@ function commonTests() { }); }); }); + + it('coalescing can work with fakeAsync', fakeAsync(() => { + if (!isBrowser) { + return; + } + + @Component({ + standalone: true, + template: ` +
+ {{clicked ? 'clicked' : '' }} + `, + }) + class OuterComponent {} + + TestBed.configureTestingModule({ + providers: [ + provideZoneChangeDetection({eventCoalescing: true, scheduleInRootZone: false} as any), + ], + }); + const fixture = TestBed.createComponent(OuterComponent); + fixture.autoDetectChanges(); + + document.querySelector('.clickable')!.dispatchEvent(new MouseEvent('click')); + flush(); + expect(fixture.nativeElement.innerText).toContain('clicked'); + })); }); describe('coalescing', () => {