Skip to content

Rewrite @supermemory/memory-graph with perf optimizations + consolidate consumers#809

Open
vorflux[bot] wants to merge 18 commits intomainfrom
vorflux/graph-perf-consolidation
Open

Rewrite @supermemory/memory-graph with perf optimizations + consolidate consumers#809
vorflux[bot] wants to merge 18 commits intomainfrom
vorflux/graph-perf-consolidation

Conversation

@vorflux
Copy link
Contributor

@vorflux vorflux bot commented Mar 27, 2026

Summary

Rewrites the @supermemory/memory-graph package with a high-performance canvas engine and consolidates duplicate graph implementations from apps/web into the shared package. Net result: ~10,900 lines deleted, ~3,400 lines added.

Package changes (packages/memory-graph)

Performance optimizations:

  • Spatial grid nearest-neighbor: O(n^2) -> O(n*k) for document-to-document edge rendering via 200px cell bucketing
  • Zoom-based edge culling: skip low-similarity edges at low zoom levels
  • Dirty-flag rAF loop: only re-render when state actually changes

Architecture:

  • Modular canvas engine: renderer.ts, simulation.ts, viewport.ts, hit-test.ts, input-handler.ts, version-chain.ts
  • Remove vanilla-extract CSS -- all inline styles, zero CSS config needed for consumers
  • Add motion/react for slide-in panel animations
  • Export mock data generator (@supermemory/memory-graph/mock-data) for stress testing
  • Export pure utility functions for testability

Bug fixes (from review):

  • Fix slideshow useEffect infinite re-mount (used nodesRef instead of nodes in deps)
  • Fix edges useMemo cascading re-computation (compute allNodeIds from normalizedDocs directly)
  • Fix stale popover position after pan/zoom (add zoomDisplay to deps)
  • Implement maxNodes prop (was declared but unused)
  • Fix SpatialIndex hash collisions (include node IDs, 10x position granularity)
  • Fix simulation prop not triggering re-render: simulationRef mutation didn't cause re-render, so GraphCanvas received undefined on initial render, causing the graph to appear frozen. Fixed by adding useState alongside the ref.
  • Fix version chain UI for root (v1) nodes: getChain() returned null for v1 root nodes, hiding the version chain UI. Added bidirectional traversal (backward to root + forward to latest descendant) with a childrenMap index.
  • Dim superseded memory nodes: Added 50% opacity for isLatest === false memory nodes.

Workspace resolution (source exports):

  • Package exports point to source files (./src/index.tsx) for workspace consumers, so Next.js/Turbopack can resolve without pre-building the package
  • A prepack/postpack script pair (scripts/swap-exports.ts) swaps exports to dist/ during npm publish and restores source exports afterward
  • @supermemory/memory-graph added to transpilePackages in apps/web/next.config.ts
  • This pattern is necessary because Turbopack does not fall through export conditions when dist/ files don't exist

Web app changes (apps/web)

  • Remove local graph implementation (~4,400 lines deleted)
  • Add thin wrapper (memory-graph-wrapper.tsx) using useGraphApi hook
  • Keep app-specific graph-card.tsx and use-graph-api.ts

Playground changes (apps/memory-graph-playground)

  • Add stress test buttons (50/100/200/500 docs)
  • Add mock data generation via package
  • Show FPS counter during stress tests

Testing

Unit Tests (86/86 pass)

cd packages/memory-graph && bun test
  • spatial-index.test.ts (11 tests): grid rebuild, queryPoint hit-testing, boundary cells, hash detection
  • viewport.test.ts (21 tests): worldToScreen/screenToWorld roundtrip, pan, zoom, fitToNodes, centerOn, inertia
  • simulation.test.ts (8 tests): init/destroy lifecycle, update hot-swap, reheat/coolDown
  • version-chain.test.ts (17 tests): chain building via parentMemoryId, bidirectional traversal, caching, rebuild, edge cases (orphaned nodes, cross-document chains, circular references, branching children)
  • graph-data-utils.test.ts (20 tests): normalizeDocCoordinates, getMemoryBorderColor, getEdgeVisualProps, coordinate transforms
  • mock-data.test.ts (9 tests): deterministic seeded output, correct counts, valid edge references

