diff --git a/adev/src/content/guide/zoneless.md b/adev/src/content/guide/zoneless.md index 94308667953b15..da6562ecd7c5f0 100644 --- a/adev/src/content/guide/zoneless.md +++ b/adev/src/content/guide/zoneless.md @@ -28,28 +28,6 @@ platformBrowser().bootstrapModule(AppModule, {ngZone: 'noop'}); export class AppModule {} ``` -## Testing - -The zoneless provider function can also be used with `TestBed` to help -ensure the components under test are compatible with a Zoneless -Angular application. - -```typescript -TestBed.configureTestingModule({ - providers: [provideExperimentalZonelessChangeDetection()] -}); - -const fixture = TestBed.createComponent(MyComponent); -await fixture.whenStable(); -``` - -To ensure tests have the most similar behavior to production code, -avoid using `fixture.detectChanges()` when possibe. This forces -change detection to run when Angular might otherwise have not -scheduled change detection. Tests should ensure these notifications -are happening and allow Angular to handle when to synchronize -state rather than manually forcing it to happen in the test. - ## Requirements for Zoneless compatibility Angular relies on notifications from core APIs in order to determine when to run change detection and on which views. @@ -104,3 +82,35 @@ taskCleanup(); The framework uses this service internally as well to prevent serialization until asynchronous tasks are complete. These include, but are not limited to, an ongoing Router navigation and an incomplete `HttpClient` request. +## Testing and Debugging + +### Using Zoneless in `TestBed` + +The zoneless provider function can also be used with `TestBed` to help +ensure the components under test are compatible with a Zoneless +Angular application. + +```typescript +TestBed.configureTestingModule({ + providers: [provideExperimentalZonelessChangeDetection()] +}); + +const fixture = TestBed.createComponent(MyComponent); +await fixture.whenStable(); +``` + +To ensure tests have the most similar behavior to production code, +avoid using `fixture.detectChanges()` when possibe. This forces +change detection to run when Angular might otherwise have not +scheduled change detection. Tests should ensure these notifications +are happening and allow Angular to handle when to synchronize +state rather than manually forcing it to happen in the test. + +### Debug-mode check to ensure updates are detected + +Angular also provides an additional tool to help verify that an application is making +updates to state in a zoneless-compatible way. `provideExperimentalCheckNoChangesForDebug` +can be used to periodically check to ensure that no bindings have been updated +without a notification. Angular will throw `ExpressionChangedAfterItHasBeenCheckedError` +if there is an updated binding that would not have refreshed by the zoneless change +detection. diff --git a/goldens/public-api/core/index.md b/goldens/public-api/core/index.md index dc04a3bb73079c..45067475456311 100644 --- a/goldens/public-api/core/index.md +++ b/goldens/public-api/core/index.md @@ -4,6 +4,7 @@ ```ts +import { EnvironmentProviders as EnvironmentProviders_2 } from '@angular/core'; import { Observable } from 'rxjs'; import { SIGNAL } from '@angular/core/primitives/signals'; import { SignalNode } from '@angular/core/primitives/signals'; @@ -1365,6 +1366,13 @@ export class PlatformRef { // @public export type Predicate = (value: T) => boolean; +// @public +export function provideExperimentalCheckNoChangesForDebug(options: { + interval?: number; + useNgZoneOnStable?: boolean; + exhaustive?: boolean; +}): EnvironmentProviders_2; + // @public export function provideExperimentalZonelessChangeDetection(): EnvironmentProviders; 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/exhaustive_check_no_changes.ts b/packages/core/src/change_detection/scheduling/exhaustive_check_no_changes.ts new file mode 100644 index 00000000000000..1d6dabeebe35b4 --- /dev/null +++ b/packages/core/src/change_detection/scheduling/exhaustive_check_no_changes.ts @@ -0,0 +1,171 @@ +/** + * @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 + */ + +import {ApplicationRef} from '../../application/application_ref'; +import {ChangeDetectionSchedulerImpl} from './zoneless_scheduling_impl'; +import {inject} from '../../di/injector_compatibility'; +import {makeEnvironmentProviders} from '../../di/provider_collection'; +import {NgZone} from '../../zone/ng_zone'; + +import {EnvironmentInjector} from '../../di/r3_injector'; +import {ENVIRONMENT_INITIALIZER} from '../../di/initializer_token'; +import {CheckNoChangesMode} from '../../render3/state'; +import {ErrorHandler} from '../../error_handler'; +import {checkNoChangesInternal} from '../../render3/instructions/change_detection'; +import {ZONELESS_ENABLED} from './zoneless_scheduling'; + +/** + * Used to periodically verify no expressions have changed after they were checked. + * + * @param options Used to configure when the check 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. + * - 'exhaustive' means that all views attached to `ApplicationRef` and all the descendants of those views will be + * checked for changes (excluding those subtrees which are detached via `ChangeDetectorRef.detach()`). + * This is useful because the check that runs after regular change detection does not work for components using `ChangeDetectionStrategy.OnPush`. + * This check is will surface any existing errors hidden by `OnPush` components. By default, this check is exhaustive + * and will always check all views, regardless of their "dirty" state and `ChangeDetectionStrategy`. + * + * When the `useNgZoneOnStable` option is `true`, this function will provide its own `NgZone` implementation and needs + * to come after any other `NgZone` provider, including `provideZoneChangeDetection()` and `provideExperimentalZonelessChangeDetection()`. + * + * @experimental + * @publicApi + */ +export function provideExperimentalCheckNoChangesForDebug(options: { + interval?: number; + useNgZoneOnStable?: boolean; + exhaustive?: boolean; +}) { + if (typeof ngDevMode === 'undefined' || ngDevMode) { + if (options.interval === undefined && !options.useNgZoneOnStable) { + throw new Error('Must provide one of `useNgZoneOnStable` or `interval`'); + } + const checkNoChangesMode = + options?.exhaustive === false + ? CheckNoChangesMode.OnlyDirtyViews + : CheckNoChangesMode.Exhaustive; + return makeEnvironmentProviders([ + options?.useNgZoneOnStable + ? {provide: NgZone, useFactory: () => new DebugNgZoneForCheckNoChanges(checkNoChangesMode)} + : [], + options?.interval !== undefined + ? exhaustiveCheckNoChangesInterval(options.interval, checkNoChangesMode) + : [], + { + provide: ENVIRONMENT_INITIALIZER, + multi: true, + useValue: () => { + if ( + options?.useNgZoneOnStable && + !(inject(NgZone) instanceof DebugNgZoneForCheckNoChanges) + ) { + throw new Error( + '`provideCheckNoChangesForDebug` with `useNgZoneOnStable` must be after any other provider for `NgZone`.', + ); + } + }, + }, + ]); + } else { + return makeEnvironmentProviders([]); + } +} + +export class DebugNgZoneForCheckNoChanges extends NgZone { + private applicationRef?: ApplicationRef; + private scheduler?: ChangeDetectionSchedulerImpl; + private errorHandler?: ErrorHandler; + private readonly injector = inject(EnvironmentInjector); + + constructor(private readonly checkNoChangesMode: CheckNoChangesMode) { + const zonelessEnabled = inject(ZONELESS_ENABLED); + // Use coalsecing to ensure we aren't ever running this check synchronously + super({ + shouldCoalesceEventChangeDetection: true, + shouldCoalesceRunChangeDetection: zonelessEnabled, + }); + + if (zonelessEnabled) { + // prevent emits to ensure code doesn't rely on these + this.onMicrotaskEmpty.emit = () => {}; + this.onStable.emit = () => { + this.scheduler ||= this.injector.get(ChangeDetectionSchedulerImpl); + if (this.scheduler.pendingRenderTaskId || this.scheduler.runningTick) { + return; + } + this.checkApplicationViews(); + }; + this.onUnstable.emit = () => {}; + } else { + this.runOutsideAngular(() => { + this.onStable.subscribe(() => { + this.checkApplicationViews(); + }); + }); + } + } + + private checkApplicationViews() { + this.applicationRef ||= this.injector.get(ApplicationRef); + for (const view of this.applicationRef.allViews) { + try { + checkNoChangesInternal(view._lView, this.checkNoChangesMode, view.notifyErrorHandler); + } catch (e) { + this.errorHandler ||= this.injector.get(ErrorHandler); + this.errorHandler.handleError(e); + } + } + } +} + +function exhaustiveCheckNoChangesInterval( + interval: number, + checkNoChangesMode: CheckNoChangesMode, +) { + return { + provide: ENVIRONMENT_INITIALIZER, + multi: true, + useFactory: () => { + const applicationRef = inject(ApplicationRef); + const errorHandler = inject(ErrorHandler); + const scheduler = inject(ChangeDetectionSchedulerImpl); + const ngZone = inject(NgZone); + + return () => { + function scheduleCheckNoChanges() { + ngZone.runOutsideAngular(() => { + setTimeout(() => { + if (applicationRef.destroyed) { + return; + } + if (scheduler.pendingRenderTaskId || scheduler.runningTick) { + scheduleCheckNoChanges(); + return; + } + + for (const view of applicationRef.allViews) { + try { + checkNoChangesInternal( + view._lView, checkNoChangesMode, view.notifyErrorHandler); + } catch (e) { + errorHandler.handleError(e); + } + } + + scheduleCheckNoChanges(); + }, interval); + }); + } + scheduleCheckNoChanges(); + }; + }, + }; +} 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..5707bcc8084331 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; 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..23537f256cef82 100644 --- a/packages/core/src/change_detection/scheduling/zoneless_scheduling_impl.ts +++ b/packages/core/src/change_detection/scheduling/zoneless_scheduling_impl.ts @@ -66,9 +66,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 +175,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; } diff --git a/packages/core/src/core.ts b/packages/core/src/core.ts index af451cb3a873a6..236faa4769221f 100644 --- a/packages/core/src/core.ts +++ b/packages/core/src/core.ts @@ -44,6 +44,7 @@ export { } from './change_detection/scheduling/ng_zone_scheduling'; export {provideExperimentalZonelessChangeDetection} from './change_detection/scheduling/zoneless_scheduling_impl'; export {ExperimentalPendingTasks} from './pending_tasks'; +export {provideExperimentalCheckNoChangesForDebug} from './change_detection/scheduling/exhaustive_check_no_changes'; export {enableProdMode, isDevMode} from './util/is_dev_mode'; export { APP_ID, 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..e2d89bb975bc8d 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 = 0; /* 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..e65c769e93fc29 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,11 @@ export class ViewRef implements EmbeddedViewRef, ChangeDetectorRefInterfac */ checkNoChanges(): void { if (ngDevMode) { - checkNoChangesInternal(this._lView, this.notifyErrorHandler); + checkNoChangesInternal( + this._lView, + CheckNoChangesMode.OnlyDirtyViews, + this.notifyErrorHandler, + ); } } diff --git a/packages/core/test/acceptance/change_detection_spec.ts b/packages/core/test/acceptance/change_detection_spec.ts index 6f7e394f7a6762..391d195dd01491 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,15 @@ import { ViewChild, ViewChildren, ViewContainerRef, + provideExperimentalCheckNoChangesForDebug, + provideExperimentalZonelessChangeDetection, + ɵRuntimeError as RuntimeError, + ɵRuntimeErrorCode as RuntimeErrorCode, + afterRender, + PLATFORM_ID, + provideZoneChangeDetection, } 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 +1300,225 @@ describe('change detection', () => { expect(comp.contentCheckCount).toEqual(1); expect(comp.viewCheckCount).toEqual(1); }); + + describe('provideCheckNoChangesForDebug', () => { + // 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 error if used after zoneless provider', async () => { + TestBed.configureTestingModule({ + providers: [ + {provide: PLATFORM_ID, useValue: PLATFORM_BROWSER_ID}, + provideExperimentalCheckNoChangesForDebug({useNgZoneOnStable: true}), + provideExperimentalZonelessChangeDetection(), + ], + }); + + expect(() => { + TestBed.createComponent(MyApp); + }).toThrowError(/must be after any other provider for `NgZone`/); + }); + + it('throws error if used after zone provider', async () => { + TestBed.configureTestingModule({ + providers: [ + {provide: PLATFORM_ID, useValue: PLATFORM_BROWSER_ID}, + provideExperimentalCheckNoChangesForDebug({useNgZoneOnStable: true}), + provideZoneChangeDetection(), + ], + }); + + expect(() => { + TestBed.createComponent(MyApp); + }).toThrowError(/must be after any other provider for `NgZone`/); + }); + + it('throws expression changed with useNgZoneOnStable', async () => { + let error: RuntimeError | undefined = undefined; + TestBed.configureTestingModule({ + providers: [ + {provide: PLATFORM_ID, useValue: PLATFORM_BROWSER_ID}, + provideExperimentalZonelessChangeDetection(), + provideExperimentalCheckNoChangesForDebug({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 throw expression changed with useNgZoneOnStable if there is a change detection scheduled', async () => { + let error: RuntimeError | undefined = undefined; + TestBed.configureTestingModule({ + providers: [ + provideExperimentalZonelessChangeDetection(), + provideExperimentalCheckNoChangesForDebug({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(), + provideExperimentalCheckNoChangesForDebug({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(), + provideExperimentalCheckNoChangesForDebug({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(); + }); + + it('does not throw expression changed with interval if OnPush component an no exhaustive', async () => { + let error: RuntimeError | undefined = undefined; + TestBed.configureTestingModule({ + providers: [ + provideExperimentalZonelessChangeDetection(), + provideExperimentalCheckNoChangesForDebug({interval: 0, exhaustive: false}), + { + provide: ErrorHandler, + useValue: { + handleError(e: unknown) { + error = e as RuntimeError; + }, + }, + }, + ], + }); + + const fixture = TestBed.createComponent(MyApp); + fixture.detectChanges(); + + fixture.componentInstance.state = 'new'; + // wait beyond the exhaustive check interval + await new Promise((resolve) => setTimeout(resolve, 1)); + expect(error).toBeUndefined(); + }); + }); }); }); diff --git a/packages/core/test/bundling/defer/bundle.golden_symbols.json b/packages/core/test/bundling/defer/bundle.golden_symbols.json index 19624a9ff9b4f7..e56179b963a08f 100644 --- a/packages/core/test/bundling/defer/bundle.golden_symbols.json +++ b/packages/core/test/bundling/defer/bundle.golden_symbols.json @@ -1427,6 +1427,9 @@ { "name": "init_eventcontract" }, + { + "name": "init_exhaustive_check_no_changes" + }, { "name": "init_fields" },