Skip to content

Commit 3f58fe3

Browse files
authored
perf(react-tree): avoid processing hidden subtrees (#35198)
1 parent b7985a2 commit 3f58fe3

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
@@ -31,6 +31,7 @@ export function createNextFlatCheckedItems(
3131
if (data.selectionMode === 'single') {
3232
return ImmutableMap.from([[data.value, data.checked]]);
3333
}
34+
3435
const treeItem = headlessTree.get(data.value);
3536
if (!treeItem) {
3637
if (process.env.NODE_ENV !== 'production') {
@@ -42,34 +43,50 @@ export function createNextFlatCheckedItems(
4243
}
4344
return previousCheckedItems;
4445
}
45-
let nextCheckedItems = previousCheckedItems;
46+
47+
// Calling `ImmutableMap.set()` creates a new ImmutableMap - avoid this in loops.
48+
// Instead write all updates to a native Map and create a new ImmutableMap at the end.
49+
// Note that all descendants of the toggled item are processed even if they are collapsed,
50+
// making the choice of algorithm more important.
51+
52+
const nextCheckedItemsMap = new Map(ImmutableMap.dangerouslyGetInternalMap(previousCheckedItems));
53+
54+
// The toggled item itself
55+
nextCheckedItemsMap.set(data.value, data.checked);
56+
57+
// Descendant updates
4658
for (const children of headlessTree.subtree(data.value)) {
47-
nextCheckedItems = nextCheckedItems.set(children.value, data.checked);
59+
nextCheckedItemsMap.set(children.value, data.checked);
4860
}
49-
nextCheckedItems = nextCheckedItems.set(data.value, data.checked);
5061

62+
// Ancestor updates - must be done after adding descendants and the toggle item.
63+
// If any ancestor is mixed, all ancestors above it are mixed too.
5164
let isAncestorsMixed = false;
52-
for (const parent of headlessTree.ancestors(treeItem.value)) {
53-
// if one parent is mixed, all ancestors are mixed
65+
66+
for (const ancestor of headlessTree.ancestors(treeItem.value)) {
5467
if (isAncestorsMixed) {
55-
nextCheckedItems = nextCheckedItems.set(parent.value, 'mixed');
68+
nextCheckedItemsMap.set(ancestor.value, 'mixed');
5669
continue;
5770
}
58-
let checkedChildrenAmount = 0;
59-
for (const child of headlessTree.children(parent.value)) {
60-
if ((nextCheckedItems.get(child.value) || false) === data.checked) {
61-
checkedChildrenAmount++;
71+
72+
// For each ancestor, if all of its children now have the same checked state as the toggled item,
73+
// set the ancestor to that checked state too. Otherwise it is 'mixed'.
74+
let childrenWithSameState = 0;
75+
for (const child of headlessTree.children(ancestor.value)) {
76+
if ((nextCheckedItemsMap.get(child.value) || false) === data.checked) {
77+
childrenWithSameState++;
6278
}
6379
}
64-
// if all children are checked, parent is checked
65-
if (checkedChildrenAmount === parent.childrenValues.length) {
66-
nextCheckedItems = nextCheckedItems.set(parent.value, data.checked);
80+
81+
if (childrenWithSameState === ancestor.childrenValues.length) {
82+
nextCheckedItemsMap.set(ancestor.value, data.checked);
6783
} else {
68-
// if one parent is mixed, all ancestors are mixed
84+
nextCheckedItemsMap.set(ancestor.value, 'mixed');
6985
isAncestorsMixed = true;
70-
nextCheckedItems = nextCheckedItems.set(parent.value, 'mixed');
7186
}
7287
}
88+
89+
const nextCheckedItems = ImmutableMap.from(nextCheckedItemsMap);
7390
return nextCheckedItems;
7491
}
7592

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)