Skip to content

Commit 8592585

Browse files
committed
test(core): Add test to ensure writing to signals in afterRender hooks throws error (#52475)
We do not yet handle running change detection again if `afterRender` hooks write to signals. PR Close #52475
1 parent b9e2893 commit 8592585

File tree

2 files changed

+51
-21
lines changed

2 files changed

+51
-21
lines changed

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

Lines changed: 24 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -46,26 +46,7 @@ export function detectChangesInternal<T>(
4646

4747
try {
4848
refreshView(tView, lView, tView.template, context);
49-
let retries = 0;
50-
// If after running change detection, this view still needs to be refreshed or there are
51-
// descendants views that need to be refreshed due to re-dirtying during the change detection
52-
// run, detect changes on the view again. We run change detection in `Targeted` mode to only
53-
// refresh views with the `RefreshView` flag.
54-
while (lView[FLAGS] & (LViewFlags.RefreshView | LViewFlags.HasChildViewsToRefresh) ||
55-
lView[REACTIVE_TEMPLATE_CONSUMER]?.dirty) {
56-
if (retries === MAXIMUM_REFRESH_RERUNS) {
57-
throw new RuntimeError(
58-
RuntimeErrorCode.INFINITE_CHANGE_DETECTION,
59-
ngDevMode &&
60-
'Infinite change detection while trying to refresh views. ' +
61-
'There may be components which each cause the other to require a refresh, ' +
62-
'causing an infinite loop.');
63-
}
64-
retries++;
65-
// Even if this view is detached, we still detect changes in targeted mode because this was
66-
// the root of the change detection run.
67-
detectChangesInView(lView, ChangeDetectionMode.Targeted);
68-
}
49+
detectChangesInViewWhileDirty(lView);
6950
} catch (error) {
7051
if (notifyErrorHandler) {
7152
handleError(lView, error);
@@ -85,6 +66,29 @@ export function detectChangesInternal<T>(
8566
}
8667
}
8768

69+
function detectChangesInViewWhileDirty(lView: LView) {
70+
let retries = 0;
71+
// If after running change detection, this view still needs to be refreshed or there are
72+
// descendants views that need to be refreshed due to re-dirtying during the change detection
73+
// run, detect changes on the view again. We run change detection in `Targeted` mode to only
74+
// refresh views with the `RefreshView` flag.
75+
while (lView[FLAGS] & (LViewFlags.RefreshView | LViewFlags.HasChildViewsToRefresh) ||
76+
lView[REACTIVE_TEMPLATE_CONSUMER]?.dirty) {
77+
if (retries === MAXIMUM_REFRESH_RERUNS) {
78+
throw new RuntimeError(
79+
RuntimeErrorCode.INFINITE_CHANGE_DETECTION,
80+
ngDevMode &&
81+
'Infinite change detection while trying to refresh views. ' +
82+
'There may be components which each cause the other to require a refresh, ' +
83+
'causing an infinite loop.');
84+
}
85+
retries++;
86+
// Even if this view is detached, we still detect changes in targeted mode because this was
87+
// the root of the change detection run.
88+
detectChangesInView(lView, ChangeDetectionMode.Targeted);
89+
}
90+
}
91+
8892
export function checkNoChangesInternal<T>(
8993
tView: TView, lView: LView, context: T, notifyErrorHandler = true) {
9094
setIsInCheckNoChangesMode(true);

packages/core/test/acceptance/change_detection_signals_in_zones_spec.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
*/
88

99
import {NgFor, NgIf} from '@angular/common';
10-
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, computed, Directive, inject, Input, signal, ViewChild} from '@angular/core';
10+
import {PLATFORM_BROWSER_ID} from '@angular/common/src/platform_id';
11+
import {afterNextRender, ChangeDetectionStrategy, ChangeDetectorRef, Component, computed, Directive, inject, Input, PLATFORM_ID, signal, ViewChild} from '@angular/core';
1112
import {TestBed} from '@angular/core/testing';
1213

1314
describe('CheckAlways components', () => {
@@ -823,6 +824,31 @@ describe('OnPush components with signals', () => {
823824
fixture.componentInstance.cdr.detectChanges();
824825
expect(fixture.nativeElement.innerText).toEqual('2');
825826
});
827+
828+
// Note: We probably don't want this to throw but need to decide how to handle reruns
829+
// This asserts current behavior and should be updated when this is handled
830+
it('throws error when writing to a signal in afterRender', () => {
831+
const counter = signal(0);
832+
833+
@Component({
834+
selector: 'test-component',
835+
standalone: true,
836+
template: ` {{counter()}} `,
837+
})
838+
class TestCmp {
839+
counter = counter;
840+
constructor() {
841+
afterNextRender(() => {
842+
this.counter.set(1);
843+
});
844+
}
845+
}
846+
TestBed.configureTestingModule(
847+
{providers: [{provide: PLATFORM_ID, useValue: PLATFORM_BROWSER_ID}]});
848+
849+
const fixture = TestBed.createComponent(TestCmp);
850+
expect(() => fixture.detectChanges()).toThrowError(/ExpressionChanged/);
851+
});
826852
});
827853

828854

0 commit comments

Comments
 (0)