8000 refactor(core): feature for potential zoneless-compatibility debug ch… · angular/angular@6b97aec · GitHub
[go: up one dir, main page]

Skip to content

Commit 6b97aec

Browse files
atscottAndrewKushnir
authored andcommitted
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
1 parent 9eb7478 commit 6b97aec

File tree

12 files changed

+487
-39
lines changed

12 files changed

+487
-39
lines changed

adev/src/content/guide/zoneless.md

Lines changed: 32 additions & 22 deletions
57AE
Original file line numberDiff line numberDiff line change
@@ -28,28 +28,6 @@ platformBrowser().bootstrapModule(AppModule, {ngZone: 'noop'});
2828
export class AppModule {}
2929
```
3030

31-
## Testing
32-
33-
The zoneless provider function can also be used with `TestBed` to help
34-
ensure the components under test are compatible with a Zoneless
35-
Angular application.
36-
37-
```typescript
38-
TestBed.configureTestingModule({
39-
providers: [provideExperimentalZonelessChangeDetection()]
40-
});
41-
42-
const fixture = TestBed.createComponent(MyComponent);
43-
await fixture.whenStable();
44-
```
45-
46-
To ensure tests have the most similar behavior to production code,
47-
avoid using `fixture.detectChanges()` when possibe. This forces
48-
change detection to run when Angular might otherwise have not
49-
scheduled change detection. Tests should ensure these notifications
50-
are happening and allow Angular to handle when to synchronize
51-
state rather than manually forcing it to happen in the test.
52-
5331
## Requirements for Zoneless compatibility
5432

5533
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();
10684
The framework uses this service internally as well to prevent serialization until asynchronous tasks are complete. These include, but are not limited to,
10785
an ongoing Router navigation and an incomplete `HttpClient` request.
10886

87+
## Testing and Debugging
88+
89+
### Using Zoneless in `TestBed`
90+
91+
The zoneless provider function can also be used with `TestBed` to help
92+
ensure the components under test are compatible with a Zoneless
93+
Angular application.
94+
95+
```typescript
96+
TestBed.configureTestingModule({
97+
providers: [provideExperimentalZonelessChangeDetection()]
98+
});
99+
100+
const fixture = TestBed.createComponent(MyComponent);
101+
await fixture.whenStable();
102+
```
103+
104+
To ensure t F438 ests have the most similar behavior to production code,
105+
avoid using `fixture.detectChanges()` when possibe. This forces
106+
change detection to run when Angular might otherwise have not
107+
scheduled change detection. Tests should ensure these notifications
108+
are happening and allow Angular to handle when to synchronize
109+
state rather than manually forcing it to happen in the test.
110+
111+
### Debug-mode check to ensure updates are detected
112+
113+
Angular also provides an additional tool to help verify that an application is making
114+
updates to state in a zoneless-compatible way. `provideExperimentalCheckNoChangesForDebug`
115+
can be used to periodically check to ensure that no bindings have been updated
116+
without a notification. Angular will throw `ExpressionChangedAfterItHasBeenCheckedError`
117+
if there is an updated binding that would not have refreshed by the zoneless change
118+
detection.

goldens/public-api/core/index.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
55
```ts
66

