Skip to content

Commit 44c48a4

Browse files
fix(core): properly update collection with repeated keys in @for (#52697)
This change fixes a bug in the new list reconcilation algorithm that could lead to an infinite loop in certain situations. More specifically, it adjusts the internal MultiMap implementation such that an entry returned from the .get call is the same entry (for an identical key) removed by the .delete call. The existing logic of the MultiMap was leading to a situation where one view was requested and attached to LContainer, but a very different view was removed from the MultiMap. This was leaving an attached LView in a collection that was supposed to hold only detached views. Closes #52524 PR Close #52697
1 parent 2cea80c commit 44c48a4

2 files changed

Lines changed: 51 additions & 3 deletions

File tree

packages/core/src/render3/list_reconciliation.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -238,8 +238,11 @@ export function reconcile<T, V>(
238238
liveCollection.destroy(liveCollection.detach(liveEndIdx--));
239239
}
240240

241+
241242
// - destroy items that were detached but never attached again.
242-
detachedItems?.forEach(item => liveCollection.destroy(item));
243+
detachedItems?.forEach(item => {
244+
liveCollection.destroy(item);
245+
});
243246
}
244247

245248
function attachPreviouslyDetached<T, V>(
@@ -285,8 +288,7 @@ class MultiMap<K, V> {
285288
delete(key: K): boolean {
286289
const listOfKeys = this.map.get(key);
287290
if (listOfKeys !== undefined) {
288-
// THINK: pop from the end or shift from the front? "Correct" vs. "slow".
289-
listOfKeys.pop();
291+
listOfKeys.shift();
290292
return true;
291293
}
292294
return false;

packages/core/test/acceptance/control_flow_for_spec.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,52 @@ describe('control flow - for', () => {
277277
fixture.detectChanges();
278278
expect(fixture.nativeElement.textContent).toBe('5(0)|3(1)|7(2)|');
279279
});
280+
281+
it('should correctly attach and detach views with duplicated keys', () => {
282+
const BEFORE = [
283+
{'name': 'Task 14', 'id': 14},
284+
{'name': 'Task 14', 'id': 14},
285+
{'name': 'Task 70', 'id': 70},
286+
{'name': 'Task 34', 'id': 34},
287+
288+
];
289+
290+
const AFTER = [
291+
{'name': 'Task 70', 'id': 70},
292+
{'name': 'Task 14', 'id': 14},
293+
{'name': 'Task 28', 'id': 28},
294+
];
295+
296+
@Component({
297+
standalone: true,
298+
template: ``,
299+
selector: 'child-cmp',
300+
})
301+
class ChildCmp {
302+
}
303+
304+
@Component({
305+
standalone: true,
306+
imports: [ChildCmp],
307+
template: `
308+
@for(task of tasks; track task.id) {
309+
<child-cmp/>
310+
}
311+
`,
312+
})
313+
class TestComponent {
314+
tasks = BEFORE;
315+
}
316+
317+
const fixture = TestBed.createComponent(TestComponent);
318+
fixture.detectChanges();
319+
320+
const cmp = fixture.componentInstance;
321+
const nativeElement = fixture.debugElement.nativeElement;
322+
cmp.tasks = AFTER;
323+
fixture.detectChanges();
324+
expect(nativeElement.querySelectorAll('child-cmp').length).toBe(3);
325+
});
280326
});
281327

282328
describe('content projection', () => {

0 commit comments

Comments
 (0)