refactor(core): feature for potential zoneless-compatibility debug check (#55663)

This commit adds a feature that is useful for determining if an
application is zoneless-ready. The way this works is generally only
useful right now when zoneless is enabled. Some version of this may be useful in
the future as a general configuration option to change detection to make
`checkNoChanges` pass always exhaustive as an opt-in to address #45612.

Because this is an experimental, debug-only feature, it is okay to merge
during the RC period.

PR Close #55663
This commit is contained in:
Andrew Scott 2024-05-02 07:39:44 -07:00 committed by Andrew Kushnir
parent abbaf8f639
commit ca6cdcd269
12 changed files with 487 additions and 39 deletions

View file

@ -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.
@ -106,3 +84,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.

View file

@ -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<T> = (value: T) => boolean;
// @public
export function provideExperimentalCheckNoChangesForDebug(options: {
interval?: number;
useNgZoneOnStable?: boolean;
exhaustive?: boolean;
}): EnvironmentProviders_2;
// @public
export function provideExperimentalZonelessChangeDetection(): EnvironmentProviders;

View file

@ -312,7 +312,8 @@ export class ApplicationRef {
private beforeRender = new Subject<boolean>();
/** @internal */
afterTick = new Subject<void>();
private get allViews() {
/** @internal */
get allViews() {
return [...this.externalTestViews.keys(), ...this._views];
}

View file

@ -0,0 +1,170 @@
/**
* @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();
};
},
};
}

View file

@ -38,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;

View file

@ -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;
}

View file

@ -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,

View file

@ -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<T>(
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) {

View file

@ -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 setIsInCheckNoChangesMode(mode: boolean): void {
export function isExhaustiveCheckNoChanges(): boolean {
!ngDevMode && throwError('Must never be called in production mode');
_isInCheckNoChangesMode = mode;
return _checkNoChangesMode === CheckNoChangesMode.Exhaustive;
}
export function setIsInCheckNoChangesMode(mode: CheckNoChangesMode): void {
!ngDevMode && throwError('Must never be called in production mode');
_checkNoChangesMode = mode;
}
export function isRefreshingViews(): boolean {

View file

@ -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<T> implements EmbeddedViewRef<T>, ChangeDetectorRefInterfac
*/
checkNoChanges(): void {
if (ngDevMode) {
checkNoChangesInternal(this._lView, this.notifyErrorHandler);
checkNoChangesInternal(
this._lView,
CheckNoChangesMode.OnlyDirtyViews,
this.notifyErrorHandler,
);
}
}

View file

@ -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<void>;
private resolve?: Function;
changeDetectorRef = inject(ChangeDetectorRef);
createReadPromise() {
this.promise = new Promise<void>((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<void>((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<void>((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<void>((resolve) => setTimeout(resolve, 1));
expect(error).toBeUndefined();
});
});
});
});

View file

@ -1430,6 +1430,9 @@
{
"name": "init_eventcontract"
},
{
"name": "init_exhaustive_check_no_changes"
},
{
"name": "init_fields"
},