Skip to content

Commit 898a532

Browse files
atscottthePunderWoman
authored andcommitted
fix(core): Fix possible infinite loop with markForCheck by partially reverting angular#54074 (angular#54329)
In some situations, calling `markForCheck` can result in an infinite loop in seemingly valid scenarios. When a transplanted view is inserted before its declaration, it gets refreshed in the retry loop of `detectChanges`. At this point, the `Dirty` flag has been cleared from all parents. Calling `markForCheck` marks the insertion tree up to the root `Dirty`. If the declaration is checked again as a result (i.e. because it has default change detection) and is reachable because its parent was marked `Dirty`, this can cause an infinite loop. The declaration is refreshed again, so the insertion is marked for refresh (again). We enter an infinite loop if the insertion tree always calls `markForCheck` for some reason (i.e. `{{createReplayObservable() | async}}`). While the case above does fall into an infinite loop, it also truly is a problem in the application. While it's not an infinite synchronous loop, the declaration and insertion are infinitely dirty and will be refreshed on every change detection round. Usually `markForCheck` does not have this problem because the `Dirty` flag is not cleared until the very end of change detection. However, if the view did not already have the `Dirty` flag set, it is never cleared because we never entered view refresh. One solution to this problem could be to clear the `Dirty` flag even after skipping view refresh but traversing to children. PR Close angular#54329
1 parent adfc3f0 commit 898a532

3 files changed

Lines changed: 65 additions & 5 deletions

File tree

packages/core/src/application/application_ref.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -702,5 +702,8 @@ export function whenStable(applicationRef: ApplicationRef): Promise<void> {
702702

703703

704704
function shouldRecheckView(view: LView): boolean {
705-
return requiresRefreshOrTraversal(view) || !!(view[FLAGS] & LViewFlags.Dirty);
705+
return requiresRefreshOrTraversal(view);
706+
// TODO(atscott): We need to support rechecking views marked dirty again in afterRender hooks
707+
// in order to support the transition to zoneless. b/308152025
708+
/* || !!(view[FLAGS] & LViewFlags.Dirty); */
706709
}

packages/core/test/acceptance/after_render_hook_spec.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -946,7 +946,8 @@ describe('after render hooks', () => {
946946
const appRef = TestBed.inject(ApplicationRef);
947947
appRef.attachView(fixture.componentRef.hostView);
948948
appRef.tick();
949-
expect(fixture.nativeElement.innerText).toBe('1');
949+
// TODO(atscott): We need to support this for zoneless to succeed
950+
expect(fixture.nativeElement.innerText).toBe('0');
950951
});
951952

952953
it('throws error when causing infinite updates', () => {

packages/core/test/acceptance/change_detection_transplanted_view_spec.ts

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

9-
import {CommonModule} from '@angular/common';
10-
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, computed, Directive, DoCheck, inject, Input, signal, TemplateRef, Type, ViewChild, ViewContainerRef} from '@angular/core';
11-
import {AfterViewChecked, EmbeddedViewRef} from '@angular/core/src/core';
9+
import {CommonModule, NgTemplateOutlet} from '@angular/common';
10+
import {AfterViewChecked, ApplicationRef, ChangeDetectionStrategy, ChangeDetectorRef, Component, computed, createComponent, Directive, DoCheck, EmbeddedViewRef, EnvironmentInjector, ErrorHandler, inject, Input, signal, TemplateRef, Type, ViewChild, ViewContainerRef} from '@angular/core';
1211
import {ComponentFixture, TestBed} from '@angular/core/testing';
1312
import {expect} from '@angular/platform-browser/testing/src/matchers';
1413

@@ -1013,6 +1012,63 @@ describe('change detection for transplanted views', () => {
10131012
expect(fixture.nativeElement.innerText).toEqual('new');
10141013
});
10151014
});
1015+
1016+
it('can call markForCheck inside insertion tree when inserted as backwards reference', () => {
1017+
@Component({selector: 'markForCheck', template: '', standalone: true})
1018+
class MarkForCheck {
1019+
cdr = inject(ChangeDetectorRef);
1020+
ngDoCheck() {
1021+
this.cdr.markForCheck();
1022+
}
1023+
}
1024+
1025+
@Component({
1026+
selector: 'insertion',
1027+
imports: [NgTemplateOutlet],
1028+
standalone: true,
1029+
template: ` <ng-container [ngTemplateOutlet]="template"> </ng-container>`,
1030+
})
1031+
class Insertion {
1032+
@Input() template!: TemplateRef<{}>;
1033+
constructor(readonly changeDetectorRef: ChangeDetectorRef) {}
1034+
}
1035+
1036+
@Component({
1037+
imports: [MarkForCheck, Insertion],
1038+
template: `<ng-template #myTmpl> <markForCheck/> </ng-template>`,
1039+
standalone: true,
1040+
selector: 'declaration'
1041+
})
1042+
class Declaration {
1043+
@ViewChild('myTmpl', {static: true}) template!: TemplateRef<{}>;
1044+
}
1045+
@Component({
1046+
standalone: true,
1047+
imports: [Declaration, Insertion],
1048+
template: '<insertion [template]="declaration.template"/><declaration #declaration/>'
1049+
})
1050+
class App {
1051+
}
1052+
1053+
TestBed.configureTestingModule({
1054+
providers: [{
1055+
provide: ErrorHandler, useClass: class extends ErrorHandler {
1056+
override handleError(e: any) {
1057+
throw e;
1058+
}
1059+
}
1060+
}]
1061+
});
1062+
1063+
const app = createComponent(App, {environmentInjector: TestBed.inject(EnvironmentInjector)});
1064+
const appRef = TestBed.inject(ApplicationRef);
1065+
appRef.attachView(app.hostView);
1066+
// ApplicationRef has a loop to continue refreshing dirty views. If done incorrectly, refreshing
1067+
// the backwards reference transplanted view can cause an infinite loop because it goes and
1068+
// marks the root view dirty, which then starts the process all over again by checking the
1069+
// declaration.
1070+
expect(() => appRef.tick()).not.toThrow();
1071+
});
10161072
});
10171073

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

0 commit comments

Comments
 (0)