TypeScript Compilation

  • packages/memory-graph: npx tsc --noEmit -- 0 errors
  • apps/web: 0 graph-related errors (pre-existing errors in unrelated files only)
  • apps/memory-graph-playground: npx tsc --noEmit -- 0 errors

Package Build

cd packages/memory-graph && bun run build
  • ESM + CJS output: 175KB / 176KB (32.9KB gzipped)
  • All type declarations generated correctly

UI Stress Test (Playground)

Tested via browser automation at localhost:3000:

Scenario Nodes Edges FPS
50 docs 242 218 60
100 docs 478 428 61
500 docs 2281 2035 54

Screenshots:

Video recording:

CI Status

  • Quality Checks (Type Check, Format & Lint): PASS
  • Workers Builds: supermemory-app: PASS
  • Workers Builds: supermemory-mcp: PASS
  • Claude Code Review: FAIL (bot actor rejection -- repo config issue, not code)

Dependency Ordering

This PR must merge and the package must be published to npm as v0.2.0 before the mono PR #1311 can merge.

Merge order:

  1. Merge this PR
  2. Publish v0.2.0: cd packages/memory-graph && bun publish (prepack script handles export swap automatically)
  3. Merge mono PR #1311

Companion PR

  • supermemoryai/mono#1311: Consumer update for apps/console-v2

Attached Images and Videos

Image available at https://supermemory.us1.vorflux.com/agent-sessions/a09f5c40-bd89-4c89-bbea-1db5bbbaee08

Image available at https://supermemory.us1.vorflux.com/agent-sessions/a09f5c40-bd89-4c89-bbea-1db5bbbaee08

Image available at https://supermemory.us1.vorflux.com/agent-sessions/a09f5c40-bd89-4c89-bbea-1db5bbbaee08

Image available at https://supermemory.us1.vorflux.com/agent-sessions/a09f5c40-bd89-4c89-bbea-1db5bbbaee08

View in Vorflux Session


Session Details

Vorflux AI added 3 commits March 26, 2026 17:58
…date consumers

Package changes (packages/memory-graph):
- Spatial grid nearest-neighbor: O(n^2) -> O(n*k) for doc-doc edge rendering
- Zoom-based edge culling: skip low-similarity edges at low zoom levels
- Modular canvas engine from mono repo (renderer, simulation, viewport, hit-test)
- Remove vanilla-extract CSS, use inline styles only
- Add motion/react for slide-in animations
- Export mock data generator for stress testing
- Add performance and integration tests (15/15 pass)

Web app changes (apps/web):
- Remove local graph implementation (~4400 lines deleted)
- Add thin wrapper (memory-graph-wrapper.tsx) using useGraphApi hook
- Keep app-specific graph-card.tsx and use-graph-api.ts

Playground changes (apps/memory-graph-playground):
- Add stress test buttons (50/100/200/500 docs)
- Add mock data generation via package
- Convert DocumentWithMemories to GraphApiDocument format
- Show FPS counter during stress tests

All TypeScript compiles clean. Performance tests: 15/15 pass.
…xNodes

- Fix slideshow useEffect: use nodesRef instead of nodes in deps to prevent
  interval teardown/recreation on every render
- Fix edges useMemo: compute allNodeIds from normalizedDocs directly instead
  of depending on nodes (which changes identity every render)
- Fix popover position: add zoomDisplay to deps so position updates on pan/zoom
- Implement maxNodes prop: limit documents before passing to useGraphData
- Fix SpatialIndex hash: include node IDs and use 10x position granularity
  to prevent false cache hits during physics simulation
