Reconcile the graph canvas — stop rebuilding on every change
- Dominant language
- TypeScript
- Stars
- 481
- Forks
- 108
- Avg merge
- 8d 9h
- Merged PRs (30d)
- 7
Description
## Problem Statement
When a user expands neighbors or changes anything in a large Session, the Graph View rebuilds its entire Cytoscape element set — deep-cloning every node and edge and handing the whole set back to Cytoscape — even to add a single vertex. At the target of 50k+ elements this produces a full transient duplicate of the element set on every change, driving memory pressure and GC churn that degrades exploration responsiveness.
## Solution
Graph elements carry only structural identity. All display text (node names, neighbor counts, edge labels) resolves at paint time from the source of truth rather than being baked into each element. With volatile state removed, elements stay referentially stable across renders, and a pure reconciler computes a minimal add/remove/update delta applied in a single Cytoscape batch — a one-vertex expand touches one element, not fifty thousand.
## User Stories
1. As a user exploring a large graph, I want expanding one vertex's neighbors to feel instant, so that exploration doesn't stall as my Session grows.
2. As a user with 50k+ elements on the Graph View, I want the app to stay responsive, so that memory pressure doesn't freeze or crash my browser tab.
3. As a user expanding neighbors, I want only the newly added vertices to be positioned by layout, so that the vertices I've already arranged don't jump around.
4. As a user who has manually positioned vertices, I want adding new data to preserve existing positions, so that I don't lose my mental map of the graph.
5. As a user editing display Styles for a Vertex Type or Edge Type, I want labels and colors to update immediately, so that customization feels live.
6. As a user expanding neighbors, I want neighbor-count badges to stay accurate, so that I always know how much of the graph remains unexplored.
7. As a user selecting vertices and edges, I want selection to survive data updates, so that my selection isn't cleared when the graph changes underneath me.
8. As a user applying filters (by type, hidden entities, blast radius, connection count), I want them to keep working after the graph changes, so that my focus view stays consistent.
9. As a user of the Schema View, I want it to benefit from the same efficient rendering, so that large schemas render smoothly too.
10. As a developer, I want the diffing logic to be a pure, directly tested function, so that the most correctness-critical part of the canvas is trustworthy.
11. As a developer, I want "Canvas" to be the consistent term for on-graph entities, so that the code vocabulary matches the domain.
12. As a developer, I want display concerns separated from structural identity, so that changing how something looks never forces a graph rebuild.
13. As a maintainer, I want the reconciler's internals kept off the module's public interface, so that the seam stays small and future consumers aren't coupled to internals.
## Implementation Decisions
- **Canvas Entity model.** Graph elements carry structural identity only — Canvas Vertex: `{ id, vertexId, type }`; Canvas Edge: `{ id, source, target, edgeId, type }` — sourced directly from the domain `Vertex`/`Edge`, not from `DisplayVertex`/`DisplayEdge`. `type` is retained because Styles are selector-driven (`node[type="X"]`).
- **Display text resolves at paint time.** Node badges (name, types, neighbor count) look up display state from the source of truth on the per-frame canvas draw loop. Edge labels move from a data-bound label to a style-function mapper reading a display-text map, re-resolved when Styles change (the stylesheet is already reapplied on config change).
- **Reference stability is inherited from the state atoms.** Adding to the graph preserves unchanged `Vertex`/`Edge` references; a reusable reference-keyed index maps stable source references to stable Canvas element references, evicting absent keys for free.
- **Reconciler = pure diff + thin apply.** A pure `diffElements(prev, next)` returning `{ added, removed, updated }` (no Cytoscape, no React) feeds a thin applier that mutates Cytoscape in one batch. Update is handled as remove + add (kept simple; safe because structural-only nodes never update, and edges have no independent position). Neither function is exported from the module barrel — both are internal seams.
- **Retire the structure-version counter.** The reconciler returns `{ addedNodeIds, structureRevision }`. Layout consumes `addedNodeIds` (only genuinely-new vertices); visibility, lock, blast-radius, and connections-filter depend on the `structureRevision` token. The phantom counter prop on the selection hook is deleted.
- **Filtering split from conversion.** "Which entities are present on the canvas" (reads filter state) becomes distinct from "Vertex → Canvas element shape" (pure), with the reference-stable index between them.
## Testing Decisions
Good tests here exercise external behavior through the highest existing seam, not implementation detail — they assert *what was diffed and applied*, never how the orchestration hooks are wired.
- **`diffElements` (new, pure) — primary seam.** Directly unit-tested: add, remove, update-via-reference-change, unchanged-skip, node-before-edge add ordering, empty↔populated transitions, no-op → empty delta.
- **Reference-stable index (new, pure util).** Directly tested: same source reference → same output reference; changed reference → new output; dropped key → evicted.
- **Canvas entities adapter (existing seam, renamed).** The current `renderedEntities.test.ts` already covers domain→element conversion and id-prefix round-tripping via the canvas-element hooks; it survives the rename and extends to the slimmed structural elements + presence-filtering split.
- **`applyDelta` (new, thin) — only live-Cytoscape seam.** Tested against a headless Cytoscape instance (or a small fake); asserts batch add/remove/re-add and that updates never patch. The orchestration hooks (element update, layout, visibility, lock, filters) are verified through the pure seams, not directly.
- **Prior art:** `renderedEntities.test.ts`, `useGraphStyles.test.tsx`, `useGraphSelection.test.ts`.
## Out of Scope
Shrinking the `GraphProps` interface, replacing the magic data-attribute contract (`__isGroupNode`/`__iconUrl`) with a typed element contract, evicting domain imports from the generic layer, and sealing the raw Cytoscape escape hatch — those are separate deepening candidates (A, C, D). This Epic is the reconciler engine only.
## Further Notes
Root cause: volatile display state — chiefly `neighborCount`, which changes on every neighbor expansion — was baked into graph elements, forcing the domain converter to rebuild every element object (new references) on any change, which defeated reference-based diffing before it could start. Removing display state from element identity is the change that unlocks everything else. A detailed three-slice implementation plan lives at `docs/plans/graph-canvas-reconciler.md`.
## Related Issues
> [!IMPORTANT]
> Internal only — this issue is maintained by the core team and is not accepting external contributions.
Contributor guide
Research direction
Start with docs/plans/graph-canvas-reconciler.md, then inspect renderedEntities.test.ts, useGraphStyles.test.tsx, and useGraphSelection.test.ts. The main seams are the pure diffElements and reference-stable index utilities plus the thin applyDelta Cytoscape boundary. Done means structural elements remain stable, deltas are minimal, existing positions and selection survive updates, and the listed pure and headless-Cytoscape tests pass.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- react, typescript
- Domain
- frontend, performance, testing
- Issue type
- Refactor
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 20/100