Skip to content

Commit 744cb1e

Browse files
fix(core): return the same children query results if there are no changes (#54392)
Assure that the same readonly array corresponding to the children query results is returned for cases where a query is marked as dirty but there were no actual changes to the content of the results array (this can happen if a view is added and removed thus marking queries as dirty but not influencing final results). Fixes #54376 PR Close #54392
1 parent deb9ea0 commit 744cb1e

File tree

2 files changed

+94
-1
lines changed

2 files changed

+94
-1
lines changed

packages/core/src/render3/query_reactive.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,11 @@ interface QuerySignalNode<T> extends ComputedNode<T|ReadonlyArray<T>> {
2424
_queryIndex?: number;
2525
_queryList?: QueryList<T>;
2626
_dirtyCounter: WritableSignal<number>;
27+
/**
28+
* Stores the last seen, flattened results for a query. This is to avoid marking the signal result
29+
* computed as dirty when there was view manipulation that didn't impact final results.
30+
*/
31+
_flatValue?: T|ReadonlyArray<T>;
2732
}
2833

2934
/**
@@ -58,6 +63,7 @@ function createQuerySignalFn<V>(firstOnly: boolean, required: boolean) {
5863
});
5964
node = signalFn[SIGNAL] as QuerySignalNode<V>;
6065
node._dirtyCounter = signal(0);
66+
node._flatValue = undefined;
6167

6268
if (ngDevMode) {
6369
signalFn.toString = () => `[Query Signal]`;
@@ -111,5 +117,15 @@ function refreshSignalQuery<V>(node: QuerySignalNode<V>, firstOnly: boolean): V|
111117

112118
queryList.reset(results, unwrapElementRef);
113119

114-
return firstOnly ? queryList.first : queryList.toArray();
120+
if (firstOnly) {
121+
return queryList.first;
122+
} else {
123+
// TODO: remove access to the private _changesDetected field by abstracting / removing usage of
124+
// QueryList in the signal-based queries (perf follow-up)
125+
const resultChanged = (queryList as any as {_changesDetected: boolean})._changesDetected;
126+
if (resultChanged || node._flatValue === undefined) {
127+
return node._flatValue = queryList.toArray();
128+
}
129+
return node._flatValue;
130+
}
115131
}

packages/core/test/acceptance/authoring/signal_queries_spec.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,4 +364,81 @@ describe('queries as signals', () => {
364364
expect(queryDir.results().length).toBe(0);
365365
});
366366
});
367+
368+
describe('reactivity and performance', () => {
369+
it('should not dirty a children query when a list of matches does not change - a view with matches',
370+
() => {
371+
let recomputeCount = 0;
372+
373+
@Component({
374+
standalone: true,
375+
template: `
376+
<div #el></div>
377+
@if (show) {
378+
<div #el></div>
379+
}
380+
`,
381+
})
382+
class AppComponent {
383+
divEls = viewChildren<ElementRef<HTMLDivElement>>('el');
384+
foundElCount = computed(() => {
385+
recomputeCount++;
386+
return this.divEls().length;
387+
});
388+
show = false;
389+
}
390+
391+
const fixture = TestBed.createComponent(AppComponent);
392+
fixture.detectChanges();
393+
expect(fixture.componentInstance.foundElCount()).toBe(1);
394+
expect(recomputeCount).toBe(1);
395+
396+
// trigger view manipulation that should dirty queries but not change the results
397+
fixture.componentInstance.show = true;
398+
fixture.detectChanges();
399+
fixture.componentInstance.show = false;
400+
fixture.detectChanges();
401+
402+
expect(fixture.componentInstance.foundElCount()).toBe(1);
403+
expect(recomputeCount).toBe(1);
404+
});
405+
406+
it('should not dirty a children query when a list of matches does not change - a view with another container',
407+
() => {
408+
let recomputeCount = 0;
409+
410+
@Component({
411+
standalone: true,
412+
template: `
413+
<div #el></div>
414+
@if (show) {
415+
<!-- an empty if to create a container -->
416+
@if (true) {}
417+
}
418+
`,
419+
})
420+
class AppComponent {
421+
divEls = viewChildren<ElementRef<HTMLDivElement>>('el');
422+
foundElCount = computed(() => {
423+
recomputeCount++;
424+
return this.divEls().length;
425+
});
426+
show = false;
427+
}
428+
429+
const fixture = TestBed.createComponent(AppComponent);
430+
fixture.detectChanges();
431+
expect(fixture.componentInstance.foundElCount()).toBe(1);
432+
expect(recomputeCount).toBe(1);
433+
434+
// trigger view manipulation that should dirty queries but not change the results
435+
fixture.componentInstance.show = true;
436+
fixture.detectChanges();
437+
fixture.componentInstance.show = false;
438+
fixture.detectChanges();
439+
440+
expect(fixture.componentInstance.foundElCount()).toBe(1);
441+
expect(recomputeCount).toBe(1);
442+
});
443+
});
367444
});

0 commit comments

Comments
 (0)