Skip to content

Commit 8ae29fa

Browse files
authored
fix: throw on duplicate keys in collections (#10605)
* fix: throw on duplicate keys in collections Two connected elements with the same key overwrite each other in the collection's keyMap while their siblings still link to that key, so the sibling chain either drops items silently or loops forever when walked backwards. Track which element owns each key in the collection Document and throw in development when a different connected element claims it. The GridList sections fixture reused the animal ids for the ice cream section and only rendered because the duplicates were dropped. * fix: simplify the duplicate key check and cover Activity boundaries Removed and hidden elements release their key in removeNode before any node is added, so the owner check only needs to compare elements; the isConnected and isHidden clauses were redundant. Cover the hidden subtree case: an item can move between a hidden and a visible Activity with the same key, and revealing a hidden duplicate next to a visible one throws. Skip the owner bookkeeping in production, where the check is off.
1 parent cc0bba9 commit 8ae29fa

5 files changed

Lines changed: 162 additions & 4 deletions

File tree

packages/react-aria-components/test/GridList.test.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -87,15 +87,15 @@ let TestGridListSections = ({listBoxProps, itemProps}) => (
8787
</GridListItem>
8888
</GridListSection>
8989
<GridListSection aria-label="Favorite Ice Cream">
90-
<GridListItem {...itemProps} id="cat" textValue="Vanilla">
90+
<GridListItem {...itemProps} id="vanilla" textValue="Vanilla">
9191
<Checkbox slot="selection" />
9292
Vanilla
9393
</GridListItem>
94-
<GridListItem {...itemProps} id="dog" textValue="Chocolate">
94+
<GridListItem {...itemProps} id="chocolate" textValue="Chocolate">
9595
<Checkbox slot="selection" />
9696
Chocolate
9797
</GridListItem>
98-
<GridListItem {...itemProps} id="kangaroo" textValue="Strawberry">
98+
<GridListItem {...itemProps} id="strawberry" textValue="Strawberry">
9999
<Checkbox slot="selection" />
100100
Strawberry
101101
</GridListItem>

packages/react-aria-components/test/ListBox.test.js

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2444,6 +2444,47 @@ describe('ListBox', () => {
24442444
rerender(<App mode="visible" pathname="/" ready />);
24452445
expect(queryAllByRole('option')).toHaveLength(5);
24462446
});
2447+
2448+
it('should only treat visible items as duplicate keys across Activity boundaries', () => {
2449+
function App({first}) {
2450+
return (
2451+
<ListBox aria-label="Test">
2452+
<React.Activity mode={first ? 'visible' : 'hidden'}>
2453+
<ListBoxItem id="a">First A</ListBoxItem>
2454+
</React.Activity>
2455+
<React.Activity mode={first ? 'hidden' : 'visible'}>
2456+
<ListBoxItem id="a">Second A</ListBoxItem>
2457+
</React.Activity>
2458+
<ListBoxItem id="b">B</ListBoxItem>
2459+
</ListBox>
2460+
);
2461+
}
2462+
2463+
let {getAllByRole, rerender, unmount} = render(<App first />);
2464+
expect(getAllByRole('option').map(o => o.textContent)).toEqual(['First A', 'B']);
2465+
rerender(<App first={false} />);
2466+
expect(getAllByRole('option').map(o => o.textContent)).toEqual(['Second A', 'B']);
2467+
rerender(<App first />);
2468+
expect(getAllByRole('option').map(o => o.textContent)).toEqual(['First A', 'B']);
2469+
unmount();
2470+
2471+
function Reveal({mode}) {
2472+
return (
2473+
<ListBox aria-label="Test">
2474+
<React.Activity mode={mode}>
2475+
<ListBoxItem id="a">Hidden A</ListBoxItem>
2476+
</React.Activity>
2477+
<ListBoxItem id="a">Visible A</ListBoxItem>
2478+
</ListBox>
2479+
);
2480+
}
2481+
2482+
let revealed = render(<Reveal mode="hidden" />);
2483+
expect(revealed.getAllByRole('option').map(o => o.textContent)).toEqual(['Visible A']);
2484+
expect(() => revealed.rerender(<Reveal mode="visible" />)).toThrow(
2485+
'Duplicate key "a" found in collection.'
2486+
);
2487+
});
24472488
}
24482489
});
24492490

packages/react-aria-components/test/Table.test.js

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2426,6 +2426,37 @@ describe('Table', () => {
24262426
'No id detected for the Row element. The Row element requires a id to be provided to it when the cells are rendered dynamically.'
24272427
);
24282428
});
2429+
2430+
it('should throw an error if two rows share an id', () => {
2431+
function DuplicateRowIds() {
2432+
return (
2433+
<Table aria-label="Files">
2434+
<TableHeader>
2435+
<Column isRowHeader>Name</Column>
2436+
<Column>Type</Column>
2437+
</TableHeader>
2438+
<TableBody>
2439+
<Row id="1">
2440+
<Cell>Games</Cell>
2441+
<Cell>File folder</Cell>
2442+
</Row>
2443+
<Row id="1">
2444+
<Cell>Program Files</Cell>
2445+
<Cell>File folder</Cell>
2446+
</Row>
2447+
<Row id="2">
2448+
<Cell>bootmgr</Cell>
2449+
<Cell>System file</Cell>
2450+
</Row>
2451+
</TableBody>
2452+
</Table>
2453+
);
2454+
}
2455+
2456+
expect(() => render(<DuplicateRowIds />)).toThrow(
2457+
'Duplicate key "1" found in collection. Every item in a collection must have a unique key.'
2458+
);
2459+
});
24292460
});
24302461