7+
import { EnvironmentProviders as EnvironmentProviders_2 } from '@angular/core';
78
import { Observable } from 'rxjs';
89
import { SIGNAL } from '@angular/core/primitives/signals';
910
import { SignalNode } from '@angular/core/primitives/signals';
@@ -1365,6 +1366,13 @@ export class PlatformRef {
13651366
// @public
13661367
export type Predicate<T> = (value: T) => boolean;
13671368

1369+
// @public
1370+
export function provideExperimentalCheckNoChangesForDebug(options: {
1371+
interval?: number;
1372+
useNgZoneOnStable?: boolean;
1373+
exhaustive?: boolean;
1374+
}): EnvironmentProviders_2;
1375+
13681376
// @public
13691377
export function provideExperimentalZonelessChangeDetection(): EnvironmentProviders;
13701378

packages/core/src/application/application_ref.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -312,7 +312,8 @@ export class ApplicationRef {
312312
private beforeRender = new Subject<boolean>();
313313
/** @internal */
314314
afterTick = new Subject<void>();
315-
private get allViews() {
315+
/** @internal */
316+
get allViews() {
316317
return [...this.externalTestViews.keys(), ...this._views];
317318
}
318319

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import {ApplicationRef} from '../../application/application_ref';
10+
import {ChangeDetectionSchedulerImpl} from './zoneless_scheduling_impl';
11+
import {inject} from '../../di/injector_compatibility';
12+
import {makeEnvironmentProviders} from '../../di/provider_collection';
13+
import {NgZone} from '../../zone/ng_zone';
14+
15+
import {EnvironmentInjector} from '../../di/r3_injector';
16+
import {ENVIRONMENT_INITIALIZER} from '../../di/initializer_token';
17+
import {CheckNoChangesMode} from '../../render3/state';
18+
import {ErrorHandler} from '../../error_handler';
19+
import {checkNoChangesInternal} from '../../render3/instructions/change_detection';
20+
import {ZONELESS_ENABLED} from './zoneless_scheduling';
21+
22+
/**
23+
* Used to periodically verify no expressions have changed after they were checked.
24+
*
25+
* @param options Used to configure when the check will execute.
26+
* - `interval` will periodically run exhaustive `checkNoChanges` on application views
27+
* - `useNgZoneOnStable` will us ZoneJS to determine when change detection might have run
28+
* in an application using ZoneJS to drive change detection. When the `NgZone.onStable` would
29+
* have emit, all views attached to the `ApplicationRef` are checked for changes.
30+
* - 'exhaustive' means that all views attached to `ApplicationRef` and all the descendants of those views will be
31+
* checked for changes (excluding those subtrees which are detached via `ChangeDetectorRef.detach()`).
32+
* This is useful because the check that runs after regular change detection does not work for components using `ChangeDetectionStrategy.OnPush`.
33+
* This check is will surface any existing errors hidden by `OnPush` components. By default, this check is exhaustive
34+
* and will always check all views, regardless of their "dirty" state and `ChangeDetectionStrategy`.
35+
*
36+
* When the `useNgZoneOnStable` option is `true`, this function will provide its own `NgZone` implementation and needs
37+
* to come after any other `NgZone` provider, including `provideZoneChangeDetection()` and `provideExperimentalZonelessChangeDetection()`.
38+
*
39+
* @experimental
40+
* @publicApi
41+
*/
42+
export function provideExperimentalCheckNoChangesForDebug(options: {
43+
interval?: number;
44+
useNgZoneOnStable?: boolean;
45+
exhaustive?: boolean;
46+
}) {
47+
if (typeof ngDevMode === 'undefined' || ngDevMode) {
48+
if (options.interval === undefined && !options.useNgZoneOnStable) {
49+
throw new Error('Must provide one of `useNgZoneOnStable` or `interval`');
50+
}
51+
const checkNoChangesMode =
52+
options?.exhaustive === false
53+
? CheckNoChangesMode.OnlyDirtyViews
54+
: CheckNoChangesMode.Exhaustive;
55+
return makeEnvironmentProviders([
56+
options?.useNgZoneOnStable
57+
? {provide: NgZone, useFactory: () => new DebugNgZoneForCheckNoChanges(checkNoChangesMode)}
58+
: [],
59+
options?.interval !== undefined
60+
? exhaustiveCheckNoChangesInterval(options.interval, checkNoChangesMode)
61+
: [],
62+
{
63+
provide: ENVIRONMENT_INITIALIZER,
64+
multi: true,
65+
useValue: () => {
66+
if (
67+
options?.useNgZoneOnStable &&
68+
!(inject(NgZone) instanceof DebugNgZoneForCheckNoChanges)
69+
) {
70+
throw new Error(
71+
'`provideCheckNoChangesForDebug` with `useNgZoneOnStable` must be after any other provider for `NgZone`.',
72+
);
73+
}
74+
},
75+
},
76+
]);
77+
} else {
78+
return makeEnvironmentProviders([]);
79+
}
80+
}
81+
82+
export class DebugNgZoneForCheckNoChanges extends NgZone {
83+
private applicationRef?: ApplicationRef;
84+
private scheduler?: ChangeDetectionSchedulerImpl;
85+
private errorHandler?: ErrorHandler;
86+
private readonly injector = inject(EnvironmentInjector);
87+
88+
constructor(private readonly checkNoChangesMode: CheckNoChangesMode) {
89+
const zonelessEnabled = inject(ZONELESS_ENABLED);
90+
// Use coalsecing to ensure we aren't ever running this check synchronously
91+
super({
92+
shouldCoalesceEventChangeDetection: true,
93+
shouldCoalesceRunChangeDetection: zonelessEnabled,
94+
});
95+
96+
if (zonelessEnabled) {
97+
// prevent emits to ensure code doesn't rely on these
98+
this.onMicrotaskEmpty.emit = () => {};
99+
this.onStable.emit = () => {
100+
this.scheduler ||= this.injector.get(ChangeDetectionSchedulerImpl);
101+
if (this.scheduler.pendingRenderTaskId || this.scheduler.runningTick) {
102+
return;
103+
}
104+
this.checkApplicationViews();
105+
};
106+
this.onUnstable.emit = () => {};
107+
} else {
108+
this.runOutsideAngular(() => {
109+
this.onStable.subscribe(() => {
110+
this.checkApplicationViews();
111+
});
112+
});
113+
}
114+
}
115+
116+
private checkApplicationViews() {
117+
this.applicationRef ||= this.injector.get(ApplicationRef);
118+
for (const view of this.applicationRef.allViews) {
119+
try {
120+
checkNoChangesInternal(view._lView, this.checkNoChangesMode, view.notifyErrorHandler);
121+
} catch (e) {
122+
this.errorHandler ||= this.injector.get(ErrorHandler);
123+
this.errorHandler.handleError(e);
124+
}
125+
}
126+
}
127+
}
128+
129+
function exhaustiveCheckNoChangesInterval(
130+
interval: number,
131+
checkNoChangesMode: CheckNoChangesMode,
132+
) {
133+
return {
134+
provide: ENVIRONMENT_INITIALIZER,
135+
multi: true,
136+
useFactory: () => {
137+
const applicationRef = inject(ApplicationRef);
138+
const errorHandler = inject(ErrorHandler);
139+
const scheduler = inject(ChangeDetectionSchedulerImpl);
140+
const ngZone = inject(NgZone);
141+
142+
return () => {
143+
function scheduleCheckNoChanges() {
144+
ngZone.runOutsideAngular(() => {
145+
setTimeout(() => {
146+
if (applicationRef.destroyed) {
147+
return;
148+
}
149+
if (scheduler.pendingRenderTaskId || scheduler.runningTick) {
150+
scheduleCheckNoChanges();
151+
return;
152+
}
153+
154+
for (const view of applicationRef.allViews) {
155+
try {
156+
checkNoChangesInternal(view._lView, checkNoChangesMode, view.notifyErrorHandler);
157+
} catch (e) {
158+
errorHandler.handleError(e);
159+
}
160+
}
161+
162+
scheduleCheckNoChanges();
163+
}, interval);
164+
});
165+
}
166+
scheduleCheckNoChanges();
167+
};
168+
},
169+
};
170+
}

packages/core/src/change_detection/scheduling/ng_zone_scheduling.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ export class NgZoneChangeDetectionScheduler {
3838
private readonly zone = inject(NgZone);
3939
private readonly changeDetectionScheduler = inject(ChangeDetectionScheduler, {optional: true});
4040
private readonly applicationRef = inject(ApplicationRef);
41+
private readonly zonelessEnabled = inject(ZONELESS_ENABLED);
4142

4243
private _onMicrotaskEmptySubscription?: Subscription;
4344

packages/core/src/change_detection/scheduling/zoneless_scheduling_impl.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,9 +66,9 @@ export class ChangeDetectionSchedulerImpl implements ChangeDetectionScheduler {
6666

6767
private cancelScheduledCallback: null | (() => void) = null;
6868
private shouldRefreshViews = false;
69-
private pendingRenderTaskId: number | null = null;
7069
private useMicrotaskScheduler = false;
7170
runningTick = false;
71+
pendingRenderTaskId: number | null = null;
7272

7373
constructor() {
7474
this.subscriptions.add(
@@ -175,7 +175,7 @@ export class ChangeDetectionSchedulerImpl implements ChangeDetectionScheduler {
175175
}
176176
// If we're inside the zone don't bother with scheduler. Zone will stabilize
177177
// eventually and run change detection.
178-
if (this.zoneIsDefined && NgZone.isInAngularZone()) {
178+
if (!this.zonelessEnabled && this.zoneIsDefined && NgZone.isInAngularZone()) {
179179
return false;
180180
}
181181

packages/core/src/core.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ export {
4444
} from './change_detection/scheduling/ng_zone_scheduling';
4545
export {provideExperimentalZonelessChangeDetection} from './change_detection/scheduling/zoneless_scheduling_impl';
4646
export {ExperimentalPendingTasks} from './pending_tasks';
47+
export {provideExperimentalCheckNoChangesForDebug} from './change_detection/scheduling/exhaustive_check_no_changes';
4748
export {enableProdMode, isDevMode} from './util/is_dev_mode';
4849
export {
4950
APP_ID,

packages/core/src/render3/instructions/change_detection.ts

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,9 @@ import {
4444
ReactiveLViewConsumer,
4545
} from '../reactive_lview_consumer';
4646
import {
47+
CheckNoChangesMode,
4748
enterView,
49+
isExhaustiveCheckNoChanges,
4850
isInCheckNoChangesMode,
4951
isRefreshingViews,
5052
leaveView,
@@ -143,12 +145,16 @@ function detectChangesInViewWhileDirty(lView: LView, mode: ChangeDetectionMode)
143145
}
144146
}
145147

146-
export function checkNoChangesInternal(lView: LView, notifyErrorHandler = true) {
147-
setIsInCheckNoChangesMode(true);
148+
export function checkNoChangesInternal(
149+
lView: LView,
150+
mode: CheckNoChangesMode,
151+
notifyErrorHandler = true,
152+
) {
153+
setIsInCheckNoChangesMode(mode);
148154
try {
149155
detectChangesInternal(lView, notifyErrorHandler);
150156
} finally {
151-
setIsInCheckNoChangesMode(false);
157+
setIsInCheckNoChangesMode(CheckNoChangesMode.Off);
152158
}
153159
}
154160

@@ -329,12 +335,13 @@ export function refreshView<T>(
329335
lView[FLAGS] &= ~(LViewFlags.Dirty | LViewFlags.FirstLViewPass);
330336
}
331337
} catch (e) {
332-
// If refreshing a view causes an error, we need to remark the ancestors as needing traversal
333-
// because the error might have caused a situation where views below the current location are
334-
// dirty but will be unreachable because the "has dirty children" flag in the ancestors has been
335-
// cleared during change detection and we failed to run to completion.
336-
337-
markAncestorsForTraversal(lView);
338+
if (!isInCheckNoChangesPass) {
339+
// If refreshing a view causes an error, we need to remark the ancestors as needing traversal
340+
// because the error might have caused a situation where views below the current location are
341+
// dirty but will be unreachable because the "has dirty children" flag in the ancestors has been
342+
// cleared during change detection and we failed to run to completion.
343+
markAncestorsForTraversal(lView);
344+
}
338345
throw e;
339346
} finally {
340347
if (currentConsumer !== null) {
@@ -469,6 +476,8 @@ function detectChangesInView(lView: LView, mode: ChangeDetectionMode) {
469476
// Refresh views when they have a dirty reactive consumer, regardless of mode.
470477
shouldRefreshView ||= !!(consumer?.dirty && consumerPollProducersForChange(consumer));
471478

479+
shouldRefreshView ||= !!(ngDevMode && isExhaustiveCheckNoChanges());
480+
472481
// Mark the Flags and `ReactiveNode` as not dirty before refreshing the component, so that they
473482
// can be re-dirtied during the refresh process.
474483
if (consumer) {

0 commit comments

Comments
 (0)
0