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) {