fix(memory-graph): stop hit-testing against stale nodes after resize or theme change (#1284)

This commit is contained in:
abhay-codes07 2026-10-12 21:42:25 -07:00 • committed by Mahesh Sanikommu
parent 575ee2eaf5
commit 7bfe0b6be4
4 changed files with 33 additions and 13 deletions

View file

@ -54,6 +54,33 @@ describe("SpatialIndex", () => {
expect(result).toBe(true)
})
it("hash guard keeps stale objects for a new generation at the same positions", () => {
const idx = new SpatialIndex()
const genA = [makeNode("a", 100, 100)]
idx.rebuild(genA)
const genB = [makeNode("a", 100, 100)]
expect(idx.rebuild(genB)).toBe(false)
expect(idx.queryPoint(100, 100)).toBe(genA[0])
})
it("force rebuild swaps the grid to the new node generation", () => {
const idx = new SpatialIndex()
const genA = [makeNode("a", 100, 100)]
idx.rebuild(genA)
const genB = [makeNode("a", 100, 100)]
expect(idx.rebuild(genB, true)).toBe(true)
expect(idx.queryPoint(100, 100)).toBe(genB[0])
})
it("force rebuild still refreshes the hash for subsequent unforced calls", () => {
const idx = new SpatialIndex()
const nodes = [makeNode("a", 100, 100)]
idx.rebuild(nodes, true)
expect(idx.rebuild(nodes)).toBe(false)
})
it("rebuild detects sub-pixel movements (10x granularity)", () => {
const idx = new SpatialIndex()
const nodes = [makeNode("a", 100.0, 100.0)]

View file

@ -5,9 +5,10 @@ export class SpatialIndex {
private cellSize = 200
private lastHash = 0
rebuild(nodes: GraphNode[]): boolean {
// Hash only covers ids/positions, so pass force when the node array identity changes.
rebuild(nodes: GraphNode[], force = false): boolean {
const hash = this.computeHash(nodes)
if (hash === this.lastHash) return false
if (!force && hash === this.lastHash) return false
this.lastHash = hash
this.grid.clear()

View file

@ -97,13 +97,13 @@ export const GraphCanvas = memo<ExtendedGraphCanvasProps>(function GraphCanvas({
simulation,
}
// Rebuild nodeMap + spatial index when nodes change
// Force the spatial rebuild: a new node generation can keep the same ids/positions
useEffect(() => {
const map = nodeMapRef.current
map.clear()
for (const n of nodes) map.set(n.id, n)
inputRef.current?.syncNodes(map)
spatialRef.current.rebuild(nodes)
spatialRef.current.rebuild(nodes, true)
renderNeeded.current = true
}, [nodes])

View file

@ -20,7 +20,6 @@ import type {
GraphApiDocument,
GraphNode,
MemoryNodeData,
GraphThemeColors,
MemoryGraphProps,
ResolvedMemoryGraphLabels,
} from "../types"
@ -85,14 +84,7 @@ export function MemoryGraph({
)
const hoverPopoverZIndex =
layering?.hoverPopoverZIndex ?? DEFAULT_HOVER_POPOVER_Z_INDEX
const resolvedColors = useGraphTheme(colorOverrides)
const colors = useMemo<GraphThemeColors>(
() =>
colorOverrides
? { ...resolvedColors, ...colorOverrides }
: resolvedColors,
[resolvedColors, colorOverrides],
)
const colors = useGraphTheme(colorOverrides)
const [containerSize, setContainerSize] = useState({ width: 0, height: 0 })
const [containerBounds, setContainerBounds] = useState<DOMRect | null>(null)