You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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
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.
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.
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.
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.
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.
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.
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.
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.
As a user of the Schema View, I want it to benefit from the same efficient rendering, so that large schemas render smoothly too.
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.
As a developer, I want "Canvas" to be the consistent term for on-graph entities, so that the code vocabulary matches the domain.
As a developer, I want display concerns separated from structural identity, so that changing how something looks never forces a graph rebuild.
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.
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.
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.
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
Implementation Decisions
{ id, vertexId, type }; Canvas Edge:{ id, source, target, edgeId, type }— sourced directly from the domainVertex/Edge, not fromDisplayVertex/DisplayEdge.typeis retained because Styles are selector-driven (node[type="X"]).Vertex/Edgereferences; a reusable reference-keyed index maps stable source references to stable Canvas element references, evicting absent keys for free.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.{ addedNodeIds, structureRevision }. Layout consumesaddedNodeIds(only genuinely-new vertices); visibility, lock, blast-radius, and connections-filter depend on thestructureRevisiontoken. The phantom counter prop on the selection hook is deleted.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.renderedEntities.test.tsalready 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.renderedEntities.test.ts,useGraphStyles.test.tsx,useGraphSelection.test.ts.Out of Scope
Shrinking the
GraphPropsinterface, 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 atdocs/plans/graph-canvas-reconciler.md.Related Issues
Important
Internal only — this issue is maintained by the core team and is not accepting external contributions.