Skip to content

Commit b7392f9

Browse files
fix(core): execute template creation in non-reactive context (#49883)
This fix assures that templates functions executed in the creation mode are run outside of the reactive context. This avoids the situation where signal reads in a directive constructor (executed as part of the creation mode) would mark the host component as dirty. Fixes #49871 PR Close #49883
1 parent ec30674 commit b7392f9

File tree

3 files changed

+90
-4
lines changed

3 files changed

+90
-4
lines changed

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

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {DoCheck, OnChanges, OnInit} from '../../interface/lifecycle_hooks';
1717
import {SchemaMetadata} from '../../metadata/schema';
1818
import {ViewEncapsulation} from '../../metadata/view';
1919
import {validateAgainstEventAttributes, validateAgainstEventProperties} from '../../sanitization/sanitization';
20+
import {setActiveConsumer} from '../../signals';
2021
import {assertDefined, assertEqual, assertGreaterThan, assertGreaterThanOrEqual, assertIndexInRange, assertNotEqual, assertNotSame, assertSame, assertString} from '../../util/assert';
2122
import {escapeCommentText} from '../../util/dom';
2223
import {normalizeDebugBindingName, normalizeDebugBindingValue} from '../../util/ng_reflect';
@@ -507,9 +508,18 @@ function executeTemplate<T>(
507508
const preHookType =
508509
isUpdatePhase ? ProfilerEvent.TemplateUpdateStart : ProfilerEvent.TemplateCreateStart;
509510
profiler(preHookType, context as unknown as {});
510-
consumer.runInContext(templateFn, rf, context);
511+
if (isUpdatePhase) {
512+
consumer.runInContext(templateFn, rf, context);
513+
} else {
514+
const prevConsumer = setActiveConsumer(null);
515+
try {
516+
templateFn(rf, context);
517+
} finally {
518+
setActiveConsumer(prevConsumer);
519+
}
520+
}
511521
} finally {
512-
if (lView[REACTIVE_TEMPLATE_CONSUMER] === null) {
522+
if (isUpdatePhase && lView[REACTIVE_TEMPLATE_CONSUMER] === null) {
513523
commitLViewConsumerIfHasProducers(lView, REACTIVE_TEMPLATE_CONSUMER);
514524
}
515525
setSelectedIndex(prevSelectedIndex);

packages/core/test/acceptance/change_detection_signals_in_zones_spec.ts

Lines changed: 49 additions & 0 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 {NgIf} from '@angular/common';
910
import {ChangeDetectionStrategy, Component} from '@angular/core';
1011
import {TestBed} from '@angular/core/testing';
1112

@@ -92,6 +93,54 @@ describe('OnPush components with signals', () => {
9293
expect(instance.value()).toBe('new');
9394
});
9495

96+
it('should not mark components as dirty when signal is read in a constructor of a child component',
97+
() => {
98+
const state = signal('initial');
99+
100+
@Component({
101+
selector: 'child',
102+
template: `child`,
103+
changeDetection: ChangeDetectionStrategy.OnPush,
104+
standalone: true,
105+
})
106+
class ChildReadingSignalCmp {
107+
constructor() {
108+
state();
109+
}
110+
}
111+
112+
@Component({
113+
template: `
114+
{{incrementTemplateExecutions()}}
115+
<!-- Template constructed to execute child component constructor in the update pass of a host component -->
116+
<ng-template [ngIf]="true"><child></child></ng-template>
117+
`,
118+
changeDetection: ChangeDetectionStrategy.OnPush,
119+
standalone: true,
120+
imports: [NgIf, ChildReadingSignalCmp],
121+
})
122+
class OnPushCmp {
123+
numTemplateExecutions = 0;
124+
incrementTemplateExecutions() {
125+
this.numTemplateExecutions++;
126+
return '';
127+
}
128+
}
129+
130+
const fixture = TestBed.createComponent(OnPushCmp);
131+
const instance = fixture.componentInstance;
132+
133+
fixture.detectChanges();
134+
expect(instance.numTemplateExecutions).toBe(1);
135+
expect(fixture.nativeElement.textContent.trim()).toEqual('child');
136+
137+
// The "state" signal is not accesses in the template's update function anywhere so it
138+
// shouldn't mark components as dirty / impact change detection.
139+
state.set('new');
140+
fixture.detectChanges();
141+
expect(instance.numTemplateExecutions).toBe(1);
142+
});
143+
95144
it('can read a signal in a host binding', () => {
96145
@Component({
97146
template: `{{incrementTemplateExecutions()}}`,

packages/core/test/render3/reactivity_spec.ts

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -239,8 +239,7 @@ describe('effects', () => {
239239
selector: 'test-cmp',
240240
standalone: true,
241241
imports: [WithInput],
242-
template: `<with-input [in]="'A'" />|<with-input [in]="'B'" />
243-
`,
242+
template: `<with-input [in]="'A'" />|<with-input [in]="'B'" />`,
244243
})
245244
class Cmp {
246245
}
@@ -249,4 +248,32 @@ describe('effects', () => {
249248
fixture.detectChanges();
250249
expect(fixture.nativeElement.textContent).toBe('A|B');
251250
});
251+
252+
it('should allow writing to signals in a constructor', () => {
253+
@Component({
254+
selector: 'with-constructor',
255+
standalone: true,
256+
template: '{{state()}}',
257+
})
258+
class WithConstructor {
259+
state = signal('property initializer');
260+
261+
constructor() {
262+
this.state.set('constructor');
263+
}
264+
}
265+
266+
@Component({
267+
selector: 'test-cmp',
268+
standalone: true,
269+
imports: [WithConstructor],
270+
template: `<with-constructor />`,
271+
})
272+
class Cmp {
273+
}
274+
275+
const fixture = TestBed.createComponent(Cmp);
276+
fixture.detectChanges();
277+
expect(fixture.nativeElement.textContent).toBe('constructor');
278+
});
252279
});

0 commit comments

Comments
 (0)