This commit is contained in:
Abhay Singh 2026-08-04 19:59:00 -07:00 • committed by GitHub
commit 99e42d1a9d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 54 additions and 13 deletions

View file

@ -54,6 +54,36 @@ describe("SpatialIndex", () => {
expect(result).toBe(true)
})
it("hash guard keeps stale objects for a new generation at the same positions", () => {
// Documents why force exists: a regenerated node array (resize,
// theme change) carries identical ids/positions, so the content
// hash cannot see it.
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,19 @@ export class SpatialIndex {
private cellSize = 200
private lastHash = 0
rebuild(nodes: GraphNode[]): boolean {
/**
* Rebuild the grid from the given nodes.
*
* The content hash only covers node ids and positions, so it cannot
* detect a new generation of node objects at the same coordinates
* (e.g. after useGraphData re-runs on resize or theme change). Callers
* reacting to a node-array identity change must pass force=true, or
* hit-testing keeps returning stale objects the renderer no longer
* draws.
*/
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

@ -94,12 +94,16 @@ export const GraphCanvas = memo<ExtendedGraphCanvasProps>(function GraphCanvas({
simulation,
}
// Rebuild nodeMap + spatial index when nodes change
// Rebuild nodeMap + spatial index when nodes change. Force the spatial
// rebuild: a new node generation can carry identical ids/positions
// (resize, theme change), which the content hash cannot distinguish,
// and stale objects in the grid break dragging — the hit-test returns
// a node the renderer no longer draws.
useEffect(() => {
const map = nodeMapRef.current
map.clear()
for (const n of nodes) map.set(n.id, n)
spatialRef.current.rebuild(nodes)
spatialRef.current.rebuild(nodes, true)
renderNeeded.current = true
}, [nodes])

View file

@ -10,7 +10,6 @@ import { useGraphData } from "../hooks/use-graph-data"
import { useGraphTheme } from "../hooks/use-graph-theme"
import type {
GraphApiDocument,
GraphThemeColors,
MemoryGraphProps,
ResolvedMemoryGraphLabels,
} from "../types"
@ -48,14 +47,12 @@ 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],
)
// useGraphTheme already merges the overrides and keeps the result
// referentially stable while the override values are unchanged.
// Re-merging here keyed on the raw prop identity made `colors` a new
// object on every render for inline `colors={{...}}` consumers, which
// rebuilt the entire node array (useGraphData dependency) each render.
const colors = useGraphTheme(colorOverrides)
const [containerSize, setContainerSize] = useState({ width: 0, height: 0 })
const [containerBounds, setContainerBounds] = useState<DOMRect | null>(null)