perf(canvas): memoize collapse visibility filter + add tests

Extract getVisibleNodeIds/filterVisibleEdges from CanvasContainer into
src/utils/collapseFilter.ts. Replace inline O(n^2) BFS (nested array
.find per node) with O(n) traversal backed by parentId->children and
id->node Maps, and wrap consumer calls in useMemo so visibility is
recomputed only when nodes/edges change rather than on every render.

Add 12 unit tests covering the filter logic that the original PR left
untested: single-level collapse, multi-level subtree hiding via
collapsed ancestor, sibling isolation when an inner zone is collapsed,
empty zones, missing custom_colors, insertion-order independence, and
edge filtering for hidden source/target.
This commit is contained in:
Pouzor
2026-05-29 01:09:46 +02:00
parent 69aa8256f0
commit 517486ff79
3 changed files with 188 additions and 19 deletions
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import {
ReactFlow,
Background,
@@ -17,6 +17,7 @@ import '@xyflow/react/dist/style.css'
import { useCanvasStore } from '@/stores/canvasStore'
import { useThemeStore } from '@/stores/themeStore'
import { THEMES } from '@/utils/themes'
import { getVisibleNodeIds, filterVisibleEdges } from '@/utils/collapseFilter'
import { nodeTypes } from './nodes/nodeTypes'
import { edgeTypes } from './edges/edgeTypes'
import { SearchBar } from './SearchBar'
@@ -55,24 +56,16 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
const activeTheme = useThemeStore((s) => s.activeTheme)
const theme = THEMES[activeTheme]
// Filter nodes and edges based on collapsed state
const getVisibleNodeIds = (): Set<string> => {
const visible = new Set<string>()
const queue = nodes.filter((n) => !n.parentId).map((n) => n.id)
while (queue.length > 0) {
const id = queue.shift()!
visible.add(id)
const node = nodes.find((n) => n.id === id)
if (node && !node.data.custom_colors?.collapsed) {
const children = nodes.filter((n) => n.parentId === id).map((n) => n.id)
queue.push(...children)
}
}
return visible
}
const visibleNodeIds = getVisibleNodeIds()
const visibleNodes = nodes.filter((n) => visibleNodeIds.has(n.id))
const visibleEdges = edges.filter((e) => visibleNodeIds.has(e.source) && visibleNodeIds.has(e.target))
// Filter nodes and edges based on collapsed state (memoized — O(n)).
const visibleNodeIds = useMemo(() => getVisibleNodeIds(nodes), [nodes])
const visibleNodes = useMemo(
() => nodes.filter((n) => visibleNodeIds.has(n.id)),
[nodes, visibleNodeIds],
)
const visibleEdges = useMemo(
() => filterVisibleEdges(edges, visibleNodeIds),
[edges, visibleNodeIds],
)
const onNodeClick = useCallback((e: React.MouseEvent, node: Node<NodeData>) => {
if (e.ctrlKey || e.metaKey) {
@@ -0,0 +1,120 @@
import { describe, it, expect } from 'vitest'
import type { Edge, Node } from '@xyflow/react'
import { getVisibleNodeIds, filterVisibleEdges } from '../collapseFilter'
import type { EdgeData, NodeData } from '@/types'
const mkNode = (
id: string,
parentId?: string,
collapsed?: boolean,
): Node<NodeData> => ({
id,
position: { x: 0, y: 0 },
...(parentId ? { parentId } : {}),
data: {
label: id,
type: parentId ? 'server' : 'groupRect',
status: 'online',
services: [],
...(collapsed !== undefined ? { custom_colors: { collapsed } } : {}),
},
})
const mkEdge = (id: string, source: string, target: string): Edge<EdgeData> => ({
id,
source,
target,
})
describe('getVisibleNodeIds', () => {
it('returns all nodes when nothing is collapsed', () => {
const nodes = [
mkNode('zone'),
mkNode('child-a', 'zone'),
mkNode('child-b', 'zone'),
]
expect(getVisibleNodeIds(nodes)).toEqual(new Set(['zone', 'child-a', 'child-b']))
})
it('hides direct children of a collapsed zone but keeps the zone itself', () => {
const nodes = [
mkNode('zone', undefined, true),
mkNode('child-a', 'zone'),
mkNode('child-b', 'zone'),
mkNode('outside'),
]
expect(getVisibleNodeIds(nodes)).toEqual(new Set(['zone', 'outside']))
})
it('hides the entire subtree when an ancestor is collapsed (multi-level)', () => {
const nodes = [
mkNode('root', undefined, true),
mkNode('mid', 'root', false), // expanded but parent collapsed → still hidden
mkNode('leaf', 'mid'),
]
const visible = getVisibleNodeIds(nodes)
expect(visible.has('root')).toBe(true)
expect(visible.has('mid')).toBe(false)
expect(visible.has('leaf')).toBe(false)
})
it('hides only the nested subtree when an inner zone is collapsed', () => {
const nodes = [
mkNode('root', undefined, false),
mkNode('inner', 'root', true),
mkNode('leaf', 'inner'),
mkNode('sibling', 'root'),
]
expect(getVisibleNodeIds(nodes)).toEqual(new Set(['root', 'inner', 'sibling']))
})
it('handles a zone with no children', () => {
const nodes = [mkNode('empty-zone', undefined, true)]
expect(getVisibleNodeIds(nodes)).toEqual(new Set(['empty-zone']))
})
it('returns an empty set for empty input', () => {
expect(getVisibleNodeIds([])).toEqual(new Set())
})
it('treats nodes with no custom_colors as expanded', () => {
const nodes = [mkNode('zone'), mkNode('child', 'zone')]
expect(getVisibleNodeIds(nodes)).toEqual(new Set(['zone', 'child']))
})
it('is independent of insertion order (children declared before parent)', () => {
const nodes = [
mkNode('child', 'zone'),
mkNode('zone', undefined, true),
]
expect(getVisibleNodeIds(nodes)).toEqual(new Set(['zone']))
})
})
describe('filterVisibleEdges', () => {
it('keeps edges whose endpoints are both visible', () => {
const visible = new Set(['a', 'b'])
const edges = [mkEdge('e1', 'a', 'b')]
expect(filterVisibleEdges(edges, visible)).toHaveLength(1)
})
it('drops an edge whose target is inside a collapsed subtree', () => {
const visible = new Set(['outside', 'zone']) // 'leaf' hidden
const edges = [
mkEdge('e-outside-leaf', 'outside', 'leaf'),
mkEdge('e-outside-zone', 'outside', 'zone'),
]
const out = filterVisibleEdges(edges, visible)
expect(out.map((e) => e.id)).toEqual(['e-outside-zone'])
})
it('drops an edge whose source is hidden', () => {
const visible = new Set(['b'])
const edges = [mkEdge('e1', 'a', 'b')]
expect(filterVisibleEdges(edges, visible)).toHaveLength(0)
})
it('returns an empty array for empty input', () => {
expect(filterVisibleEdges([], new Set(['a']))).toEqual([])
})
})
+56
View File
@@ -0,0 +1,56 @@
import type { Edge, Node } from '@xyflow/react'
import type { EdgeData, NodeData } from '@/types'
/**
* Compute the set of node IDs that should be visible on the canvas given the
* current collapse state of group/zone nodes.
*
* A node is hidden if any ancestor (via `parentId`) has
* `data.custom_colors.collapsed === true`. Root nodes (no `parentId`) are
* always visible.
*
* O(n) — builds a `parentId -> children[]` index once, then BFS from roots.
*/
export function getVisibleNodeIds(nodes: Node<NodeData>[]): Set<string> {
const childrenByParent = new Map<string, string[]>()
for (const n of nodes) {
if (n.parentId) {
const arr = childrenByParent.get(n.parentId)
if (arr) arr.push(n.id)
else childrenByParent.set(n.parentId, [n.id])
}
}
// Fast lookup for collapse flag.
const byId = new Map<string, Node<NodeData>>()
for (const n of nodes) byId.set(n.id, n)
const visible = new Set<string>()
const queue: string[] = []
for (const n of nodes) {
if (!n.parentId) queue.push(n.id)
}
while (queue.length > 0) {
const id = queue.shift()!
visible.add(id)
const node = byId.get(id)
if (node && !node.data.custom_colors?.collapsed) {
const children = childrenByParent.get(id)
if (children) queue.push(...children)
}
}
return visible
}
/**
* Filter edges to those whose source and target are both in `visibleIds`.
* Edges crossing into a collapsed subtree are dropped.
*/
export function filterVisibleEdges(
edges: Edge<EdgeData>[],
visibleIds: Set<string>,
): Edge<EdgeData>[] {
return edges.filter((e) => visibleIds.has(e.source) && visibleIds.has(e.target))
}