Skip to content

Commit e67a730

Browse files
pkozlowski-opensourcealxhub
authored andcommitted
refactor(core): warn about duplicated keys when using built-in @for (angular#55243)
This commit a new check that warn users about duplicated keys detected given a tracking expression and a collection iterated over with @for. Duplicated keys should be avoided as those are more expensive to manage and can result in incorrect UI display. PR Close angular#55243
1 parent a0eebcd commit e67a730

File tree

4 files changed

+162
-0
lines changed

4 files changed

+162
-0
lines changed

goldens/public-api/core/errors.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,8 @@ export const enum RuntimeErrorCode {
7979
// (undocumented)
8080
INVALID_SKIP_HYDRATION_HOST = -504,
8181
// (undocumented)
82+
LOOP_TRACK_DUPLICATE_KEYS = 955,
83+
// (undocumented)
8284
MISSING_DOCUMENT = 210,
8385
// (undocumented)
8486
MISSING_GENERATED_DEF = 906,

packages/core/src/errors.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,9 @@ export const enum RuntimeErrorCode {
124124
// Output()
125125
OUTPUT_REF_DESTROYED = 953,
126126

127+
// Repeater errors
128+
LOOP_TRACK_DUPLICATE_KEYS = 955,
129+
127130
// Runtime dependency tracker errors
128131
RUNTIME_DEPS_INVALID_IMPORTED_TYPE = 1000,
129132
RUNTIME_DEPS_ORPHAN_COMPONENT = 1001,

packages/core/src/render3/list_reconciliation.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,11 @@
77
*/
88

99
import {TrackByFunction} from '../change_detection';
10+
import {formatRuntimeError, RuntimeErrorCode} from '../errors';
1011
import {assertNotSame} from '../util/assert';
1112

13+
import {stringifyForError} from './util/stringify_utils';
14+
1215
/**
1316
* A type representing the live collection to be reconciled with any new (incoming) collection. This
1417
* is an adapter class that makes it possible to work with different internal data structures,
@@ -61,6 +64,16 @@ function valuesMatching<V>(
6164
return 0;
6265
}
6366

67+
function recordDuplicateKeys(keyToIdx: Map<unknown, Set<number>>, key: unknown, idx: number): void {
68+
const idxSoFar = keyToIdx.get(key);
69+
70+
if (idxSoFar !== undefined) {
71+
idxSoFar.add(idx);
72+
} else {
73+
keyToIdx.set(key, new Set([idx]));
74+
}
75+
}
76+
6477
/**
6578
* The live collection reconciliation algorithm that perform various in-place operations, so it
6679
* reflects the content of the new (incoming) collection.
@@ -93,13 +106,20 @@ export function reconcile<T, V>(
93106
let liveStartIdx = 0;
94107
let liveEndIdx = liveCollection.length - 1;
95108

109+
const duplicateKeys = ngDevMode ? new Map<unknown, Set<number>>() : undefined;
110+
96111
if (Array.isArray(newCollection)) {
97112
let newEndIdx = newCollection.length - 1;
98113

99114
while (liveStartIdx <= liveEndIdx && liveStartIdx <= newEndIdx) {
100115
// compare from the beginning
101116
const liveStartValue = liveCollection.at(liveStartIdx);
102117
const newStartValue = newCollection[liveStartIdx];
118+
119+
if (ngDevMode) {
120+
recordDuplicateKeys(duplicateKeys!, trackByFn(liveStartIdx, newStartValue), liveStartIdx);
121+
}
122+
103123
const isStartMatching =
104124
valuesMatching(liveStartIdx, liveStartValue, liveStartIdx, newStartValue, trackByFn);
105125
if (isStartMatching !== 0) {
@@ -114,6 +134,11 @@ export function reconcile<T, V>(
114134
// TODO(perf): do _all_ the matching from the end
115135
const liveEndValue = liveCollection.at(liveEndIdx);
116136
const newEndValue = newCollection[newEndIdx];
137+
138+
if (ngDevMode) {
139+
recordDuplicateKeys(duplicateKeys!, trackByFn(newEndIdx, newEndValue), newEndIdx);
140+
}
141+
117142
const isEndMatching =
118143
valuesMatching(liveEndIdx, liveEndValue, newEndIdx, newEndValue, trackByFn);
119144
if (isEndMatching !== 0) {
@@ -188,6 +213,11 @@ export function reconcile<T, V>(
188213
while (!newIterationResult.done && liveStartIdx <= liveEndIdx) {
189214
const liveValue = liveCollection.at(liveStartIdx);
190215
const newValue = newIterationResult.value;
216+
217+
if (ngDevMode) {
218+
recordDuplicateKeys(duplicateKeys!, trackByFn(liveStartIdx, newValue), liveStartIdx);
219+
}
220+
191221
const isStartMatching =
192222
valuesMatching(liveStartIdx, liveValue, liveStartIdx, newValue, trackByFn);
193223
if (isStartMatching !== 0) {
@@ -244,6 +274,31 @@ export function reconcile<T, V>(
244274
detachedItems?.forEach(item => {
245275
liveCollection.destroy(item);
246276
});
277+
278+
// report duplicate keys (dev mode only)
279+
if (ngDevMode) {
280+
let duplicatedKeysMsg = [];
281+
for (const [key, idxSet] of duplicateKeys!) {
282+
if (idxSet.size > 1) {
283+
const idx = [...idxSet].sort((a, b) => a - b);
284+
for (let i = 1; i < idx.length; i++) {
285+
duplicatedKeysMsg.push(
286+
`key "${stringifyForError(key)}" at index "${idx[i - 1]}" and "${idx[i]}"`);
287+
}
288+
}
289+
}
290+
291+
if (duplicatedKeysMsg.length > 0) {
292+
const message = formatRuntimeError(
293+
RuntimeErrorCode.LOOP_TRACK_DUPLICATE_KEYS,
294+
'The provided track expression resulted in duplicated keys for a given collection. ' +
295+
'Adjust the tracking expression such that it uniquely identifies all the items in the collection. ' +
296+
'Duplicated keys were: \n' + duplicatedKeysMsg.join(', \n') + '.');
297+
298+
// tslint:disable-next-line:no-console
299+
console.warn(message);
300+
}
301+
}
247302
}
248303

249304
function attachPreviouslyDetached<T, V>(

packages/core/test/acceptance/control_flow_for_spec.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,19 @@ describe('control flow - for', () => {
3838
expect(fixture.nativeElement.textContent).toBe('3(0)|2(1)|1(2)|');
3939
});
4040

41+
it('should loop over iterators that can be iterated over only once', () => {
42+
@Component({
43+
template: '@for ((item of items.keys()); track $index) {{{item}}|}',
44+
})
45+
class TestComponent {
46+
items = new Map([['a', 1], ['b', 2], ['c', 3]]);
47+
}
48+
49+
const fixture = TestBed.createComponent(TestComponent);
50+
fixture.detectChanges();
51+
expect(fixture.nativeElement.textContent).toBe('a|b|c|');
52+
});
53+
4154
it('should work correctly with trackBy index', () => {
4255
@Component({
4356
template: '@for ((item of items); track idx; let idx = $index) {{{item}}({{idx}})|}',
@@ -273,6 +286,95 @@ describe('control flow - for', () => {
273286
fixture.detectChanges();
274287
expect(context).toBe(fixture.componentInstance);
275288
});
289+
290+
it('should warn about duplicated keys when using arrays', () => {
291+
@Component({
292+
template: `@for (item of items; track item) {{{item}}}`,
293+
})
294+
class TestComponent {
295+
items = ['a', 'b', 'a', 'c', 'a'];
296+
}
297+
298+
spyOn(console, 'warn');
299+
300+
const fixture = TestBed.createComponent(TestComponent);
301+
fixture.detectChanges();
302+
expect(fixture.nativeElement.textContent).toBe('abaca');
303+
expect(console.warn).toHaveBeenCalledTimes(1);
304+
expect(console.warn)
305+
.toHaveBeenCalledWith(jasmine.stringContaining(
306+
`NG0955: The provided track expression resulted in duplicated keys for a given collection.`));
307+
expect(console.warn)
308+
.toHaveBeenCalledWith(jasmine.stringContaining(
309+
`Adjust the tracking expression such that it uniquely identifies all the items in the collection. `));
310+
expect(console.warn)
311+
.toHaveBeenCalledWith(jasmine.stringContaining(`key "a" at index "0" and "2"`));
312+
expect(console.warn)
313+
.toHaveBeenCalledWith(jasmine.stringContaining(`key "a" at index "2" and "4"`));
314+
});
315+
316+
it('should warn about duplicated keys when using iterables', () => {
317+
@Component({
318+
template: `@for (item of items.values(); track item) {{{item}}}`,
319+
})
320+
class TestComponent {
321+
items = new Map([[1, 'a'], [2, 'b'], [3, 'a'], [4, 'c'], [5, 'a']]);
322+
}
323+
324+
spyOn(console, 'warn');
325+
326+
const fixture = TestBed.createComponent(TestComponent);
327+
fixture.detectChanges();
328+
expect(fixture.nativeElement.textContent).toBe('abaca');
329+
expect(console.warn).toHaveBeenCalledTimes(1);
330+
expect(console.warn)
331+
.toHaveBeenCalledWith(jasmine.stringContaining(
332+
`NG0955: The provided track expression resulted in duplicated keys for a given collection.`));
333+
expect(console.warn)
334+
.toHaveBeenCalledWith(jasmine.stringContaining(
335+
`Adjust the tracking expression such that it uniquely identifies all the items in the collection. `));
336+
expect(console.warn)
337+
.toHaveBeenCalledWith(jasmine.stringContaining(`key "a" at index "0" and "2"`));
338+
expect(console.warn)
339+
.toHaveBeenCalledWith(jasmine.stringContaining(`key "a" at index "2" and "4"`));
340+
});
341+
342+
it('should warn about duplicate keys when keys are expressed as symbols', () => {
343+
const value = Symbol('a');
344+
345+
@Component({
346+
template: `@for (item of items.values(); track item) {}`,
347+
})
348+
class TestComponent {
349+
items = new Map([[1, value], [2, value]]);
350+
}
351+
352+
spyOn(console, 'warn');
353+
354+
const fixture = TestBed.createComponent(TestComponent);
355+
fixture.detectChanges();
356+
expect(console.warn)
357+
.toHaveBeenCalledWith(jasmine.stringContaining(`Symbol(a)" at index "0" and "1".`));
358+
});
359+
360+
it('should warn about duplicate keys iterating over the new collection only', () => {
361+
@Component({
362+
template: `@for (item of items; track item) {}`,
363+
})
364+
class TestComponent {
365+
items = [1, 2, 3];
366+
}
367+
368+
spyOn(console, 'warn');
369+
370+
const fixture = TestBed.createComponent(TestComponent);
371+
fixture.detectChanges();
372+
expect(console.warn).not.toHaveBeenCalled();
373+
374+
fixture.componentInstance.items = [4, 5, 6];
375+
fixture.detectChanges();
376+
expect(console.warn).not.toHaveBeenCalled();
377+
});
276378
});
277379

278380
describe('list diffing and view operations', () => {

0 commit comments

Comments
 (0)