Skip to content

Commit 0147e0b

Browse files
atscottdylhunn
authored andcommitted
fix(core): exhaustive checkNoChanges should only do a single pass (#55839)
Because exhaustive checks traverse the whole tree regardless of the dirty state, it breaks some expectations around how change detection should be running. When a view has transplanted views, it unconditionally marks all ancestors for traversal, assuming this is fine because the loop will just traverse them and find nothing dirty. However, exhaustive checkNoChanages actually refreshes everything during traversal. This update ensures the exhaustive check only does a single pass and also prevents some unnecessary marking of transplanted views for refresh since we know they're going to be reached. PR Close #55839
1 parent 8d93597 commit 0147e0b

File tree

2 files changed

+57
-12
lines changed

2 files changed

+57
-12
lines changed

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

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,8 @@ import {
1515

1616
import {RuntimeError, RuntimeErrorCode} from '../../errors';
1717
import {assertDefined, assertEqual} from '../../util/assert';
18-
import {assertLContainer} from '../assert';
1918
import {executeCheckHooks, executeInitAndCheckHooks, incrementInitPhaseFlags} from '../hooks';
20-
import {
21-
CONTAINER_HEADER_OFFSET,
22-
LContainer,
23-
LContainerFlags,
24-
MOVED_VIEWS,
25-
} from '../interfaces/container';
19+
import {CONTAINER_HEADER_OFFSET, LContainerFlags, MOVED_VIEWS} from '../interfaces/container';
2620
import {ComponentTemplate, RenderFlags} from '../interfaces/definition';
2721
import {
2822
CONTEXT,
@@ -32,7 +26,6 @@ import {
3226
InitPhaseState,
3327
LView,
3428
LViewFlags,
35-
PARENT,
3629
REACTIVE_TEMPLATE_CONSUMER,
3730
TVIEW,
3831
TView,
@@ -119,6 +112,13 @@ function detectChangesInViewWhileDirty(lView: LView, mode: ChangeDetectionMode)
119112
setIsRefreshingViews(true);
120113
detectChangesInView(lView, mode);
121114

115+
// We don't need or want to do any looping when in exhaustive checkNoChanges because we
116+
// already traverse all the views and nothing should change so we shouldn't have to do
117+
// another pass to pick up new changes.
118+
if (ngDevMode && isExhaustiveCheckNoChanges()) {
119+
return;
120+
}
121+
122122
let retries = 0;
123123
// If after running change detection, this view still needs to be refreshed or there are
124124
// descendants views that need to be refreshed due to re-dirtying during the change detection
@@ -199,6 +199,7 @@ export function refreshView<T>(
199199
// Check no changes mode is a dev only mode used to verify that bindings have not changed
200200
// since they were assigned. We do not want to execute lifecycle hooks in that mode.
201201
const isInCheckNoChangesPass = ngDevMode && isInCheckNoChangesMode();
202+
const isInExhaustiveCheckNoChangesPass = ngDevMode && isExhaustiveCheckNoChanges();
202203

203204
!isInCheckNoChangesPass && lView[ENVIRONMENT].inlineEffectRunner?.flush();
204205

@@ -241,10 +242,14 @@ export function refreshView<T>(
241242
}
242243
}
243244

244-
// First mark transplanted views that are declared in this lView as needing a refresh at their
245-
// insertion points. This is needed to avoid the situation where the template is defined in this
246-
// `LView` but its declaration appears after the insertion component.
247-
markTransplantedViewsForRefresh(lView);
245+
// We do not need to mark transplanted views for refresh when doing exhaustive checks
246+
// because all views will be reached anyways during the traversal.
247+
if (!isInExhaustiveCheckNoChangesPass) {
248+
// First mark transplanted views that are declared in this lView as needing a refresh at their
249+
// insertion points. This is needed to avoid the situation where the template is defined in this
250+
// `LView` but its declaration appears after the insertion component.
251+
markTransplantedViewsForRefresh(lView);
252+
}
248253
detectChangesInEmbeddedViews(lView, ChangeDetectionMode.Global);
249254

250255
// Content query results must be refreshed before content hooks are called.

packages/core/test/acceptance/change_detection_transplanted_view_spec.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
ViewChild,
2929
ViewContainerRef,
3030
} from '@angular/core';
31+
import {provideExperimentalCheckNoChangesForDebug} from '@angular/core/src/change_detection/scheduling/exhaustive_check_no_changes';
3132
import {ComponentFixture, TestBed} from '@angular/core/testing';
3233
import {expect} from '@angular/platform-browser/testing/src/matchers';
3334
import {of} from 'rxjs';
@@ -1105,6 +1106,45 @@ describe('change detection for transplanted views', () => {
11051106
// checking the declaration.
11061107
expect(() => appRef.tick()).not.toThrow();
11071108
});
1109+
it('does not cause infinite loops with exhaustive checkNoChanges', async () => {
1110+
TestBed.configureTestingModule({
1111+
providers: [provideExperimentalCheckNoChangesForDebug({interval: 1})],
1112+
});
1113+
const errorSpy = spyOn(console, 'error').and.callFake((...v) => {
1114+
fail('console errored with ' + v);
1115+
});
1116+
@Component({
1117+
standalone: true,
1118+
selector: 'insertion',
1119+
template: `<ng-container #vc></ng-container>`,
1120+
changeDetection: ChangeDetectionStrategy.OnPush,
1121+
})
1122+
class Insertion {
1123+
@ViewChild('vc', {read: ViewContainerRef, static: true}) viewContainer!: ViewContainerRef;
1124+
@Input() template!: TemplateRef<{}>;
1125+
ngOnChanges() {
1126+
return this.viewContainer.createEmbeddedView(this.template);
1127+
}
1128+
}
1129+
1130+
@Component({
1131+
standalone: true,
1132+
template: `
1133+
<ng-template #template>hello world</ng-template>
1134+
<insertion [template]="transplantedTemplate"></insertion>
1135+
`,
1136+
imports: [Insertion],
1137+
})
1138+
class Root {
1139+
@ViewChild('template', {static: true}) transplantedTemplate!: TemplateRef<{}>;
1140+
}
1141+
1142+
const fixture = TestBed.createComponent(Root);
1143+
TestBed.inject(ApplicationRef).attachView(fixture.componentRef.hostView);
1144+
// wait the 1 tick for exhaustive check to trigger
1145+
await new Promise((r) => setTimeout(r, 1));
1146+
expect(errorSpy).not.toHaveBeenCalled();
1147+
});
11081148
});
11091149

11101150
function trim(text: string | null): string {

0 commit comments

Comments
 (0)