Skip to content

Commit 5254266

Browse files
committed
perf(react-tree): don't process hidden subtrees
This PR makes FlatTree support large data sets with good performance as long as most items are not rendered at once, which should be the common case. This is done by optimizing two key functions. The first deals with multiselect state, and simply batches all updates instead of calling `ImmutableMap.set()` in a loop over the entire subtree, which had a big performance impact due to each call copying the entire Map. When toggling the checkbox for a branch with a 2800 item subtree, the delay went from an unusable 1800 ms to 8 ms. The second fix is to avoid processing of invisible subtrees when generating the visible tree based on open items. This one requires a larger tree to be noticeable, but at 35k items, the generator took 600 ms before and 10 ms after the fix. An easy way to test is to modify the story "TreeSelection", changing the `items` array so that it's generated with a loop.
1 parent f4a83d0 commit 5254266

File tree

3 files changed

+70
-19
lines changed

3 files changed

+70
-19
lines changed
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"type": "patch",
3+
"comment": "performance optimization in react-tree",
4+
"packageName": "@fluentui/react-tree",
5+
"email": "maachin@gmail.com",
6+
"dependentChangeType": "patch"
7+
}

packages/react-components/react-tree/library/src/components/FlatTree/useFlatControllableCheckedItems.ts

Lines changed: 32 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export function createNextFlatCheckedItems(
2929
if (data.selectionMode === 'single') {
3030
return ImmutableMap.from([[data.value, data.checked]]);
3131
}
32+
3233
const treeItem = headlessTree.get(data.value);
3334
if (!treeItem) {
3435
if (process.env.NODE_ENV !== 'production') {
@@ -40,34 +41,50 @@ export function createNextFlatCheckedItems(
4041
}
4142
return previousCheckedItems;
4243
}
43-
let nextCheckedItems = previousCheckedItems;
44+
45+
// Calling `ImmutableMap.set()` creates a new ImmutableMap - avoid this in loops.
46+
// Instead write all updates to a native Map and create a new ImmutableMap at the end.
47+
// Note that all descendants of the toggled item are processed even if they are collapsed,
48+
// making the choice of algorithm more important.
49+
50+
const nextCheckedItemsMap = new Map(ImmutableMap.dangerouslyGetInternalMap(previousCheckedItems));
51+
52+
// The toggled item itself
53+
nextCheckedItemsMap.set(data.value, data.checked);
54+
55+
// Descendant updates
4456
for (const children of headlessTree.subtree(data.value)) {
45-
nextCheckedItems = nextCheckedItems.set(children.value, data.checked);
57+
nextCheckedItemsMap.set(children.value, data.checked);
4658
}
47-
nextCheckedItems = nextCheckedItems.set(data.value, data.checked);
4859

60+
// Ancestor updates - must be done after adding descendants and the toggle item.
61+
// If any ancestor is mixed, all ancestors above it are mixed too.
4962
let isAncestorsMixed = false;
50-
for (const parent of headlessTree.ancestors(treeItem.value)) {
51-
// if one parent is mixed, all ancestors are mixed
63+
64+
for (const ancestor of headlessTree.ancestors(treeItem.value)) {
5265
if (isAncestorsMixed) {
53-
nextCheckedItems = nextCheckedItems.set(parent.value, 'mixed');
66+
nextCheckedItemsMap.set(ancestor.value, 'mixed');
5467
continue;
5568
}
56-
let checkedChildrenAmount = 0;
57-
for (const child of headlessTree.children(parent.value)) {
58-
if ((nextCheckedItems.get(child.value) || false) === data.checked) {
59-
checkedChildrenAmount++;
69+
70+
// For each ancestor, if all of its children now have the same checked state as the toggled item,
71+
// set the ancestor to that checked state too. Otherwise it is 'mixed'.
72+
let childrenWithSameState = 0;
73+
for (const child of headlessTree.children(ancestor.value)) {
74+
if ((nextCheckedItemsMap.get(child.value) || false) === data.checked) {
75+
childrenWithSameState++;
6076
}
6177
}
62-
// if all children are checked, parent is checked
63-
if (checkedChildrenAmount === parent.childrenValues.length) {
64-
nextCheckedItems = nextCheckedItems.set(parent.value, data.checked);
78+
79+
if (childrenWithSameState === ancestor.childrenValues.length) {
80+
nextCheckedItemsMap.set(ancestor.value, data.checked);
6581
} else {
66-
// if one parent is mixed, all ancestors are mixed
82+
nextCheckedItemsMap.set(ancestor.value, 'mixed');
6783
isAncestorsMixed = true;
68-
nextCheckedItems = nextCheckedItems.set(parent.value, 'mixed');
6984
}
7085
}
86+
87+
const nextCheckedItems = ImmutableMap.from(nextCheckedItemsMap);
7188
return nextCheckedItems;
7289
}
7390

packages/react-components/react-tree/library/src/utils/createHeadlessTree.ts

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -261,10 +261,37 @@ function* HeadlessTreeVisibleItemsGenerator<Props extends HeadlessTreeItemProps>
261261
virtualTreeItems: HeadlessTree<Props>,
262262
): Generator<HeadlessTreeItem<Props>, void, void> {
263263
let index = 0;
264-
for (const item of HeadlessTreeSubtreeGenerator(virtualTreeItems.root.value, virtualTreeItems)) {
265-
if (isItemVisible(item, openItems, virtualTreeItems)) {
266-
item.index = index++;
267-
yield item;
264+
for (const item of recursiveVisibleItems(virtualTreeItems.root.value, openItems, virtualTreeItems)) {
265+
item.index = index++;
266+
yield item;
267+
}
268+
}
269+
270+
function* recursiveVisibleItems<Props extends HeadlessTreeItemProps>(
271+
parentValue: TreeItemValue,
272+
openItems: ImmutableSet<TreeItemValue>,
273+
virtualTreeItems: HeadlessTree<Props>,
274+
): Generator<HeadlessTreeItem<Props>, void, void> {
275+
const parent = virtualTreeItems.get(parentValue);
276+
if (!parent || parent.childrenValues.length === 0) {
277+
return;
278+
}
279+
280+
for (const childValue of parent.childrenValues) {
281+
const child = virtualTreeItems.get(childValue);
282+
if (!child) {
283+
continue;
284+
}
285+
286+
if (isItemVisible(child, openItems, virtualTreeItems)) {
287+
yield child;
288+
289+
// Process children only as long as their parents are open.
290+
// This makes it possible to have large trees with good performance as
291+
// long as most branches are not expanded.
292+
if (openItems.has(childValue)) {
293+
yield* recursiveVisibleItems(childValue, openItems, virtualTreeItems);
294+
}
268295
}
269296
}
270297
}

0 commit comments

Comments
 (0)