diff --git a/packages/core/src/render3/instructions/shared.ts b/packages/core/src/render3/instructions/shared.ts index 628c0b6c785..d4bcc0e03ae 100644 --- a/packages/core/src/render3/instructions/shared.ts +++ b/packages/core/src/render3/instructions/shared.ts @@ -17,6 +17,7 @@ import {DoCheck, OnChanges, OnInit} from '../../interface/lifecycle_hooks'; import {SchemaMetadata} from '../../metadata/schema'; import {ViewEncapsulation} from '../../metadata/view'; import {validateAgainstEventAttributes, validateAgainstEventProperties} from '../../sanitization/sanitization'; +import {setActiveConsumer} from '../../signals'; import {assertDefined, assertEqual, assertGreaterThan, assertGreaterThanOrEqual, assertIndexInRange, assertNotEqual, assertNotSame, assertSame, assertString} from '../../util/assert'; import {escapeCommentText} from '../../util/dom'; import {normalizeDebugBindingName, normalizeDebugBindingValue} from '../../util/ng_reflect'; @@ -507,9 +508,18 @@ function executeTemplate( const preHookType = isUpdatePhase ? ProfilerEvent.TemplateUpdateStart : ProfilerEvent.TemplateCreateStart; profiler(preHookType, context as unknown as {}); - consumer.runInContext(templateFn, rf, context); + if (isUpdatePhase) { + consumer.runInContext(templateFn, rf, context); + } else { + const prevConsumer = setActiveConsumer(null); + try { + templateFn(rf, context); + } finally { + setActiveConsumer(prevConsumer); + } + } } finally { - if (lView[REACTIVE_TEMPLATE_CONSUMER] === null) { + if (isUpdatePhase && lView[REACTIVE_TEMPLATE_CONSUMER] === null) { commitLViewConsumerIfHasProducers(lView, REACTIVE_TEMPLATE_CONSUMER); } setSelectedIndex(prevSelectedIndex); diff --git a/packages/core/test/acceptance/change_detection_signals_in_zones_spec.ts b/packages/core/test/acceptance/change_detection_signals_in_zones_spec.ts index 320fa765866..4238008e9f5 100644 --- a/packages/core/test/acceptance/change_detection_signals_in_zones_spec.ts +++ b/packages/core/test/acceptance/change_detection_signals_in_zones_spec.ts @@ -6,6 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ +import {NgIf} from '@angular/common'; import {ChangeDetectionStrategy, Component} from '@angular/core'; import {TestBed} from '@angular/core/testing'; @@ -92,6 +93,54 @@ describe('OnPush components with signals', () => { expect(instance.value()).toBe('new'); }); + it('should not mark components as dirty when signal is read in a constructor of a child component', + () => { + const state = signal('initial'); + + @Component({ + selector: 'child', + template: `child`, + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, + }) + class ChildReadingSignalCmp { + constructor() { + state(); + } + } + + @Component({ + template: ` + {{incrementTemplateExecutions()}} + + + `, + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, + imports: [NgIf, ChildReadingSignalCmp], + }) + class OnPushCmp { + numTemplateExecutions = 0; + incrementTemplateExecutions() { + this.numTemplateExecutions++; + return ''; + } + } + + const fixture = TestBed.createComponent(OnPushCmp); + const instance = fixture.componentInstance; + + fixture.detectChanges(); + expect(instance.numTemplateExecutions).toBe(1); + expect(fixture.nativeElement.textContent.trim()).toEqual('child'); + + // The "state" signal is not accesses in the template's update function anywhere so it + // shouldn't mark components as dirty / impact change detection. + state.set('new'); + fixture.detectChanges(); + expect(instance.numTemplateExecutions).toBe(1); + }); + it('can read a signal in a host binding', () => { @Component({ template: `{{incrementTemplateExecutions()}}`, diff --git a/packages/core/test/render3/reactivity_spec.ts b/packages/core/test/render3/reactivity_spec.ts index 4bb763ef65e..4db0a6c4ad7 100644 --- a/packages/core/test/render3/reactivity_spec.ts +++ b/packages/core/test/render3/reactivity_spec.ts @@ -239,8 +239,7 @@ describe('effects', () => { selector: 'test-cmp', standalone: true, imports: [WithInput], - template: `| - `, + template: `|`, }) class Cmp { } @@ -249,4 +248,32 @@ describe('effects', () => { fixture.detectChanges(); expect(fixture.nativeElement.textContent).toBe('A|B'); }); + + it('should allow writing to signals in a constructor', () => { + @Component({ + selector: 'with-constructor', + standalone: true, + template: '{{state()}}', + }) + class WithConstructor { + state = signal('property initializer'); + + constructor() { + this.state.set('constructor'); + } + } + + @Component({ + selector: 'test-cmp', + standalone: true, + imports: [WithConstructor], + template: ``, + }) + class Cmp { + } + + const fixture = TestBed.createComponent(Cmp); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toBe('constructor'); + }); });