24312462
describe('load more spinner', () => {

packages/react-aria/src/collections/Document.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
import {BaseCollection, CollectionNode, Mutable} from './BaseCollection';
1414
import {CollectionNodeClass} from './CollectionBuilder';
1515
import {CSSProperties, ForwardedRef, ReactElement, ReactNode} from 'react';
16-
import {Node} from '@react-types/shared';
16+
import {Key, Node} from '@react-types/shared';
1717

1818
// This Collection implementation is perhaps a little unusual. It works by rendering the React tree into a
1919
// Portal to a fake DOM implementation. This gives us efficient access to the tree of rendered objects, and
@@ -432,6 +432,7 @@ export class Document<T, C extends BaseCollection<T> = BaseCollection<T>> extend
432432
isSSR = false;
433433
nodeId = 0;
434434
nodesByProps: WeakMap<object, ElementNode<T>> = new WeakMap<object, ElementNode<T>>();
435+
private keyOwners: Map<Key, ElementNode<T>> = new Map();
435436
private collection: C;
436437
private nextCollection: C | null = null;
437438
private subscriptions: Set<() => void> = new Set();
@@ -470,6 +471,21 @@ export class Document<T, C extends BaseCollection<T> = BaseCollection<T>> extend
470471
return;
471472
}
472473

474+
if (process.env.NODE_ENV !== 'production') {
475+
// Two elements with the same key would corrupt the linked list of sibling keys, which
476+
// silently drops items or makes key traversal loop forever. Removed and hidden elements
477+
// release their key in removeNode before any node is added, so a different owner here
478+
// is a live duplicate.
479+
let key = element.node.key;
480+
let owner = this.keyOwners.get(key);
481+
if (owner && owner !== element) {
482+
throw new Error(
483+
`Duplicate key "${String(key)}" found in collection. Every item in a collection must have a unique key.`
484+
);
485+
}
486+
this.keyOwners.set(key, element);
487+
}
488+
473489
let collection = this.getMutableCollection();
474490
if (!collection.getItem(element.node.key)) {
475491
for (let child of element) {
@@ -488,6 +504,9 @@ export class Document<T, C extends BaseCollection<T> = BaseCollection<T>> extend
488504
if (node.node) {
489505
let collection = this.getMutableCollection();
490506
collection.removeNode(node.node.key);
507+
if (process.env.NODE_ENV !== 'production' && this.keyOwners.get(node.node.key) === node) {
508+
this.keyOwners.delete(node.node.key);
509+
}
491510
}
492511
}
493512

@@ -592,6 +611,7 @@ export class Document<T, C extends BaseCollection<T> = BaseCollection<T>> extend
592611
this.firstChild = null;
593612
this.lastChild = null;
594613
this.nodeId = 0;
614+
this.keyOwners.clear();
595615
}
596616
}
597617
}

packages/react-aria/test/collections/CollectionBuilder.test.js

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,22 @@ const renderItems = (items, spyCollection) => (
4040
</CollectionBuilder>
4141
);
4242

43+
const renderItemsWithIds = (items, spyCollection) => (
44+
<CollectionBuilder
45+
content={
46+
<Collection>
47+
{items.map(item => (
48+
<Item key={item.key} id={item.id} />
49+
))}
50+
</Collection>
51+
}>
52+
{collection => {
53+
spyCollection.current = collection;
54+
return null;
55+
}}
56+
</CollectionBuilder>
57+
);
58+
4359
const renderItemsOld = (items, spyCollection) => (
4460
<CollectionBuilder
4561
content={
@@ -74,6 +90,56 @@ describe('CollectionBuilder', () => {
7490
expect(spyCollection.current.lastKey).toBe(null);
7591
});
7692

93+
it('should throw when two items share a key', () => {
94+
let spyCollection = {};
95+
let adjacent = [
96+
{key: 1, id: 'a'},
97+
{key: 2, id: 'a'},
98+
{key: 3, id: 'b'}
99+
];
100+
expect(() => render(renderItemsWithIds(adjacent, spyCollection))).toThrow(
101+
'Duplicate key "a" found in collection. Every item in a collection must have a unique key.'
102+
);
103+
104+
let separated = [
105+
{key: 1, id: 'a'},
106+
{key: 2, id: 'x'},
107+
{key: 3, id: 'a'}
108+
];
109+
expect(() => render(renderItemsWithIds(separated, spyCollection))).toThrow(
110+
'Duplicate key "a" found in collection.'
111+
);
112+
});
113+
114+
it('should allow a new item to reuse the key of an item removed in the same render', () => {
115+
let spyCollection = {};
116+
const {rerender} = render(
117+
renderItemsWithIds(
118+
[
119+
{key: 1, id: 'a'},
120+
{key: 2, id: 'b'}
121+
],
122+
spyCollection
123+
)
124+
);
125+
expect([...spyCollection.current.getKeys()]).toEqual(['a', 'b']);
126+
127+
rerender(
128+
renderItemsWithIds(
129+
[
130+
{key: 1, id: 'a'},
131+
{key: 3, id: 'c'},
132+
{key: 4, id: 'b'}
133+
],
134+
spyCollection
135+
)
136+
);
137+
expect([...spyCollection.current.getKeys()]).toEqual(['a', 'c', 'b']);
138+
139+
rerender(renderItemsWithIds([{key: 5, id: 'a'}], spyCollection));
140+
expect([...spyCollection.current.getKeys()]).toEqual(['a']);
141+
});
142+
77143
it('should still support using strings for the collection node class in createLeafComponent/createBranchComponent', () => {
78144
let spyCollection = {};
79145
render(renderItemsOld(['a'], spyCollection));

0 commit comments

Comments
 (0)