Skip to content

Commit 87c8816

Browse files
committed
feat: use node ID as element key and fix error in updating algorithm
1 parent b52d315 commit 87c8816

File tree

7 files changed

+176
-71
lines changed

7 files changed

+176
-71
lines changed

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -647,4 +647,8 @@ const Node = ({data: {isLeaf, name}, isOpen, style, setOpen}) => (
647647
<div>{name}</div>
648648
</div>
649649
);
650-
```
650+
```
651+
652+
### 5. Migrate all your IDs to string
653+
654+
Using node IDs as keys should improve React rendering performance. However, it means that you won't be able to use `Symbol` as IDs anymore. You should move all your IDs to be strings instead of symbols.

__tests__/FixedSizeTree.spec.tsx

Lines changed: 35 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,21 @@ describe('FixedSizeTree', () => {
272272
).toEqual(['foo-1', 'foo-2', 'foo-5', 'foo-6', 'foo-7']);
273273
});
274274

275+
it('provides a itemKey function to FixedSizeList', () => {
276+
const itemKey = component.find(FixedSizeList).prop('itemKey');
277+
expect(itemKey).not.toBeUndefined();
278+
279+
expect(component.find(Row).map((node) => node.key())).toEqual([
280+
'foo-1',
281+
'foo-2',
282+
'foo-3',
283+
'foo-4',
284+
'foo-5',
285+
'foo-6',
286+
'foo-7',
287+
]);
288+
});
289+
275290
describe('placeholder', () => {
276291
const testRICTimeout = 16;
277292
let unmockRIC: () => void;
@@ -486,23 +501,34 @@ describe('FixedSizeTree', () => {
486501
},
487502
},
488503
'foo-5': true,
504+
// This action means nothing because "foo-6" is leaf and has no
505+
// children. But it sill will set `isOpen` to true.
489506
'foo-6': true,
490507
});
508+
component.update();
491509

492510
const receivedRecords = extractReceivedRecords(
493511
component.find(FixedSizeList),
494512
);
513+
expect(
514+
receivedRecords.reduce<Record<string, boolean>>(
515+
(acc, {data: {id}, isOpen}) => {
516+
acc[id] = isOpen;
495517

496-
expect(receivedRecords.map(({isOpen}) => isOpen)).toEqual([
497-
true,
498-
false,
499-
false,
500-
false,
518+
return acc;
519+
},
520+
{},
521+
),
522+
).toEqual({
523+
'foo-1': true,
524+
// Since `foo-2` is closed, `foo-3` and `foo-4` do not even appear in
525+
// the list of received records.
526+
'foo-2': false,
501527
// The `foo-5` and `foo-6` nodes are opened manually in recomputeTree
502-
true,
503-
true,
504-
false,
505-
]);
528+
'foo-5': true,
529+
'foo-6': true,
530+
'foo-7': false,
531+
});
506532
});
507533

508534
it('does nothing if opennessState is not an object', async () => {

__tests__/VariableSizeTree.spec.tsx

Lines changed: 35 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,21 @@ describe('VariableSizeTree', () => {
293293
).toEqual(['foo-1', 'foo-2', 'foo-5', 'foo-6', 'foo-7']);
294294
});
295295

296+
it('provides a itemKey function to VariableSizeList', () => {
297+
const itemKey = component.find(VariableSizeList).prop('itemKey');
298+
expect(itemKey).not.toBeUndefined();
299+
300+
expect(component.find(Row).map((node) => node.key())).toEqual([
301+
'foo-1',
302+
'foo-2',
303+
'foo-3',
304+
'foo-4',
305+
'foo-5',
306+
'foo-6',
307+
'foo-7',
308+
]);
309+
});
310+
296311
describe('placeholder', () => {
297312
const testRICTimeout = 16;
298313
let unmockRIC: () => void;
@@ -515,23 +530,34 @@ describe('VariableSizeTree', () => {
515530
},
516531
},
517532
'foo-5': true,
533+
// This action means nothing because "foo-6" is leaf and has no
534+
// children. But it sill will set `isOpen` to true.
518535
'foo-6': true,
519536
});
537+
component.update();
520538

521539
const receivedRecords = extractReceivedRecords(
522540
component.find(VariableSizeList),
523541
);
542+
expect(
543+
receivedRecords.reduce<Record<string, boolean>>(
544+
(acc, {data: {id}, isOpen}) => {
545+
acc[id] = isOpen;
524546

525-
expect(receivedRecords.map(({isOpen}) => isOpen)).toEqual([
526-
true,
527-
false,
528-
false,
529-
false,
547+
return acc;
548+
},
549+
{},
550+
),
551+
).toEqual({
552+
'foo-1': true,
553+
// Since `foo-2` is closed, `foo-3` and `foo-4` do not even appear in
554+
// the list of received records.
555+
'foo-2': false,
530556
// The `foo-5` and `foo-6` nodes are opened manually in recomputeTree
531-
true,
532-
true,
533-
false,
534-
]);
557+
'foo-5': true,
558+
'foo-6': true,
559+
'foo-7': false,
560+
});
535561
});
536562

537563
it('does nothing if opennessState is not an object', async () => {

src/FixedSizeTree.tsx

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import Tree, {
77
TreeState,
88
NodePublicState,
99
} from './Tree';
10-
import {createBasicRecord} from './utils';
10+
import {createBasicRecord, getIdByIndex} from './utils';
1111

1212
export type FixedSizeNodeData = NodeData;
1313

@@ -32,8 +32,8 @@ const computeTree = createTreeComputer<
3232
FixedSizeTreeProps<FixedSizeNodeData>,
3333
FixedSizeTreeState<FixedSizeNodeData>
3434
>({
35-
createRecord(data, {recomputeTree}, parent, previousRecord) {
36-
const record = createBasicRecord(
35+
createRecord: (data, {recomputeTree}, parent, previousRecord) =>
36+
createBasicRecord(
3737
{
3838
data,
3939
isOpen: previousRecord
@@ -45,10 +45,7 @@ const computeTree = createTreeComputer<
4545
}),
4646
},
4747
parent,
48-
);
49-
50-
return record;
51-
},
48+
),
5249
});
5350

5451
export class FixedSizeTree<
@@ -88,6 +85,8 @@ export class FixedSizeTree<
8885
itemCount={order!.length}
8986
itemData={this.getItemData()}
9087
ref={this.list}
88+
// eslint-disable-next-line @typescript-eslint/unbound-method
89+
itemKey={getIdByIndex}
9190
>
9291
{rowComponent!}
9392
</FixedSizeList>

src/Tree.tsx

