diff --git a/packages/core/src/application/application_ref.ts b/packages/core/src/application/application_ref.ts index 5a1cfe65e25555..e64a8bd7c2e27f 100644 --- a/packages/core/src/application/application_ref.ts +++ b/packages/core/src/application/application_ref.ts @@ -312,7 +312,8 @@ export class ApplicationRef { private beforeRender = new Subject(); /** @internal */ afterTick = new Subject(); - private get allViews() { + /** @internal */ + get allViews() { return [...this.externalTestViews.keys(), ...this._views]; } 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 9d07ba90562580..13cf1cb48239fe 100644 --- a/packages/core/src/change_detection/scheduling/ng_zone_scheduling.ts +++ b/packages/core/src/change_detection/scheduling/ng_zone_scheduling.ts @@ -26,7 +26,11 @@ import {NgZone} from '../../zone'; import {InternalNgZoneOptions} from '../../zone/ng_zone'; import {alwaysProvideZonelessScheduler} from './flags'; -import {ChangeDetectionScheduler, ZONELESS_SCHEDULER_DISABLED} from './zoneless_scheduling'; +import { + ChangeDetectionScheduler, + ZONELESS_ENABLED, + ZONELESS_SCHEDULER_DISABLED, +} from './zoneless_scheduling'; import {ChangeDetectionSchedulerImpl} from './zoneless_scheduling_impl'; @Injectable({providedIn: 'root'}) @@ -34,6 +38,7 @@ export class NgZoneChangeDetectionScheduler { private readonly zone = inject(NgZone); private readonly changeDetectionScheduler = inject(ChangeDetectionScheduler, {optional: true}); private readonly applicationRef = inject(ApplicationRef); + private readonly zonelessEnabled = inject(ZONELESS_ENABLED); private _onMicrotaskEmptySubscription?: Subscription; @@ -47,7 +52,7 @@ export class NgZoneChangeDetectionScheduler { // `onMicroTaskEmpty` can happen _during_ the zoneless scheduler change detection because // zone.run(() => {}) will result in `checkStable` at the end of the `zone.run` closure // and emit `onMicrotaskEmpty` synchronously if run coalsecing is false. - if (this.changeDetectionScheduler?.runningTick) { + if (this.changeDetectionScheduler?.runningTick || this.zonelessEnabled) { return; } this.zone.run(() => { 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 d6f2b2b53cae96..0d3cb1f9d8b520 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,10 @@ import { ZONELESS_ENABLED, ZONELESS_SCHEDULER_DISABLED, } from './zoneless_scheduling'; +import {EnvironmentInjector} from '../../di/r3_injector'; +import {ENVIRONMENT_INITIALIZER} from '../../di'; +import {CheckNoChangesMode} from '../../render3/state'; +import {ErrorHandler} from '../../error_handler'; const CONSECUTIVE_MICROTASK_NOTIFICATION_LIMIT = 100; let consecutiveMicrotaskNotifications = 0; @@ -66,9 +70,9 @@ export class ChangeDetectionSchedulerImpl implements ChangeDetectionScheduler { private cancelScheduledCallback: null | (() => void) = null; private shouldRefreshViews = false; - private pendingRenderTaskId: number | null = null; private useMicrotaskScheduler = false; runningTick = false; + pendingRenderTaskId: number | null = null; constructor() { this.subscriptions.add( @@ -175,7 +179,7 @@ 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.zoneIsDefined && NgZone.isInAngularZone()) { + if (!this.zonelessEnabled && this.zoneIsDefined && NgZone.isInAngularZone()) { return false; } @@ -299,3 +303,92 @@ export function provideExperimentalZonelessChangeDetection(): EnvironmentProvide {provide: ZONELESS_ENABLED, useValue: true}, ]); } + +/** + * For internal use only. Potentially useful for determining if an application is zoneless-compatible. + * + * @param options Used to configure when the 'exhaustive' checkNoChanges will execute. + * - `interval` will periodically run exhaustive `checkNoChanges` on application views + * - `useNgZoneOnStable` will us ZoneJS to determine when change detection might have run + * in an application using ZoneJS to drive change detection. When the `NgZone.onStable` would + * have emit, all views attached to the ApplicationRef are checked for changes. + * + * With both strategies above, if the zoneless scheduler has a change detection scheduled, + * the check will be skipped for that round. + */ +export function provideExhaustiveCheckNoChangesForDebug(options: { + interval?: number; + useNgZoneOnStable?: boolean; +}) { + if (typeof ngDevMode === 'undefined' || ngDevMode) { + if (options.interval === undefined && !options.useNgZoneOnStable) { + throw new Error('Must provide one of `useNgZoneOnStable` or `interval`'); + } + return makeEnvironmentProviders([ + options?.useNgZoneOnStable ? {provide: NgZone, useClass: DebugNgZoneForCheckNoChanges} : [], + options?.interval !== undefined ? exhaustiveCheckNoChangesInterval(options.interval) : [], + ]); + } else { + return makeEnvironmentProviders([]); + } +} + +@Injectable({providedIn: 'root'}) +export class DebugNgZoneForCheckNoChanges extends NgZone { + applicationRef?: ApplicationRef; + scheduler?: ChangeDetectionSchedulerImpl; + errorHandler?: ErrorHandler; + + constructor(injector: EnvironmentInjector) { + // Use coalsecing to ensure we aren't ever running this check synchronously + super({shouldCoalesceEventChangeDetection: true, shouldCoalesceRunChangeDetection: true}); + + // prevent emits to ensure code doesn't rely on these + this.onMicrotaskEmpty.emit = () => {}; + this.onStable.emit = () => { + this.scheduler ||= injector.get(ChangeDetectionSchedulerImpl); + if (this.scheduler.pendingRenderTaskId || this.scheduler.runningTick) { + return; + } + this.applicationRef ||= injector.get(ApplicationRef); + for (const view of this.applicationRef.allViews) { + try { + view._checkNoChangesWithMode(CheckNoChangesMode.Exhaustive); + } catch (e) { + this.errorHandler ||= injector.get(ErrorHandler); + this.errorHandler.handleError(e); + } + } + }; + this.onUnstable.emit = () => {}; + } +} + +function exhaustiveCheckNoChangesInterval(interval: number) { + return { + provide: ENVIRONMENT_INITIALIZER, + multi: true, + useFactory: () => { + const applicationRef = inject(ApplicationRef); + const errorHandler = inject(ErrorHandler); + const injector = inject(EnvironmentInjector); + const scheduler = inject(ChangeDetectionSchedulerImpl); + + return () => { + const timer = setInterval(() => { + if (scheduler.pendingRenderTaskId || scheduler.runningTick) { + return; + } + for (const view of applicationRef.allViews) { + try { + view._checkNoChangesWithMode(CheckNoChangesMode.Exhaustive); + } catch (e) { + errorHandler.handleError(e); + } + } + }, interval); + injector.onDestroy(() => clearInterval(timer)); + }; + }, + }; +} diff --git a/packages/core/src/core_private_export.ts b/packages/core/src/core_private_export.ts index 8be806b6efe15c..22ec1d997d2c65 100644 --- a/packages/core/src/core_private_export.ts +++ b/packages/core/src/core_private_export.ts @@ -26,6 +26,7 @@ export { NotificationSource as ɵNotificationSource, ZONELESS_ENABLED as ɵZONELESS_ENABLED, } from './change_detection/scheduling/zoneless_scheduling'; +export {provideExhaustiveCheckNoChangesForDebug as ɵprovideExhaustiveCheckNoChangesForDebug} from './change_detection/scheduling/zoneless_scheduling_impl'; export {Console as ɵConsole} from './console'; export { DeferBlockDetails as ɵDeferBlockDetails, diff --git a/packages/core/src/render3/instructions/change_detection.ts b/packages/core/src/render3/instructions/change_detection.ts index 275a47ed63d12f..2a17a010e76e66 100644 --- a/packages/core/src/render3/instructions/change_detection.ts +++ b/packages/core/src/render3/instructions/change_detection.ts @@ -44,7 +44,9 @@ import { ReactiveLViewConsumer, } from '../reactive_lview_consumer'; import { + CheckNoChangesMode, enterView, + isExhaustiveCheckNoChanges, isInCheckNoChangesMode, isRefreshingViews, leaveView, @@ -143,12 +145,16 @@ function detectChangesInViewWhileDirty(lView: LView, mode: ChangeDetectionMode) } } -export function checkNoChangesInternal(lView: LView, notifyErrorHandler = true) { - setIsInCheckNoChangesMode(true); +export function checkNoChangesInternal( + lView: LView, + mode: CheckNoChangesMode, + notifyErrorHandler = true, +) { + setIsInCheckNoChangesMode(mode); try { detectChangesInternal(lView, notifyErrorHandler); } finally { - setIsInCheckNoChangesMode(false); + setIsInCheckNoChangesMode(CheckNoChangesMode.Off); } } @@ -329,12 +335,13 @@ export function refreshView( lView[FLAGS] &= ~(LViewFlags.Dirty | LViewFlags.FirstLViewPass); } } catch (e) { - // If refreshing a view causes an error, we need to remark the ancestors as needing traversal - // because the error might have caused a situation where views below the current location are - // dirty but will be unreachable because the "has dirty children" flag in the ancestors has been - // cleared during change detection and we failed to run to completion. - - markAncestorsForTraversal(lView); + if (!isInCheckNoChangesPass) { + // If refreshing a view causes an error, we need to remark the ancestors as needing traversal + // because the error might have caused a situation where views below the current location are + // dirty but will be unreachable because the "has dirty children" flag in the ancestors has been + // cleared during change detection and we failed to run to completion. + markAncestorsForTraversal(lView); + } throw e; } finally { if (currentConsumer !== null) { @@ -469,6 +476,8 @@ function detectChangesInView(lView: LView, mode: ChangeDetectionMode) { // Refresh views when they have a dirty reactive consumer, regardless of mode. shouldRefreshView ||= !!(consumer?.dirty && consumerPollProducersForChange(consumer)); + shouldRefreshView ||= !!(ngDevMode && isExhaustiveCheckNoChanges()); + // Mark the Flags and `ReactiveNode` as not dirty before refreshing the component, so that they // can be re-dirtied during the refresh process. if (consumer) { diff --git a/packages/core/src/render3/state.ts b/packages/core/src/render3/state.ts index 9dae40cfc4cfeb..7c5c24244e7cf4 100644 --- a/packages/core/src/render3/state.ts +++ b/packages/core/src/render3/state.ts @@ -208,6 +208,12 @@ const instructionState: InstructionState = { skipHydrationRootTNode: null, }; +export enum CheckNoChangesMode { + Off, + Exhaustive, + OnlyDirtyViews, +} + /** * In this mode, any changes in bindings will throw an ExpressionChangedAfterChecked error. * @@ -216,7 +222,7 @@ const instructionState: InstructionState = { * The `checkNoChanges` function is invoked only in ngDevMode=true and verifies that no unintended * changes exist in the change detector or its children. */ -let _isInCheckNoChangesMode = false; +let _checkNoChangesMode: CheckNoChangesMode = CheckNoChangesMode.Off; /** * Flag used to indicate that we are in the middle running change detection on a view @@ -411,12 +417,17 @@ export function getContextLView(): LView { export function isInCheckNoChangesMode(): boolean { !ngDevMode && throwError('Must never be called in production mode'); - return _isInCheckNoChangesMode; + return _checkNoChangesMode !== CheckNoChangesMode.Off; +} + +export function isExhaustiveCheckNoChanges(): boolean { + !ngDevMode && throwError('Must never be called in production mode'); + return _checkNoChangesMode === CheckNoChangesMode.Exhaustive; } -export function setIsInCheckNoChangesMode(mode: boolean): void { +export function setIsInCheckNoChangesMode(mode: CheckNoChangesMode): void { !ngDevMode && throwError('Must never be called in production mode'); - _isInCheckNoChangesMode = mode; + _checkNoChangesMode = mode; } export function isRefreshingViews(): boolean { diff --git a/packages/core/src/render3/view_ref.ts b/packages/core/src/render3/view_ref.ts index 90a794f17aa84e..23aa6326ba30e1 100644 --- a/packages/core/src/render3/view_ref.ts +++ b/packages/core/src/render3/view_ref.ts @@ -34,6 +34,7 @@ import { detachViewFromDOM, trackMovedView, } from './node_manipulation'; +import {CheckNoChangesMode} from './state'; import {storeLViewOnDestroy, updateAncestorTraversalFlagsOnAttach} from './util/view_utils'; // Needed due to tsickle downleveling where multiple `implements` with classes creates @@ -320,7 +321,14 @@ export class ViewRef implements EmbeddedViewRef, ChangeDetectorRefInterfac */ checkNoChanges(): void { if (ngDevMode) { - checkNoChangesInternal(this._lView, this.notifyErrorHandler); + this._checkNoChangesWithMode(CheckNoChangesMode.OnlyDirtyViews); + } + } + + /**@internal */ + _checkNoChangesWithMode(mode: CheckNoChangesMode) { + if (ngDevMode) { + checkNoChangesInternal(this._lView, mode, this.notifyErrorHandler); } } diff --git a/packages/core/test/acceptance/change_detection_spec.ts b/packages/core/test/acceptance/change_detection_spec.ts index 6f7e394f7a6762..a97809643b8fc2 100644 --- a/packages/core/test/acceptance/change_detection_spec.ts +++ b/packages/core/test/acceptance/change_detection_spec.ts @@ -7,8 +7,10 @@ */ import {CommonModule} from '@angular/common'; +import {PLATFORM_BROWSER_ID} from '@angular/common/src/platform_id'; import { ApplicationRef, + NgZone, ChangeDetectionStrategy, ChangeDetectorRef, Component, @@ -29,7 +31,14 @@ import { ViewChild, ViewChildren, ViewContainerRef, + ɵprovideExhaustiveCheckNoChangesForDebug, + provideExperimentalZonelessChangeDetection, + ɵRuntimeError as RuntimeError, + ɵRuntimeErrorCode as RuntimeErrorCode, + afterRender, + PLATFORM_ID, } from '@angular/core'; +import {} from '@angular/core/src/errors'; import {ComponentFixture, fakeAsync, TestBed, tick} from '@angular/core/testing'; import {expect} from '@angular/platform-browser/testing/src/matchers'; import {BehaviorSubject} from 'rxjs'; @@ -1290,6 +1299,171 @@ describe('change detection', () => { expect(comp.contentCheckCount).toEqual(1); expect(comp.viewCheckCount).toEqual(1); }); + + describe('provideExhaustiveCheckNoChangesForDebug', () => { + // Needed because tests in this repo patch rAF to be setTimeout + // and coalescing tries to get the native one but fails so + // coalescing will run a timeout in the zone and cause an infinite loop. + const previousRaf = global.requestAnimationFrame; + beforeEach(() => { + (global as any).requestAnimationFrame = undefined; + }); + afterEach(() => { + (global as any).requestAnimationFrame = previousRaf; + }); + + @Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, + template: '{{state}}{{resolveReadPromise()}}', + }) + class MyApp { + state = 'initial'; + promise?: Promise; + private resolve?: Function; + changeDetectorRef = inject(ChangeDetectorRef); + createReadPromise() { + this.promise = new Promise((resolve) => { + this.resolve = resolve; + }); + } + resolveReadPromise() { + this.resolve?.(); + } + } + + it('throws expression changed with useNgZoneOnStable', async () => { + let error: RuntimeError | undefined = undefined; + TestBed.configureTestingModule({ + providers: [ + {provide: PLATFORM_ID, useValue: PLATFORM_BROWSER_ID}, + provideExperimentalZonelessChangeDetection(), + ɵprovideExhaustiveCheckNoChangesForDebug({useNgZoneOnStable: true}), + { + provide: ErrorHandler, + useValue: { + handleError(e: unknown) { + error = e as RuntimeError; + }, + }, + }, + ], + }); + + let renderHookCalls = 0; + TestBed.runInInjectionContext(() => { + afterRender(() => { + renderHookCalls++; + }); + }); + + const fixture = TestBed.createComponent(MyApp); + await fixture.whenStable(); + expect(renderHookCalls).toBe(1); + + fixture.componentInstance.createReadPromise(); + TestBed.inject(NgZone).run(() => { + fixture.componentInstance.state = 'new'; + }); + await fixture.componentInstance.promise; + // should not have run appplicationRef.tick again + expect(renderHookCalls).toBe(1); + expect(error).toBeDefined(); + expect(error!.code).toEqual(RuntimeErrorCode.EXPRESSION_CHANGED_AFTER_CHECKED); + }); + + it('does not throws expression changed with useNgZoneOnStable if there is a change detection scheduled', async () => { + let error: RuntimeError | undefined = undefined; + TestBed.configureTestingModule({ + providers: [ + provideExperimentalZonelessChangeDetection(), + ɵprovideExhaustiveCheckNoChangesForDebug({useNgZoneOnStable: true}), + { + provide: ErrorHandler, + useValue: { + handleError(e: unknown) { + error = e as RuntimeError; + }, + }, + }, + ], + }); + + const fixture = TestBed.createComponent(MyApp); + await fixture.whenStable(); + + fixture.componentInstance.createReadPromise(); + TestBed.inject(NgZone).run(() => { + setTimeout(() => { + fixture.componentInstance.state = 'new'; + fixture.componentInstance.changeDetectorRef.markForCheck(); + }, 20); + }); + await fixture.componentInstance.promise; + // checkNoChanges runs from zone.run call + expect(error).toBeUndefined(); + + // checkNoChanges runs from the timeout + fixture.componentInstance.createReadPromise(); + await fixture.componentInstance.promise; + expect(error).toBeUndefined(); + }); + + it('throws expression changed with interval', async () => { + let error: RuntimeError | undefined = undefined; + TestBed.configureTestingModule({ + providers: [ + provideExperimentalZonelessChangeDetection(), + ɵprovideExhaustiveCheckNoChangesForDebug({interval: 5}), + { + provide: ErrorHandler, + useValue: { + handleError(e: unknown) { + error = e as RuntimeError; + }, + }, + }, + ], + }); + + const fixture = TestBed.createComponent(MyApp); + fixture.detectChanges(); + + fixture.componentInstance.state = 'new'; + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(error!.code).toEqual(RuntimeErrorCode.EXPRESSION_CHANGED_AFTER_CHECKED); + }); + + it('does not throw expression changed with interval if change detection is scheduled', async () => { + let error: RuntimeError | undefined = undefined; + TestBed.configureTestingModule({ + providers: [ + provideExperimentalZonelessChangeDetection(), + ɵprovideExhaustiveCheckNoChangesForDebug({interval: 0}), + { + provide: ErrorHandler, + useValue: { + handleError(e: unknown) { + error = e as RuntimeError; + }, + }, + }, + ], + }); + + const fixture = TestBed.createComponent(MyApp); + fixture.detectChanges(); + + fixture.componentInstance.state = 'new'; + // markForCheck schedules change detection + fixture.componentInstance.changeDetectorRef.markForCheck(); + // wait beyond the exhaustive check interval + await new Promise((resolve) => setTimeout(resolve, 1)); + + expect(error).toBeUndefined(); + }); + }); }); });