79 tests across 6 test files covering:
- SpatialIndex: grid rebuild, queryPoint hit-testing, boundary cells, hash detection
- ViewportState: worldToScreen/screenToWorld roundtrip, pan, zoomImmediate,
  zoomTo animation, fitToNodes, centerOn, inertia decay
- ForceSimulation: init/destroy lifecycle, update hot-swap, reheat/coolDown
- VersionChainIndex: chain building via parentMemoryId, caching, rebuild
- Graph data utils: normalizeDocCoordinates, getMemoryBorderColor,
  getEdgeVisualProps, screenToBackendCoords, calculateBackendViewport
- Mock data: deterministic seeded output, correct counts, valid edge references

Also exports pure utility functions from use-graph-data.ts for testability.
@entelligence-ai-pr-reviews
Copy link

Automatic Review Skipped

Too many files for automatic review.

If you would still like a review, you can trigger one manually by commenting:

@entelligence review

@graphite-app graphite-app bot requested a review from Dhravya March 27, 2026 00:07
@cloudflare-workers-and-pages
Copy link

cloudflare-workers-and-pages bot commented Mar 27, 2026

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
supermemory-app 8dced3d Commit Preview URL

Branch Preview URL
Mar 27 2026, 06:54 AM

Vorflux AI added 5 commits March 27, 2026 00:12
….POSITIVE_INFINITY, line length formatting
…for npm publish

- Package exports now point to source files (./src/index.tsx) for workspace
  consumers, allowing Next.js/Turbopack to resolve without pre-building
- Added publishConfig swap script that switches exports to dist/ during
  npm publish (prepublishOnly) and back to source after (postpublish)
- Added @supermemory/memory-graph to transpilePackages in web app next.config.ts
  so Next.js transpiles the source files correctly
… swap

prepack/postpack fire for both 'pack' and 'publish', and postpack
always runs after packing regardless of publish success, preventing
the working tree from being left in dist-exports state on failure.
Memory nodes with isLatest=false are now visually dimmed:
- Full-size nodes: 35% opacity with dashed border stroke
- Zoomed-out dots: 35% opacity with thinner stroke
- Selected/hovered superseded nodes render at full opacity
  so users can still inspect them
@@ -33,7 +33,6 @@ export class VersionChainIndex {
const mem = this.memoryMap.get(memoryId)
if (!mem || mem.version <= 1) return null

This comment was marked as outdated.

Vorflux AI added 2 commits March 27, 2026 02:18
- Added childrenMap for forward traversal from parent to children
- getChain now walks backward to root AND forward to latest descendant
- Clicking v1 root node now shows the complete version chain UI
- Standalone v1 nodes (no children) still return null (no chain to show)
- Updated tests to reflect bidirectional behavior + added v1 root test
- Rename tip -> cursor for consistency with backward walk
- Fix misleading test descriptions to reflect bidirectional behavior
- Add test for circular parent references (cycle protection)
- Add test for branching children (first-child-wins behavior)
- Document linear chain assumption in forward walk comment
Comment on lines 548 to 551
)}

