Fix slow appearance of nodes on tree view

The tree view rendering performance is optimized by improving the node
render queue ordering. The node rendering order is modified based on the
expansion state and the depth in the hierarchy, leading to faster
rendering of visible nodes. This optimization is applied when the tree
nodes are not expanded to improve the rendering speed.

This new ordering ensures that nodes are rendered more efficiently,
prioritizing nodes that are collapsed and are at a higher level in the
hierarchy.
This commit is contained in:
undergroundwires
2023-09-25 14:21:29 +02:00
parent 8f188acd3c
commit bd2082e8c5
13 changed files with 205 additions and 19 deletions

View File

@@ -51,7 +51,7 @@ import {
} from 'vue'; } from 'vue';
import { TreeRoot } from '../TreeRoot/TreeRoot'; import { TreeRoot } from '../TreeRoot/TreeRoot';
import { useCurrentTreeNodes } from '../UseCurrentTreeNodes'; import { useCurrentTreeNodes } from '../UseCurrentTreeNodes';
import { NodeRenderingStrategy } from '../Rendering/NodeRenderingStrategy'; import { NodeRenderingStrategy } from '../Rendering/Scheduling/NodeRenderingStrategy';
import { useNodeState } from './UseNodeState'; import { useNodeState } from './UseNodeState';
import { TreeNode } from './TreeNode'; import { TreeNode } from './TreeNode';
import LeafTreeNode from './LeafTreeNode.vue'; import LeafTreeNode from './LeafTreeNode.vue';

View File

@@ -0,0 +1,29 @@
import { ReadOnlyTreeNode } from '../../Node/TreeNode';
import { RenderQueueOrderer } from './RenderQueueOrderer';
export class CollapseDepthOrderer implements RenderQueueOrderer {
public orderNodes(nodes: Iterable<ReadOnlyTreeNode>): ReadOnlyTreeNode[] {
return orderNodes(nodes);
}
}
function orderNodes(nodes: Iterable<ReadOnlyTreeNode>): ReadOnlyTreeNode[] {
return [...nodes]
.sort((a, b) => {
const [aCollapseStatus, bCollapseStatus] = [isNodeCollapsed(a), isNodeCollapsed(b)];
if (aCollapseStatus !== bCollapseStatus) {
return (aCollapseStatus ? 1 : 0) - (bCollapseStatus ? 1 : 0);
}
return a.hierarchy.depthInTree - b.hierarchy.depthInTree;
});
}
function isNodeCollapsed(node: ReadOnlyTreeNode): boolean {
if (!node.state.current.isExpanded) {
return true;
}
if (node.hierarchy.parent) {
return isNodeCollapsed(node.hierarchy.parent);
}
return false;
}

View File

@@ -0,0 +1,5 @@
import { ReadOnlyTreeNode } from '../../Node/TreeNode';
export interface RenderQueueOrderer {
orderNodes(nodes: Iterable<ReadOnlyTreeNode>): ReadOnlyTreeNode[];
}

View File

@@ -1,4 +1,4 @@
import { TreeNode } from '../Node/TreeNode'; import { TreeNode } from '../../Node/TreeNode';
export interface NodeRenderingStrategy { export interface NodeRenderingStrategy {
shouldRender(node: TreeNode): boolean; shouldRender(node: TreeNode): boolean;

View File

@@ -1,4 +1,4 @@
import { DelayScheduler } from './DelayScheduler'; import { DelayScheduler } from '../DelayScheduler';
export class TimeoutDelayScheduler implements DelayScheduler { export class TimeoutDelayScheduler implements DelayScheduler {
private timeoutId: ReturnType<typeof setTimeout> | undefined = undefined; private timeoutId: ReturnType<typeof setTimeout> | undefined = undefined;

View File

@@ -1,13 +1,15 @@
import { import {
WatchSource, computed, shallowRef, triggerRef, watch, WatchSource, shallowRef, triggerRef, watch,
} from 'vue'; } from 'vue';
import { ReadOnlyTreeNode } from '../Node/TreeNode'; import { ReadOnlyTreeNode } from '../Node/TreeNode';
import { useNodeStateChangeAggregator } from '../UseNodeStateChangeAggregator'; import { useNodeStateChangeAggregator } from '../UseNodeStateChangeAggregator';
import { TreeRoot } from '../TreeRoot/TreeRoot'; import { TreeRoot } from '../TreeRoot/TreeRoot';
import { useCurrentTreeNodes } from '../UseCurrentTreeNodes'; import { useCurrentTreeNodes } from '../UseCurrentTreeNodes';
import { NodeRenderingStrategy } from './NodeRenderingStrategy'; import { NodeRenderingStrategy } from './Scheduling/NodeRenderingStrategy';
import { DelayScheduler } from './DelayScheduler'; import { DelayScheduler } from './DelayScheduler';
import { TimeoutDelayScheduler } from './TimeoutDelayScheduler'; import { TimeoutDelayScheduler } from './Scheduling/TimeoutDelayScheduler';
import { RenderQueueOrderer } from './Ordering/RenderQueueOrderer';
import { CollapseDepthOrderer } from './Ordering/CollapseDepthOrderer';
/** /**
* Renders tree nodes gradually to prevent UI freeze when loading large amounts of nodes. * Renders tree nodes gradually to prevent UI freeze when loading large amounts of nodes.
@@ -19,6 +21,7 @@ export function useGradualNodeRendering(
scheduler: DelayScheduler = new TimeoutDelayScheduler(), scheduler: DelayScheduler = new TimeoutDelayScheduler(),
initialBatchSize = 30, initialBatchSize = 30,
subsequentBatchSize = 5, subsequentBatchSize = 5,
orderer: RenderQueueOrderer = new CollapseDepthOrderer(),
): NodeRenderingStrategy { ): NodeRenderingStrategy {
const nodesToRender = new Set<ReadOnlyTreeNode>(); const nodesToRender = new Set<ReadOnlyTreeNode>();
const nodesBeingRendered = shallowRef(new Set<ReadOnlyTreeNode>()); const nodesBeingRendered = shallowRef(new Set<ReadOnlyTreeNode>());
@@ -28,8 +31,6 @@ export function useGradualNodeRendering(
const { onNodeStateChange } = useChangeAggregator(treeWatcher); const { onNodeStateChange } = useChangeAggregator(treeWatcher);
const { nodes } = useTreeNodes(treeWatcher); const { nodes } = useTreeNodes(treeWatcher);
const orderedNodes = computed<readonly ReadOnlyTreeNode[]>(() => nodes.value.flattenedNodes);
function updateNodeRenderQueue(node: ReadOnlyTreeNode, isVisible: boolean) { function updateNodeRenderQueue(node: ReadOnlyTreeNode, isVisible: boolean) {
if (isVisible if (isVisible
&& !nodesToRender.has(node) && !nodesToRender.has(node)
@@ -47,14 +48,15 @@ export function useGradualNodeRendering(
} }
} }
watch(() => orderedNodes.value, (newNodes) => { watch(() => nodes.value, (newNodes) => {
nodesToRender.clear(); nodesToRender.clear();
nodesBeingRendered.value.clear(); nodesBeingRendered.value.clear();
if (!newNodes?.length) { if (!newNodes || newNodes.flattenedNodes.length === 0) {
triggerRef(nodesBeingRendered); triggerRef(nodesBeingRendered);
return; return;
} }
newNodes newNodes
.flattenedNodes
.filter((node) => node.state.current.isVisible) .filter((node) => node.state.current.isVisible)
.forEach((node) => nodesToRender.add(node)); .forEach((node) => nodesToRender.add(node));
beginRendering(); beginRendering();
@@ -80,10 +82,8 @@ export function useGradualNodeRendering(
return; return;
} }
isRenderingInProgress = true; isRenderingInProgress = true;
const sortedNodes = Array.from(nodesToRender).sort( const orderedNodes = orderer.orderNodes(nodesToRender);
(a, b) => orderedNodes.value.indexOf(a) - orderedNodes.value.indexOf(b), const currentBatch = orderedNodes.slice(0, batchSize);
);
const currentBatch = sortedNodes.slice(0, batchSize);
if (currentBatch.length === 0) { if (currentBatch.length === 0) {
return; return;
} }

View File

@@ -22,7 +22,7 @@ import {
} from 'vue'; } from 'vue';
import HierarchicalTreeNode from '../Node/HierarchicalTreeNode.vue'; import HierarchicalTreeNode from '../Node/HierarchicalTreeNode.vue';
import { useCurrentTreeNodes } from '../UseCurrentTreeNodes'; import { useCurrentTreeNodes } from '../UseCurrentTreeNodes';
import { NodeRenderingStrategy } from '../Rendering/NodeRenderingStrategy'; import { NodeRenderingStrategy } from '../Rendering/Scheduling/NodeRenderingStrategy';
import { TreeRoot } from './TreeRoot'; import { TreeRoot } from './TreeRoot';
export default defineComponent({ export default defineComponent({

View File

@@ -0,0 +1,93 @@
import { describe, it, expect } from 'vitest';
import { TreeNode } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/TreeNode';
import { CollapseDepthOrderer } from '@/presentation/components/Scripts/View/Tree/TreeView/Rendering/Ordering/CollapseDepthOrderer';
import { HierarchyAccessStub } from '@tests/unit/shared/Stubs/HierarchyAccessStub';
import { TreeNodeStateAccessStub } from '@tests/unit/shared/Stubs/TreeNodeStateAccessStub';
import { TreeNodeStateDescriptorStub } from '@tests/unit/shared/Stubs/TreeNodeStateDescriptorStub';
import { TreeNodeStub } from '@tests/unit/shared/Stubs/TreeNodeStub';
describe('CollapseDepthOrderer', () => {
describe('orderNodes', () => {
it('should order by collapsed state and then by depth in', () => {
// arrange
const node1 = createNodeForOrder({
isExpanded: false,
depthInTree: 1,
});
const node2 = createNodeForOrder({
isExpanded: true,
depthInTree: 2,
});
const node3 = createNodeForOrder({
isExpanded: false,
depthInTree: 3,
});
const node4 = createNodeForOrder({
isExpanded: false,
depthInTree: 4,
});
const nodes = [node1, node2, node3, node4];
const expectedOrder = [node2, node1, node3, node4];
// act
const orderer = new CollapseDepthOrderer();
const orderedNodes = orderer.orderNodes(nodes);
// assert
expect(orderedNodes.map((node) => node.id)).to.deep
.equal(expectedOrder.map((node) => node.id));
});
it('should handle parent collapsed state', () => {
// arrange
const collapsedParent = createNodeForOrder({
isExpanded: false,
depthInTree: 0,
});
const childWithCollapsedParent = createNodeForOrder({
isExpanded: true,
depthInTree: 1,
parent: collapsedParent,
});
const deepExpandedNode = createNodeForOrder({
isExpanded: true,
depthInTree: 3,
});
const nodes = [childWithCollapsedParent, collapsedParent, deepExpandedNode];
const expectedOrder = [
deepExpandedNode, // comes first due to collapse parent of child
collapsedParent,
childWithCollapsedParent,
];
// act
const orderer = new CollapseDepthOrderer();
const orderedNodes = orderer.orderNodes(nodes);
// assert
expect(orderedNodes.map((node) => node.id)).to.deep
.equal(expectedOrder.map((node) => node.id));
});
});
});
function createNodeForOrder(options: {
readonly isExpanded: boolean;
readonly depthInTree: number;
readonly parent?: TreeNode;
}): TreeNode {
return new TreeNodeStub()
.withId([
`isExpanded: ${options.isExpanded}`,
`depthInTree: ${options.depthInTree}`,
...(options.parent ? [`parent: ${options.parent.id}`] : []),
].join(', '))
.withState(
new TreeNodeStateAccessStub()
.withCurrent(
new TreeNodeStateDescriptorStub()
.withVisibility(true)
.withExpansion(options.isExpanded),
),
)
.withHierarchy(
new HierarchyAccessStub()
.withDepthInTree(options.depthInTree)
.withParent(options.parent),
);
}

View File

@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { TimeFunctions, TimeoutDelayScheduler } from '@/presentation/components/Scripts/View/Tree/TreeView/Rendering/TimeoutDelayScheduler'; import { TimeFunctions, TimeoutDelayScheduler } from '@/presentation/components/Scripts/View/Tree/TreeView/Rendering/Scheduling/TimeoutDelayScheduler';
import { StubWithObservableMethodCalls } from '@tests/unit/shared/Stubs/StubWithObservableMethodCalls'; import { StubWithObservableMethodCalls } from '@tests/unit/shared/Stubs/StubWithObservableMethodCalls';
describe('TimeoutDelayScheduler', () => { describe('TimeoutDelayScheduler', () => {

View File

@@ -12,7 +12,9 @@ import { NodeStateChangeEventArgsStub } from '@tests/unit/shared/Stubs/NodeState
import { TreeNodeStateDescriptorStub } from '@tests/unit/shared/Stubs/TreeNodeStateDescriptorStub'; import { TreeNodeStateDescriptorStub } from '@tests/unit/shared/Stubs/TreeNodeStateDescriptorStub';
import { DelaySchedulerStub } from '@tests/unit/shared/Stubs/DelaySchedulerStub'; import { DelaySchedulerStub } from '@tests/unit/shared/Stubs/DelaySchedulerStub';
import { DelayScheduler } from '@/presentation/components/Scripts/View/Tree/TreeView/Rendering/DelayScheduler'; import { DelayScheduler } from '@/presentation/components/Scripts/View/Tree/TreeView/Rendering/DelayScheduler';
import { TreeNode } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/TreeNode'; import { ReadOnlyTreeNode, TreeNode } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/TreeNode';
import { RenderQueueOrdererStub } from '@tests/unit/shared/Stubs/RenderQueueOrdererStub';
import { RenderQueueOrderer } from '@/presentation/components/Scripts/View/Tree/TreeView/Rendering/Ordering/RenderQueueOrderer';
describe('useGradualNodeRendering', () => { describe('useGradualNodeRendering', () => {
it('watches nodes on specified tree', () => { it('watches nodes on specified tree', () => {
@@ -194,6 +196,42 @@ describe('useGradualNodeRendering', () => {
}); });
}); });
}); });
it('orders nodes before rendering', async () => {
// arrange
const delaySchedulerStub = new DelaySchedulerStub();
const allNodes = Array.from({ length: 3 }).map(() => createNodeWithVisibility(true));
const expectedNodes = [
/* initial render */ [allNodes[2]],
/* first subsequent render */ [allNodes[1]],
/* second subsequent render */ [allNodes[0]],
];
const ordererStub = new RenderQueueOrdererStub();
const nodesStub = new UseCurrentTreeNodesStub().withQueryableNodes(
new QueryableNodesStub().withFlattenedNodes(allNodes),
);
const builder = new UseGradualNodeRenderingBuilder()
.withCurrentTreeNodes(nodesStub)
.withInitialBatchSize(1)
.withSubsequentBatchSize(1)
.withDelayScheduler(delaySchedulerStub)
.withOrderer(ordererStub);
const actualOrder = new Set<ReadOnlyTreeNode>();
// act
ordererStub.orderNodes = () => expectedNodes[0];
const strategy = builder.call();
const updateOrder = () => allNodes
.filter((node) => strategy.shouldRender(node))
.forEach((node) => actualOrder.add(node));
updateOrder();
for (let i = 1; i < expectedNodes.length; i++) {
ordererStub.orderNodes = () => expectedNodes[i];
delaySchedulerStub.runNextScheduled();
updateOrder();
}
// assert
const expectedOrder = expectedNodes.flat();
expect([...actualOrder]).to.deep.equal(expectedOrder);
});
}); });
it('skips scheduling when no nodes to render', () => { it('skips scheduling when no nodes to render', () => {
// arrange // arrange
@@ -241,6 +279,8 @@ class UseGradualNodeRenderingBuilder {
private subsequentBatchSize = 3; private subsequentBatchSize = 3;
private orderer: RenderQueueOrderer = new RenderQueueOrdererStub();
public withChangeAggregator(changeAggregator: UseNodeStateChangeAggregatorStub): this { public withChangeAggregator(changeAggregator: UseNodeStateChangeAggregatorStub): this {
this.changeAggregator = changeAggregator; this.changeAggregator = changeAggregator;
return this; return this;
@@ -271,6 +311,11 @@ class UseGradualNodeRenderingBuilder {
return this; return this;
} }
public withOrderer(orderer: RenderQueueOrderer) {
this.orderer = orderer;
return this;
}
public call(): ReturnType<typeof useGradualNodeRendering> { public call(): ReturnType<typeof useGradualNodeRendering> {
return useGradualNodeRendering( return useGradualNodeRendering(
this.treeWatcher, this.treeWatcher,
@@ -279,6 +324,7 @@ class UseGradualNodeRenderingBuilder {
this.delayScheduler, this.delayScheduler,
this.initialBatchSize, this.initialBatchSize,
this.subsequentBatchSize, this.subsequentBatchSize,
this.orderer,
); );
} }
} }

View File

@@ -2,7 +2,7 @@ import { HierarchyAccess } from '@/presentation/components/Scripts/View/Tree/Tre
import { TreeNode } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/TreeNode'; import { TreeNode } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/TreeNode';
export class HierarchyAccessStub implements HierarchyAccess { export class HierarchyAccessStub implements HierarchyAccess {
public parent: TreeNode = undefined; public parent: TreeNode | undefined = undefined;
public children: readonly TreeNode[] = []; public children: readonly TreeNode[] = [];
@@ -20,7 +20,7 @@ export class HierarchyAccessStub implements HierarchyAccess {
this.children = children; this.children = children;
} }
public withParent(parent: TreeNode): this { public withParent(parent: TreeNode | undefined): this {
this.parent = parent; this.parent = parent;
return this; return this;
} }

View File

@@ -0,0 +1,8 @@
import { ReadOnlyTreeNode } from '@/presentation/components/Scripts/View/Tree/TreeView/Node/TreeNode';
import { RenderQueueOrderer } from '@/presentation/components/Scripts/View/Tree/TreeView/Rendering/Ordering/RenderQueueOrderer';
export class RenderQueueOrdererStub implements RenderQueueOrderer {
public orderNodes(nodes: Iterable<ReadOnlyTreeNode>): ReadOnlyTreeNode[] {
return [...nodes];
}
}

View File

@@ -26,4 +26,9 @@ export class TreeNodeStateDescriptorStub implements TreeNodeStateDescriptor {
this.isVisible = isVisible; this.isVisible = isVisible;
return this; return this;
} }
public withExpansion(isExpanded: boolean): this {
this.isExpanded = isExpanded;
return this;
}
} }