Skip to content

Commit

Permalink
refactor(core): feature for potential zoneless-compatibility debug check
Browse files Browse the repository at this point in the history
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 angular#45612.

Because this is an experimental, debug-only feature, it is okay to merge
during the RC period.
  • Loading branch information
atscott committed May 7, 2024
1 parent a0ec2d8 commit d16bff2
Show file tree
Hide file tree
Showing 12 changed files with 493 additions and 40 deletions.
54 changes: 32 additions & 22 deletions adev/src/content/guide/zoneless.md
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
8 changes: 8 additions & 0 deletions goldens/public-api/core/index.md
Expand Up @@ -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';
Expand Down Expand Up @@ -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;

Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/application/application_ref.ts
Expand Up @@ -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];
}

Expand Down
@@ -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();
};
},
};
}
Expand Up @@ -26,14 +26,19 @@ 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'})
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;

Expand Down
Expand Up @@ -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(
Expand Down Expand Up @@ -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;
}

Expand Down
1 change: 1 addition & 0 deletions packages/core/src/core.ts
Expand Up @@ -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,
Expand Down
27 changes: 18 additions & 9 deletions packages/core/src/render3/instructions/change_detection.ts
Expand Up @@ -44,7 +44,9 @@ import {
ReactiveLViewConsumer,
} from '../reactive_lview_consumer';
import {
CheckNoChangesMode,
enterView,
isExhaustiveCheckNoChanges,
isInCheckNoChangesMode,
isRefreshingViews,
leaveView,
Expand Down Expand Up @@ -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);
}
}

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down

0 comments on commit d16bff2

Please sign in to comment.