Skip to content

Commit f8b95b9

Browse files
fix(core): execute query setters in non-reactive context (#49906)
This commit assures that query results setters run when there is no active reactive consumer set. PR Close #49906
1 parent 2650f1a commit f8b95b9

File tree

3 files changed

+148
-9
lines changed

3 files changed

+148
-9
lines changed

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

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -536,13 +536,18 @@ function executeTemplate<T>(
536536

537537
export function executeContentQueries(tView: TView, tNode: TNode, lView: LView) {
538538
if (isContentQueryHost(tNode)) {
539-
const start = tNode.directiveStart;
540-
const end = tNode.directiveEnd;
541-
for (let directiveIndex = start; directiveIndex < end; directiveIndex++) {
542-
const def = tView.data[directiveIndex] as DirectiveDef<any>;
543-
if (def.contentQueries) {
544-
def.contentQueries(RenderFlags.Create, lView[directiveIndex], directiveIndex);
539+
const prevConsumer = setActiveConsumer(null);
540+
try {
541+
const start = tNode.directiveStart;
542+
const end = tNode.directiveEnd;
543+
for (let directiveIndex = start; directiveIndex < end; directiveIndex++) {
544+
const def = tView.data[directiveIndex] as DirectiveDef<any>;
545+
if (def.contentQueries) {
546+
def.contentQueries(RenderFlags.Create, lView[directiveIndex], directiveIndex);
547+
}
545548
}
549+
} finally {
550+
setActiveConsumer(prevConsumer);
546551
}
547552
}
548553
}
@@ -1873,7 +1878,12 @@ function executeViewQueryFn<T>(
18731878
flags: RenderFlags, viewQueryFn: ViewQueriesFunction<T>, component: T): void {
18741879
ngDevMode && assertDefined(viewQueryFn, 'View queries function to execute must be defined.');
18751880
setCurrentQueryIndex(0);
1876-
viewQueryFn(flags, component);
1881+
const prevConsumer = setActiveConsumer(null);
1882+
try {
1883+
viewQueryFn(flags, component);
1884+
} finally {
1885+
setActiveConsumer(prevConsumer);
1886+
}
18771887
}
18781888

18791889
///////////////////////////////

packages/core/test/acceptance/change_detection_signals_in_zones_spec.ts

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

99
import {NgIf} from '@angular/common';
10-
import {ChangeDetectionStrategy, Component, Input} from '@angular/core';
10+
import {ChangeDetectionStrategy, Component, Input, ViewChild} from '@angular/core';
1111
import {TestBed} from '@angular/core/testing';
1212

1313
import {signal} from '../../src/signals';
@@ -192,6 +192,56 @@ describe('OnPush components with signals', () => {
192192
expect(fixture.nativeElement.textContent.trim()).toEqual('initial:input');
193193
});
194194

195+
it('should not mark components as dirty when signal is read in a query result setter', () => {
196+
const state = signal('initial');
197+
198+
@Component({
199+
selector: 'with-query-setter',
200+
standalone: true,
201+
template: '<div #el>child</div>',
202+
})
203+
class WithQuerySetter {
204+
el: unknown;
205+
@ViewChild('el', {static: true})
206+
set elQuery(result: unknown) {
207+
// read a signal in a setter
208+
state();
209+
this.el = result;
210+
}
211+
}
212+
213+
@Component({
214+
template: `
215+
{{incrementTemplateExecutions()}}
216+
<!-- Template constructed to execute child component constructor in the update pass of a host component -->
217+
<ng-template [ngIf]="true"><with-query-setter /></ng-template>
218+
`,
219+
changeDetection: ChangeDetectionStrategy.OnPush,
220+
standalone: true,
221+
imports: [NgIf, WithQuerySetter],
222+
})
223+
class OnPushCmp {
224+
numTemplateExecutions = 0;
225+
incrementTemplateExecutions() {
226+
this.numTemplateExecutions++;
227+
return '';
228+
}
229+
}
230+
231+
const fixture = TestBed.createComponent(OnPushCmp);
232+
const instance = fixture.componentInstance;
233+
234+
fixture.detectChanges();
235+
expect(instance.numTemplateExecutions).toBe(1);
236+
expect(fixture.nativeElement.textContent.trim()).toEqual('child');
237+
238+
// The "state" signal is not accesses in the template's update function anywhere so it
239+
// shouldn't mark components as dirty / impact change detection.
240+
state.set('new');
241+
fixture.detectChanges();
242+
expect(instance.numTemplateExecutions).toBe(1);
243+
});
244+
195245
it('can read a signal in a host binding', () => {
196246
@Component({
197247
template: `{{incrementTemplateExecutions()}}`,

packages/core/test/render3/reactivity_spec.ts

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

9-
import {AfterViewInit, Component, destroyPlatform, effect, inject, Injector, Input, NgZone, OnChanges, signal, SimpleChanges} from '@angular/core';
9+
import {AfterViewInit, Component, ContentChildren, createComponent, destroyPlatform, effect, EnvironmentInjector, inject, Injector, Input, NgZone, OnChanges, QueryList, signal, SimpleChanges, ViewChild} from '@angular/core';
1010
import {TestBed} from '@angular/core/testing';
1111
import {bootstrapApplication} from '@angular/platform-browser';
1212
import {withBody} from '@angular/private/testing';
@@ -307,4 +307,83 @@ describe('effects', () => {
307307
fixture.detectChanges();
308308
expect(fixture.nativeElement.textContent).toBe('binding|static');
309309
});
310+
311+
it('should allow writing to signals in query result setters', () => {
312+
@Component({
313+
selector: 'with-query',
314+
standalone: true,
315+
template: '{{items().length}}',
316+
})
317+
class WithQuery {
318+
items = signal<unknown[]>([]);
319+
320+
@ContentChildren('item')
321+
set itemsQuery(result: QueryList<unknown>) {
322+
this.items.set(result.toArray());
323+
}
324+
}
325+
326+
@Component({
327+
selector: 'test-cmp',
328+
standalone: true,
329+
imports: [WithQuery],
330+
template: `<with-query><div #item></div></with-query>`,
331+
})
332+
class Cmp {
333+
}
334+
335+
const fixture = TestBed.createComponent(Cmp);
336+
fixture.detectChanges();
337+
expect(fixture.nativeElement.textContent).toBe('1');
338+
});
339+
340+
it('should not execute query setters in the reactive context', () => {
341+
const state = signal('initial');
342+
343+
@Component({
344+
selector: 'with-query-setter',
345+
standalone: true,
346+
template: '<div #el></div>',
347+
348+
})
349+
class WithQuerySetter {
350+
el: unknown;
351+
@ViewChild('el', {static: true})
352+
set elQuery(result: unknown) {
353+
// read a signal in a setter - I want to verify that framework executes this code outside of
354+
// the reactive context
355+
state();
356+
this.el = result;
357+
}
358+
}
359+
360+
@Component({
361+
selector: 'test-cmp',
362+
standalone: true,
363+
template: ``,
364+
})
365+
class Cmp {
366+
noOfCmpCreated = 0;
367+
constructor(environmentInjector: EnvironmentInjector) {
368+
// A slightly artificial setup where a component instance is created using imperative APIs.
369+
// We don't have control over the timing / reactive context of such API calls so need to
370+
// code defensively in the framework.
371+
372+
// Here we want to specifically verify that an effect is _not_ re-run if a signal read
373+
// happens in a query setter of a dynamically created component.
374+
effect(() => {
375+
createComponent(WithQuerySetter, {environmentInjector});
376+
this.noOfCmpCreated++;
377+
});
378+
}
379+
}
380+
381+
const fixture = TestBed.createComponent(Cmp);
382+
fixture.detectChanges();
383+
expect(fixture.componentInstance.noOfCmpCreated).toBe(1);
384+
385+
state.set('changed');
386+
fixture.detectChanges();
387+
expect(fixture.componentInstance.noOfCmpCreated).toBe(1);
388+
});
310389
});

0 commit comments

Comments
 (0)