Skip to content

Commit 76152a5

Browse files
atscottdylhunn
authored andcommitted
fix(core): Ensure backwards-referenced transplanted views are refreshed (angular#51854)
This commit runs change detection in a loop while there are still dirty views to be refreshed in the tree. At the moment, this only applies to transplanted views but will also apply to views with changed signals. fixes angular#49801 PR Close angular#51854
1 parent c993e9a commit 76152a5

File tree

15 files changed

+87
-28
lines changed

15 files changed

+87
-28
lines changed

goldens/public-api/core/errors.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ export const enum RuntimeErrorCode {
5959
// (undocumented)
6060
IMPORT_PROVIDERS_FROM_STANDALONE = 800,
6161
// (undocumented)
62+
INFINITE_CHANGE_DETECTION = 103,
63+
// (undocumented)
6264
INJECTOR_ALREADY_DESTROYED = 205,
6365
// (undocumented)
6466
INVALID_DIFFER_INPUT = 900,

packages/core/src/errors.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export const enum RuntimeErrorCode {
3131
EXPRESSION_CHANGED_AFTER_CHECKED = -100,
3232
RECURSIVE_APPLICATION_REF_TICK = 101,
3333
RECURSIVE_APPLICATION_RENDER = 102,
34+
INFINITE_CHANGE_DETECTION = 103,
3435

3536
// Dependency Injection Errors
3637
CYCLIC_DI_DEPENDENCY = -200,

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

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9+
import {RuntimeError, RuntimeErrorCode} from '../../errors';
910
import {assertDefined, assertEqual} from '../../util/assert';
1011
import {assertLContainer} from '../assert';
1112
import {getComponentViewByInstance} from '../context_discovery';
@@ -19,6 +20,11 @@ import {getComponentLViewByIndex, isCreationMode, markAncestorsForTraversal, mar
1920

2021
import {executeTemplate, executeViewQueryFn, handleError, processHostBindingOpCodes, refreshContentQueries} from './shared';
2122

23+
/**
24+
* The maximum number of times the change detection traversal will rerun before throwing an error.
25+
*/
26+
const MAXIMUM_REFRESH_RERUNS = 100;
27+
2228
export function detectChangesInternal<T>(
2329
tView: TView, lView: LView, context: T, notifyErrorHandler = true) {
2430
const environment = lView[ENVIRONMENT];
@@ -37,6 +43,25 @@ export function detectChangesInternal<T>(
3743

3844
try {
3945
refreshView(tView, lView, tView.template, context);
46+
let retries = 0;
47+
// If after running change detection, this view still needs to be refreshed or there are
48+
// descendants views that need to be refreshed due to re-dirtying during the change detection
49+
// run, detect changes on the view again. We run change detection in `Targeted` mode to only
50+
// refresh views with the `RefreshView` flag.
51+
while (lView[FLAGS] & (LViewFlags.RefreshView | LViewFlags.HasChildViewsToRefresh)) {
52+
if (retries === MAXIMUM_REFRESH_RERUNS) {
53+
throw new RuntimeError(
54+
RuntimeErrorCode.INFINITE_CHANGE_DETECTION,
55+
ngDevMode &&
56+
'Infinite change detection while trying to refresh views. ' +
57+
'There may be components which each cause the other to require a refresh, ' +
58+
'causing an infinite loop.');
59+
}
60+
retries++;
61+
// Even if this view is detached, we still detect changes in targeted mode because this was
62+
// the root of the change detection run.
63+
detectChangesInView(lView, ChangeDetectionMode.Targeted);
64+
}
4065
} catch (error) {
4166
if (notifyErrorHandler) {
4267
handleError(lView, error);
@@ -228,7 +253,6 @@ export function refreshView<T>(
228253
if (!isInCheckNoChangesPass) {
229254
lView[FLAGS] &= ~(LViewFlags.Dirty | LViewFlags.FirstLViewPass);
230255
}
231-
lView[FLAGS] &= ~LViewFlags.RefreshView;
232256
} catch (e) {
233257
// If refreshing a view causes an error, we need to remark the ancestors as needing traversal
234258
// because the error might have caused a situation where views below the current location are
@@ -319,7 +343,7 @@ function detectChangesInView(lView: LView, mode: ChangeDetectionMode) {
319343

320344
// Flag cleared before change detection runs so that the view can be re-marked for traversal if
321345
// necessary.
322-
lView[FLAGS] &= ~LViewFlags.HasChildViewsToRefresh;
346+
lView[FLAGS] &= ~(LViewFlags.HasChildViewsToRefresh | LViewFlags.RefreshView);
323347

324348
if ((flags & (LViewFlags.CheckAlways | LViewFlags.Dirty) &&
325349
mode === ChangeDetectionMode.Global) ||

packages/core/test/acceptance/change_detection_transplanted_view_spec.ts

Lines changed: 22 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -410,35 +410,25 @@ describe('change detection for transplanted views', () => {
410410
expect(appComponent.declaration.transplantedViewRefreshCount).toEqual(1);
411411
});
412412

413-
it('updates only the declaration view when there is a change to declaration and declaration is marked dirty',
414-
() => {
415-
appComponent.declaration.name = 'new name';
416-
appComponent.declaration.changeDetectorRef.markForCheck();
417-
fixture.detectChanges(false);
418-
419-
const expectedContent =
420-
'Insertion(initial)TemplateDeclaration(initial)TemplateContext(initial)Declaration(new name)';
421-
expect(fixture.nativeElement.textContent).toEqual(expectedContent);
422-
expect(appComponent.declaration.transplantedViewRefreshCount).toEqual(0);
413+
it('updates the declaration view when there is a change to either declaration or insertion', () => {
414+
appComponent.declaration.name = 'new name';
415+
appComponent.declaration.changeDetectorRef.markForCheck();
416+
fixture.detectChanges(false);
423417

424-
// Note here that this second change detection should not be necessary, but is because of
425-
// the backwards reference not being fully supported. The assertions below should be true
426-
// after the first CD.
427-
fixture.detectChanges(false);
428-
expect(fixture.nativeElement.textContent)
429-
.toEqual(
430-
'Insertion(initial)TemplateDeclaration(new name)TemplateContext(initial)Declaration(new name)');
431-
expect(appComponent.declaration.transplantedViewRefreshCount).toEqual(1);
432-
});
418+
const expectedContent =
419+
'Insertion(initial)TemplateDeclaration(new name)TemplateContext(initial)Declaration(new name)';
420+
expect(fixture.nativeElement.textContent).toEqual(expectedContent);
421+
expect(appComponent.declaration.transplantedViewRefreshCount).toEqual(1);
422+
});
433423

434-
it('should not update when there is a change to insertion and declaration is marked dirty', () => {
424+
it('should update when there is a change to insertion and declaration is marked dirty', () => {
435425
appComponent.insertion.name = 'new name';
436426
appComponent.declaration.changeDetectorRef.markForCheck();
437427
fixture.detectChanges(false);
438428
expect(fixture.nativeElement.textContent)
439429
.toEqual(
440430
'Insertion(initial)TemplateDeclaration(initial)TemplateContext(initial)Declaration(initial)');
441-
expect(appComponent.declaration.transplantedViewRefreshCount).toEqual(0);
431+
expect(appComponent.declaration.transplantedViewRefreshCount).toEqual(1);
442432
});
443433

444434
it('should update insertion view and template when there is a change to insertion and insertion marked dirty',
@@ -462,8 +452,9 @@ describe('change detection for transplanted views', () => {
462452
appComponent.insertion.changeDetectorRef.markForCheck();
463453
fixture.detectChanges(false);
464454
expect(appComponent.declaration.transplantedViewRefreshCount)
465-
.withContext('Should refresh once because backwards references are not rechecked')
466-
.toEqual(1);
455+
.withContext(
456+
'Should refresh twice because insertion executes and then declaration marks transplanted view dirty again')
457+
.toEqual(2);
467458
});
468459
});
469460

@@ -653,7 +644,8 @@ describe('change detection for transplanted views', () => {
653644
// Detach view, manually call `detectChanges`, and verify the template was refreshed
654645
component.rootViewContainerRef.detach();
655646
viewRef.detectChanges();
656-
expect(component.templateExecutions).toEqual(1);
647+
// This view is a backwards reference so it's refreshed twice
648+
expect(component.templateExecutions).toEqual(2);
657649
});
658650

659651
it('should work when change detecting detached transplanted view already marked for refresh',
@@ -669,7 +661,8 @@ describe('change detection for transplanted views', () => {
669661
// should not affect parent counters.
670662
viewRef.detectChanges();
671663
}).not.toThrow();
672-
expect(component.templateExecutions).toEqual(1);
664+
// This view is a backwards reference so it's refreshed twice
665+
expect(component.templateExecutions).toEqual(2);
673666
});
674667

675668
it('should work when re-inserting a previously detached transplanted view marked for refresh',
@@ -689,7 +682,10 @@ describe('change detection for transplanted views', () => {
689682
// the counter, this would fail when attempted to decrement.
690683
fixture.detectChanges(false);
691684
}).not.toThrow();
692-
expect(component.templateExecutions).toBeGreaterThan(0);
685+
// The transplanted view gets refreshed twice because it's actually inserted "backwards"
686+
// The view is defined in AppComponent but inserted in its ViewContainerRef (as an
687+
// embedded view in AppComponent's host view).
688+
expect(component.templateExecutions).toEqual(2);
693689
});
694690

695691
it('should work when detaching an attached transplanted view with the refresh flag', () => {

packages/core/test/bundling/animations-standalone/bundle.golden_symbols.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -818,6 +818,9 @@
818818
{
819819
"name": "detectChangesInEmbeddedViews"
820820
},
821+
{
822+
"name": "detectChangesInView"
823+
},
821824
{
822825
"name": "detectChangesInViewIfAttached"
823826
},

packages/core/test/bundling/animations/bundle.golden_symbols.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -884,6 +884,9 @@
884884
{
885885
"name": "detectChangesInEmbeddedViews"
886886
},
887+
{
888+
"name": "detectChangesInView"
889+
},
887890
{
888891
"name": "detectChangesInViewIfAttached"
889892
},

packages/core/test/bundling/cyclic_import/bundle.golden_symbols.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -668,6 +668,9 @@
668668
{
669669
"name": "detectChangesInEmbeddedViews"
670670
},
671+
{
672+
"name": "detectChangesInView"
673+
},
671674
{
672675
"name": "detectChangesInViewIfAttached"
673676
},

packages/core/test/bundling/defer/bundle.golden_symbols.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,9 @@
242242
{
243243
"name": "MATH_ML_NAMESPACE"
244244
},
245+
{
246+
"name": "MAXIMUM_REFRESH_RERUNS"
247+
},
245248
{
246249
"name": "MINIMUM_SLOT"
247250
},
@@ -752,6 +755,9 @@
752755
{
753756
"name": "detectChangesInEmbeddedViews"
754757
},
758+
{
759+
"name": "detectChangesInView"
760+
},
755761
{
756762
"name": "detectChangesInViewIfAttached"
757763
},

packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -911,6 +911,9 @@
911911
{
912912
"name": "detectChangesInEmbeddedViews"
913913
},
914+
{
915+
"name": "detectChangesInView"
916+
},
914917
{
915918
"name": "detectChangesInViewIfAttached"
916919
},

packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -881,6 +881,9 @@
881881
{
882882
"name": "detectChangesInEmbeddedViews"
883883
},
884+
{
885+
"name": "detectChangesInView"
886+
},
884887
{
885888
"name": "detectChangesInViewIfAttached"
886889
},

0 commit comments

Comments
 (0)