{/* Graph container */}
<div className={styles.graphContainer} ref={containerRef}>
<div style={canvasContainerStyle} ref={containerRef}>
{containerSize.width > 0 && containerSize.height > 0 && (
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: GraphCanvas receives an undefined simulation prop on initial render because mutating simulationRef doesn't trigger a re-render, causing the graph to appear frozen.
Severity: HIGH

Suggested Fix

To ensure GraphCanvas receives the simulation object immediately after it's created, trigger a re-render. This can be achieved by moving the simulation object into React state using useState instead of useRef. For example: const [simulation, setSimulation] = useState<ForceSimulation | null>(null); and then call setSimulation(newSimulation); inside the effect.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location: packages/memory-graph/src/components/memory-graph.tsx#L548-L551

Potential issue: The `simulationRef` is initialized to `null` and populated within a
`useEffect` hook. However, mutating a ref does not trigger a component re-render.
Consequently, on its initial render, the `GraphCanvas` component receives
`simulation={undefined}` because `simulationRef.current` is still `null`. The render
loop inside `GraphCanvas` checks `simulation?.isActive()` to determine if it should draw
a new frame. Since the simulation object is initially undefined, this check fails, and
the canvas does not update, making the graph appear frozen while the physics simulation
runs in the background.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. This was a real bug -- mutating simulationRef doesn't trigger a re-render, so GraphCanvas received undefined for the simulation prop on initial render. The render loop's cb.current.simulation?.isActive() ?? false would always be false, causing frames to be skipped even while the simulation was running and mutating node positions.

Fixed in 13eb5e9 by adding a useState for the simulation object alongside the ref. The state triggers a re-render when the simulation is first created, ensuring GraphCanvas receives it. The ref is kept for imperative access in callbacks (reheat/coolDown).

- Use useState for simulation so GraphCanvas receives it after creation
  (ref mutation alone didn't trigger re-render, causing frozen graph)
- Keep simulationRef for imperative access (reheat/coolDown in callbacks)
- Add edge case tests: orphaned non-root memory, cross-document chains,
  circular references with membership assertion, cold-cache middle node
Copy link
Member

@MaheshtheDev MaheshtheDev left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this looks good to me but have to test in prod as well with heavy load

…rgotten X icon, version edge glow

- Drop shadows on selected/hovered nodes (document + memory) for depth
- Subtle gradient fill on document nodes
- Improved dimming contrast for superseded memories (0.3 alpha + strikethrough)
- Hover glow ring with dashed outline for interactivity feedback
- X icon overlay on forgotten memory nodes (size > 14px)
- Glow pass behind version edges for visual emphasis
- lightenColor utility with cache + guard for non-6-digit hex
- Use memData.isForgotten instead of fragile color comparison
- Optimize version edge batch: skip filter, use first.isVersion flag
- Add 10 unit tests for lightenColor (hex parsing, clamping, cache, edge cases)
Comment on lines +459 to +469
if (!activeNodeData || !viewportRef.current) return null
const vp = viewportRef.current
const screen = vp.worldToScreen(activeNodeData.x, activeNodeData.y)
return {
screenX: screen.x,
screenY: screen.y,
nodeRadius: (activeNodeData.size * vp.zoom) / 2,
}
// zoomDisplay triggers re-computation on viewport changes (pan/zoom)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeNodeData, zoomDisplay])
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: The node popover's position does not update during pan operations because its recalculation is incorrectly tied only to zoom changes, causing it to appear detached from the node.
Severity: MEDIUM

Suggested Fix

Modify the onViewportChange callback in graph-canvas.tsx to trigger on pan events in addition to zoom events. This will ensure that any viewport movement causes a state update, forcing the activePopoverPosition useMemo hook to recalculate the popover's position correctly during both pan and zoom actions.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location: packages/memory-graph/src/components/memory-graph.tsx#L458-L469

Potential issue: The `activePopoverPosition` `useMemo` hook, which calculates a node's
popover screen coordinates, only re-evaluates when the `zoomDisplay` state changes. The
`onViewportChange` callback in `graph-canvas.tsx` is responsible for updating this
state, but it is only triggered on zoom events, not on pan events. Consequently, when a
user pans the graph without zooming, the popover's position is not recalculated. This
causes the popover to remain fixed at its old screen coordinates while the associated
node moves, making it appear disconnected from the node it is supposed to be annotating.

Vorflux AI added 2 commits March 27, 2026 05:32
- Change bun:test import to vitest for CI compatibility
- Add viewportVersion counter for popover recalculation during pan
- Add hex color guard in lightenColor for non-6-digit formats
- Optimize version edge filtering with batch property
- Simplify stroke style conditions in drawDocumentNode
Comment on lines +17 to +18
// Module-level reusable batch map – cleared each frame instead of reallocating
const edgeBatches = new Map<string, PreparedEdge[]>()

This comment was marked as outdated.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. viewportVersion is now in the activePopoverPosition dependency array (replacing zoomDisplay), and the onViewportChange callback now fires based on actual viewport state changes (panX/panY/zoom comparison) rather than only during animated transitions (vpMoving). This covers mouse-drag panning, momentum, spring zoom, and simulation-driven node movement.

Vorflux AI added 2 commits March 27, 2026 05:37
- Use viewportVersion (not zoomDisplay) as activePopoverPosition dependency
- Fire onViewportChange based on actual viewport state diff (panX/panY/zoom)
  instead of only during animated transitions (vpMoving)
- Also fire during simulation so popover tracks settling nodes
- Remove dead lastReportedZoom variable
- Remove no-op node ID tracking effect and prevIdsRef
Comment on lines 81 to +91
for (let i = 0; i < docs.length; i++) {
const d = docs[i]!
const cx = Math.floor(d.x / CELL)
const cy = Math.floor(d.y / CELL)
let best1 = -1
let best2 = -1
let dist1 = Number.POSITIVE_INFINITY
let dist2 = Number.POSITIVE_INFINITY

for (let j = 0; j < docs.length; j++) {
if (j === i) continue
const dx = docs[j]!.x - d.x
const dy = docs[j]!.y - d.y
const dist = dx * dx + dy * dy
if (dist < dist1) {
best2 = best1
dist2 = dist1
best1 = j
dist1 = dist
} else if (dist < dist2) {
best2 = j
dist2 = dist
for (let dx = -1; dx <= 1; dx++) {
for (let dy = -1; dy <= 1; dy++) {

This comment was marked as outdated.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is by design. The coordinates are in world space, not screen space -- the force simulation constrains document positions within a bounded area where 200px cells with a 3x3 search (600px effective radius) covers the vast majority of nearest-neighbor pairs. For typical graphs (50-500 docs), documents are always within this radius of their nearest neighbors.

The only edge case would be 2-3 documents spread extremely far apart, where the missing dashed cosmetic line has negligible visual impact. The O(n*k) performance benefit (vs O(n^2)) is significant for larger graphs -- at 500 docs, this avoids 250,000 distance comparisons per frame.

If this becomes a visual issue in practice, we can add a fallback that does a brute-force scan only when best1 === -1 (no neighbors found at all), which would be rare and wouldn't affect the common-case performance.

Comment on lines +443 to +444
nodes.length,
containerSize.width,
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: The slideshow useEffect hook includes containerSize in its dependencies, causing the slideshow interval to reset and jump to a new node on any container resize.
Severity: MEDIUM

Suggested Fix

Create a ref for containerSize, similar to how nodesRef is used. Update this ref with the latest containerSize on each render. Inside the pick function, read the dimensions from containerSizeRef.current. Finally, remove containerSize.width and containerSize.height from the useEffect dependency array to prevent the interval from being reset on resize.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location: packages/memory-graph/src/components/memory-graph.tsx#L443-L444

Potential issue: The `useEffect` hook that manages the slideshow feature incorrectly
includes `containerSize.width` and `containerSize.height` in its dependency array. A
`ResizeObserver` updates these values on any container resize, causing the effect to
re-run. The effect's cleanup function clears the 3.5-second slideshow interval, and the
effect body immediately picks a new node. This interrupts the slideshow's intended
cadence, causing it to abruptly jump to a new node whenever the window is resized,
creating a jarring user experience.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. Fixed in 8dced3d5 -- moved containerSize and onSlideshowNodeChange to refs (containerSizeRef, onSlideshowNodeChangeRef) so the slideshow useEffect reads them via .current instead of closing over the values. The dependency array is now just [isSlideshowActive, nodes.length], so the interval only resets when the slideshow is toggled or the node count changes, not on container resize.

Use refs for containerSize and onSlideshowNodeChange inside the
slideshow useEffect to avoid resetting the 3.5s interval when the
container is resized. The interval now only resets when
isSlideshowActive or nodes.length changes.
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