Skip to content

Commit b78f97e

Browse files
authored
fix: Improve collection update performance (#7905)
1 parent 1f07140 commit b78f97e

File tree

2 files changed

+74
-14
lines changed

2 files changed

+74
-14
lines changed

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

Lines changed: 43 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ export class BaseNode<T> {
3838
private _previousSibling: ElementNode<T> | null = null;
3939
private _nextSibling: ElementNode<T> | null = null;
4040
private _parentNode: BaseNode<T> | null = null;
41+
private _minInvalidChildIndex: ElementNode<T> | null = null;
4142
ownerDocument: Document<T, any>;
4243

4344
constructor(ownerDocument: Document<T, any>) {
@@ -101,6 +102,21 @@ export class BaseNode<T> {
101102
return this.parentNode?.isConnected || false;
102103
}
103104

105+
private invalidateChildIndices(child: ElementNode<T>) {
106+
if (this._minInvalidChildIndex == null || child.index < this._minInvalidChildIndex.index) {
107+
this._minInvalidChildIndex = child;
108+
}
109+
}
110+
111+
updateChildIndices() {
112+
let node = this._minInvalidChildIndex;
113+
while (node) {
114+
node.index = node.previousSibling ? node.previousSibling.index + 1 : 0;
115+
node = node.nextSibling;
116+
}
117+
this._minInvalidChildIndex = null;
118+
}
119+
104120
appendChild(child: ElementNode<T>) {
105121
this.ownerDocument.startTransaction();
106122
if (child.parentNode) {
@@ -158,11 +174,7 @@ export class BaseNode<T> {
158174
referenceNode.previousSibling = newNode;
159175
newNode.parentNode = referenceNode.parentNode;
160176

161-
let node: ElementNode<T> | null = referenceNode;
162-
while (node) {
163-
node.index++;
164-
node = node.nextSibling;
165-
}
177+
this.invalidateChildIndices(referenceNode);
166178

167179
if (newNode.hasSetProps) {
168180
this.ownerDocument.addNode(newNode);
@@ -178,13 +190,9 @@ export class BaseNode<T> {
178190
}
179191

180192
this.ownerDocument.startTransaction();
181-
let node = child.nextSibling;
182-
while (node) {
183-
node.index--;
184-
node = node.nextSibling;
185-
}
186-
193+
187194
if (child.nextSibling) {
195+
this.invalidateChildIndices(child.nextSibling);
188196
child.nextSibling.previousSibling = child.previousSibling;
189197
}
190198

@@ -330,6 +338,8 @@ export class Document<T, C extends BaseCollection<T> = BaseCollection<T>> extend
330338
private mutatedNodes: Set<ElementNode<T>> = new Set();
331339
private subscriptions: Set<() => void> = new Set();
332340
private transactionCount = 0;
341+
private queuedRender = false;
342+
private inSubscription = false;
333343

334344
constructor(collection: C) {
335345
// @ts-ignore
@@ -412,10 +422,22 @@ export class Document<T, C extends BaseCollection<T> = BaseCollection<T>> extend
412422
}
413423

414424
this.updateCollection();
425+
426+
// Reset queuedRender to false when getCollection is called during render.
427+
if (!this.inSubscription) {
428+
this.queuedRender = false;
429+
}
430+
415431
return this.collection;
416432
}
417433

418434
updateCollection() {
435+
// First, update the indices of dirty element children.
436+
for (let element of this.dirtyNodes) {
437+
element.updateChildIndices();
438+
}
439+
440+
// Next, update dirty collection nodes.
419441
for (let element of this.dirtyNodes) {
420442
if (element instanceof ElementNode && element.isConnected) {
421443
element.updateNode();
@@ -424,6 +446,7 @@ export class Document<T, C extends BaseCollection<T> = BaseCollection<T>> extend
424446

425447
this.dirtyNodes.clear();
426448

449+
// Finally, update the collection.
427450
if (this.mutatedNodes.size || this.collectionMutated) {
428451
let collection = this.getMutableCollection();
429452
for (let element of this.mutatedNodes) {
@@ -442,13 +465,21 @@ export class Document<T, C extends BaseCollection<T> = BaseCollection<T>> extend
442465
queueUpdate() {
443466
// Don't emit any updates if there is a transaction in progress.
444467
// queueUpdate should be called again after the transaction.
445-
if (this.dirtyNodes.size === 0 || this.transactionCount > 0) {
468+
if (this.dirtyNodes.size === 0 || this.transactionCount > 0 || this.queuedRender) {
446469
return;
447470
}
448471

472+
// Only trigger subscriptions once during an update, when the first item changes.
473+
// React's useSyncExternalStore will call getCollection immediately, to check whether the snapshot changed.
474+
// If so, React will queue a render to happen after the current commit to our fake DOM finishes.
475+
// We track whether getCollection is called in a subscription, and once it is called during render,
476+
// we reset queuedRender back to false.
477+
this.queuedRender = true;
478+
this.inSubscription = true;
449479
for (let fn of this.subscriptions) {
450480
fn();
451481
}
482+
this.inSubscription = false;
452483
}
453484

454485
subscribe(fn: () => void) {

packages/react-aria-components/stories/ComboBox.stories.tsx

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,9 @@
1010
* governing permissions and limitations under the License.
1111
*/
1212

13-
import {Button, ComboBox, Input, Label, ListBox, Popover} from 'react-aria-components';
13+
import {Button, ComboBox, Input, Label, ListBox, ListLayout, Popover, useFilter, Virtualizer} from 'react-aria-components';
1414
import {MyListBoxItem} from './utils';
15-
import React from 'react';
15+
import React, {useMemo, useState} from 'react';
1616
import styles from '../example/index.css';
1717
import {useAsyncList} from 'react-stately';
1818

@@ -207,3 +207,32 @@ export const ComboBoxImeExample = () => (
207207
</Popover>
208208
</ComboBox>
209209
);
210+
211+
let manyItems = [...Array(10000)].map((_, i) => ({id: i, name: `Item ${i}`}));
212+
213+
export const VirtualizedComboBox = () => {
214+
const [searchTerm, setSearchTerm] = useState('');
215+
const {contains} = useFilter({sensitivity: 'base'});
216+
const filteredItems = useMemo(() => {
217+
return manyItems.filter((item) => contains(item.name, searchTerm));
218+
}, [searchTerm, contains]);
219+
220+
return (
221+
<ComboBox items={filteredItems} inputValue={searchTerm} onInputChange={setSearchTerm}>
222+
<Label style={{display: 'block'}}>Test</Label>
223+
<div style={{display: 'flex'}}>
224+
<Input />
225+
<Button>
226+
<span aria-hidden="true" style={{padding: '0 2px'}}></span>
227+
</Button>
228+
</div>
229+
<Popover>
230+
<Virtualizer layout={ListLayout} layoutOptions={{rowHeight: 25}}>
231+
<ListBox className={styles.menu}>
232+
{(item: any) => <MyListBoxItem>{item.name}</MyListBoxItem>}
233+
</ListBox>
234+
</Virtualizer>
235+
</Popover>
236+
</ComboBox>
237+
);
238+
};

0 commit comments

Comments
 (0)