Lines changed: 76 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
/* eslint-disable react/no-unused-state,@typescript-eslint/consistent-type-assertions,no-labels,max-depth,complexity */
1+
/* eslint-disable no-labels,max-depth,complexity */
22
import React, {
33
Component,
44
ComponentType,
@@ -19,15 +19,13 @@ import {
1919
DefaultTreeState,
2020
noop,
2121
RequestIdleCallbackDeadline,
22-
revisitRecord,
23-
visitRecord,
2422
} from './utils';
2523

2624
export type NodeData = Readonly<{
2725
/**
2826
* Unique ID of the current node.
2927
*/
30-
id: string | symbol;
28+
id: string;
3129

3230
/**
3331
* Default node openness state. If the Tree component performs building a new
@@ -100,7 +98,7 @@ export type OpennessState<
10098
export type TreeProps<
10199
TData extends NodeData,
102100
TNodePublicState extends NodePublicState<TData>
103-
> = Readonly<Omit<ListProps, 'children' | 'itemCount'>> &
101+
> = Readonly<Omit<ListProps, 'children' | 'itemCount' | 'itemKey'>> &
104102
Readonly<{
105103
buildingTaskTimeout?: number;
106104
children: ComponentType<NodeComponentProps<TData, TNodePublicState>>;
@@ -114,7 +112,7 @@ export type TreeState<
114112
TData extends NodeData,
115113
TNodePublicState extends NodePublicState<TData>
116114
> = Readonly<{
117-
order?: Array<string | symbol>;
115+
order?: string[];
118116
computeTree: TreeComputer<any, any, any, any>;
119117
records: ReadonlyMap<string | symbol, NodeRecord<TNodePublicState>>;
120118
recomputeTree: (
@@ -233,7 +231,7 @@ const generateNewTree = <
233231
async && state.records !== undefined;
234232
const {records: previousRecords} = state;
235233

236-
const order: Array<string | symbol> = [];
234+
const order: string[] = [];
237235
const records = new Map<string | symbol, NodeRecord<TNodePublicState>>();
238236
const requestIdleCallbackOptions = buildingTaskTimeout
239237
? {timeout: buildingTaskTimeout}
@@ -299,7 +297,14 @@ const generateNewTree = <
299297
order.push(currentRecord.public.data.id);
300298
}
301299

302-
currentRecord = visitRecord(currentRecord);
300+
currentRecord.visited = currentRecord.child !== null;
301+
302+
currentRecord =
303+
currentRecord.child !== null
304+
? currentRecord.child
305+
: currentRecord.sibling !== null
306+
? currentRecord.sibling
307+
: currentRecord.parent;
303308
}
304309

305310
tempRecord = currentRecord;
@@ -325,7 +330,11 @@ const generateNewTree = <
325330

326331
tempRecord = childRecord;
327332
} else {
328-
currentRecord = revisitRecord(currentRecord);
333+
currentRecord.visited = false;
334+
currentRecord =
335+
currentRecord.sibling !== null
336+
? currentRecord.sibling
337+
: currentRecord.parent;
329338
tempRecord = currentRecord;
330339
}
331340
}
@@ -399,14 +408,40 @@ const updateExistingTree = <
399408
let apply: () => void = noop;
400409

401410
if (ownerRecord.isShown) {
402-
if (open && !ownerRecord.public.isOpen) {
403-
// If received rules require us to open the subtree that is not currently
404-
// open, we have to add new ids to the order list.
411+
if (open) {
412+
// If received rules require us to open the subtree, we have 2 cases:
413+
// 1. The node is not opened yet. In this case we simply have to
414+
// calculate and add new ids.
415+
// 2. The node is opened already. In this case we have to remove all
416+
// existing ids and replace them with new ids.
405417

406418
const index = order!.indexOf(id);
419+
420+
// Here we calculate a count of visible subtree nodes to remove from
421+
// `order`. Then we will replace the gap with the updated list of
422+
// subtree nodes.
423+
let recordNextToSubtree: NodeRecord<
424+
TNodePublicState
425+
> | null = ownerRecord;
426+
427+
while (recordNextToSubtree !== null) {
428+
if (recordNextToSubtree.sibling !== null) {
429+
recordNextToSubtree = recordNextToSubtree.sibling;
430+
break;
431+
}
432+
433+
recordNextToSubtree = recordNextToSubtree.parent;
434+
}
435+
436+
const countToRemove =
437+
recordNextToSubtree === null
438+
? order!.length - 1 - index
439+
: order!.indexOf(recordNextToSubtree.public.data.id) - 1 - index;
440+
407441
const orderParts: Array<Array<number | string | symbol>> = [
408-
[index + 1, 0],
442+
[index + 1, countToRemove],
409443
];
444+
410445
let orderPartsCursor = 0;
411446

412447
// Unfortunately, splice cannot work with big arrays. If array exceeds
@@ -445,7 +480,7 @@ const updateExistingTree = <
445480
order!.splice(...orderParts[i]);
446481
}
447482
};
448-
} else if (!open && ownerRecord.public.isOpen) {
483+
} else if (ownerRecord.public.isOpen) {
449484
// If received rules require us to close the subtree, we have to remove
450485
// all subtree ids from the order list.
451486

@@ -488,9 +523,31 @@ const updateExistingTree = <
488523
update(currentRecord);
489524
}
490525

491-
currentRecord = visitRecord(currentRecord);
526+
currentRecord.visited = currentRecord.child !== null;
527+
528+
// This algorithm is a bit different from the visit algorithm in the
529+
// tree generator. We are restricted with the bounds of a subtree and
530+
// shouldn't go over it. So we cannot search for the ownerRecord's
531+
// parent or sibling because it will lead us out of the subtree.
532+
currentRecord =
533+
// Look for child in any case
534+
currentRecord.child !== null
535+
? currentRecord.child
536+
: // Stop looking for next element if currentRecord is root.
537+
currentRecord === ownerRecord
538+
? null
539+
: // Otherwise, look for sibling or parent
540+
currentRecord.sibling !== null
541+
? currentRecord.sibling
542+
: currentRecord.parent;
492543
} else {
493-
currentRecord = revisitRecord(currentRecord, ownerRecord);
544+
currentRecord.visited = false;
545+
currentRecord =
546+
currentRecord === ownerRecord
547+
? null
548+
: currentRecord.sibling !== null
549+
? currentRecord.sibling
550+
: currentRecord.parent;
494551
}
495552
}
496553

@@ -553,10 +610,12 @@ class Tree<
553610

554611
this.getRecordData = this.getRecordData.bind(this);
555612

613+
/* eslint-disable react/no-unused-state,@typescript-eslint/consistent-type-assertions */
556614
this.state = {
557615
recomputeTree: this.recomputeTree.bind(this),
558616
setState: this.setState.bind(this),
559617
} as TState;
618+
/* eslint-enable react/no-unused-state,@typescript-eslint/consistent-type-assertions */
560619
}
561620

562621
protected getItemData(): TypedListChildComponentData<
@@ -597,7 +656,7 @@ class Tree<
597656
this.list.current?.scrollTo(scrollOffset);
598657
}
599658

600-
public scrollToItem(id: string | symbol, align?: Align): void {
659+
public scrollToItem(id: string, align?: Align): void {
601660
// eslint-disable-next-line react/destructuring-assignment
602661
this.list.current?.scrollToItem(this.state.order!.indexOf(id), align);
603662
}

0 commit comments

Comments
 (0)