Skip to content

Fix node positions drifting on reload (snapshot-authoritative layout)#38

Merged
mvalancy merged 1 commit into
developfrom
fix/node-position-persistence
Jun 13, 2026
Merged

Fix node positions drifting on reload (snapshot-authoritative layout)#38
mvalancy merged 1 commit into
developfrom
fix/node-position-persistence

Conversation

@mvalancy

Copy link
Copy Markdown
Member

The bug

Arrange a graph neatly → reload → it relaxes into a different shape every time. Reported by the user; root cause confirmed by instrumenting live nodes (fx/fy were always null even for saved nodes).

Root cause (three compounding issues)

  1. initializeVisualization ran node.fx = node.fy = null on every node on every init — the force sim always re-flowed from scratch, ignoring saved positions.
  2. mergeSimulationNodes strips physics keys (correct — preserves live velocity for the edge-follow fix), so placed nodes arriving via graph-switch / 2s poll came back unpinned and drifted too.
  3. Positions saved only on drag-end; a physics-laid-out graph the user never dragged was never persisted.

Fix — snapshot-authoritative (per decision)

  • Placed nodes (saved position ≠ default) load pinned (fx/fy = saved); only new/unplaced nodes flow.
  • Pins re-asserted after every merge.
  • persistAllPositions() batch-saves moved nodes on settle (on('end')) and on pagehide/visibilitychange → physics-arranged and grown layouts become durable.
  • Reset Layout remains the explicit re-flow escape.

Verified

Arrange + reload drift 536px → 0px. New smoke-gate test layout persistence: an arranged node survives a reload (≤25px tolerance) locks it in. web 91 unit · lint 0 errors · typecheck clean · smoke 4/4.

Part of the deeper plan in docs/design/spatial-stability-and-reporting.md (physics tuning + unified video report are the next slices).

🤖 Generated with Claude Code

The bug the user reported: arrange a graph neatly, reload, and it
'relaxes' into a different shape every time. Root cause found by
instrumenting live nodes (fx/fy were always null even for saved nodes):

1. initializeVisualization unconditionally ran node.fx = node.fy = null
   on EVERY node on EVERY init ('like Reset Layout button') — so the
   force sim always re-flowed from scratch, ignoring saved positions.
2. mergeSimulationNodes strips physics keys (correct, to preserve live
   velocity for the edge-follow fix) — so placed nodes arriving via a
   graph-switch or 2s poll came back unpinned and drifted too.
3. Positions were saved only on drag-end; a physics-laid-out graph the
   user never dragged was never persisted.

Fix (snapshot-authoritative, per decision):
- On init, a node whose saved position != (0,0) loads PINNED (fx/fy =
  saved), so the sim cannot move it; only new/unplaced nodes flow.
- Re-assert pins after every merge (graph-switch/poll).
- persistAllPositions(): batch-save any node moved >1px from its saved
  position, fired when the sim settles (on('end')) and on pagehide/
  visibilitychange — so physics-arranged and grown nodes become durable.
- Reset Layout stays the explicit re-flow escape: clears saved-position
  intent, suspends pinning while it rearranges, persists the new layout.

Verified: arrange + reload drift went 536px → 0px. New smoke gate test
'layout persistence: an arranged node survives a reload' (≤25px) locks
it in. web 91 unit, lint 0 errors, typecheck clean, smoke 4/4.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

🧪 Comprehensive Test Suite

  • Unit suites (Node 18.x & 20.x) — core, web, server, mcp-server: ✅ passed
  • Installer & deploy config: ✅ passed

Full-stack smoke gate runs in the CI workflow.

@mvalancy mvalancy merged commit 902177e into develop Jun 13, 2026
14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant