Skip to content

Fix canvas render lag: LOD fan-out, unmemoized markdown, prop churn - #319

Merged
dovvnloading merged 1 commit into
mainfrom
fix/canvas-render-perf
Aug 14, 2026
Merged

Fix canvas render lag: LOD fan-out, unmemoized markdown, prop churn#319
dovvnloading merged 1 commit into
mainfrom
fix/canvas-render-perf

Conversation

@dovvnloading

Copy link
Copy Markdown
Owner

Problem

The canvas UI was reported as glitchy/laggy, with nodes visibly fidgeting behind the cursor while dragging. Live measurement on a 63-node scene traced the dominant cost to useLodVisibility: it subscribed to the raw zoom number from React Flow's store rather than the derived LOD boolean, so every node view (~14 kinds) re-rendered on every animation frame of any zoom/pan gesture - not just when the LOD threshold actually crossed. That fan-out repeatedly re-ran NodeMarkdown's full remark/rehype/KaTeX/highlight.js parse pipeline, which was itself unmemoized. Measured cost: ~15ms of main-thread work per wheel step (near the full 16.6ms 60fps budget) on the same scene.

Secondary contributors: SceneCanvas's static <ReactFlow> props (deleteKeyCode, proOptions, defaultEdgeOptions, snapGrid) and event-handler props (onSelectionChange, onEdgeMouseEnter, onEdgeMouseLeave) were fresh literals/closures on every render; the drag-frame smart-guide state update fired unconditionally even when nothing changed; and the controlled-drag round-trip lag let the cursor drift outside a dragged node's true rect between frames, toggling :hover on/off and replaying its border/box-shadow transition as visible flicker.

Change

  • useLodVisibility.ts - selector now returns the boolean directly (s.transform[2] < LOD_ZOOM_THRESHOLD) so subscribers only re-render on an actual LOD flip.
  • NodeMarkdown.tsx - memoized the component; hoisted the static plugin/component config to module scope.
  • SceneCanvas.tsx - hoisted static <ReactFlow> props to stable references, wrapped selection/edge-hover handlers in useCallback, made the smart-guide state update bail out via Object.is when unchanged.
  • styles.css - pins node-card hover chrome to its resting state while .react-flow__node.dragging, so the round-trip lag can't trigger transition flicker.
  • ChatNodeView.test.tsx - NodeMarkdown is now memo(...), an object rather than a directly-callable function, which broke the existing vi.fn(actual.NodeMarkdown) mock (.apply is not a function). Fixed by spying on a plain wrapper that renders the real memoized component via createElement, preserving both the original call-count assertions and genuine markdown rendering in tests.

Test plan

  • npm run check passes end-to-end (schema, typecheck, lint - 0 errors, 1922/1922 tests, build, bundle-size)
  • Live-measured before/after on the same 63-node scene: zoom cost ~15ms median (max 17.7ms) → ~1.4ms median (max 2.4ms) per wheel step
  • Confirmed in-browser: drag/pan/zoom still function correctly, the new hover-suppression rule is live and reachable during an active drag, zero console errors on a fresh load

…own, prop churn

useLodVisibility subscribed to the raw zoom number instead of the derived
LOD boolean, so every node view re-rendered on every animation frame of any
zoom/pan gesture. Verified live: ~15ms of main-thread work per wheel step on
a 63-node scene, most of it this fan-out re-rendering NodeMarkdown's full
remark/rehype/KaTeX/highlight parse pipeline, which was itself unmemoized.

Also stabilizes SceneCanvas's static <ReactFlow> props and the
onSelectionChange/onEdgeMouseEnter/onEdgeMouseLeave callbacks so they don't
mint fresh references every render, makes the drag-frame smart-guide state
update bail out when nothing actually changed, and suppresses the node-card
hover transition while dragging (the controlled-drag round-trip lag was
toggling :hover on/off between frames, reading as flicker).

Re-measured on the same 63-node scene after the fix: zoom cost dropped to
~1.4ms median (max 2.4ms) per wheel step, roughly a 10x reduction.

ChatNodeView.test.tsx's NodeMarkdown mock is updated to match: memo() wraps
NodeMarkdown in an object that isn't directly callable, so the vi.fn() spy
now wraps a plain function that renders the real memoized component via
createElement, preserving both the original call-count assertions and
genuine markdown rendering in tests.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@dovvnloading
dovvnloading merged commit cbdb6b7 into main Aug 14, 2026
4 checks passed
@dovvnloading
dovvnloading deleted the fix/canvas-render-perf branch August 14, 2026 13:09
dovvnloading added a commit that referenced this pull request Aug 14, 2026
These three changes were attempts at the drag artifact that were not
confirmed to work before shipping, and the most recent one made things
worse: with #324 in place the connection lag became visible in the light
theme as well, where it had previously been unnoticeable.

The most likely cause of that regression is the composition flag. The
audit behind #324 established that the flag added in #323 never reached
the browser, because the window library passes a conflicting switch and
duplicate switches resolve last-one-wins. #324 corrected the delivery,
so disabling delegated composition took effect for the first time - and
it applies to both themes equally, which matches the report exactly. The
stroke width increase and the explicit high-quality smoothing hint in
the same change also affect both themes and cannot be ruled out.

This restores graphlink_desktop.py, styles.css and gl-vars-dev.css to
their state as of #321. The measured performance fixes in #319, #320 and
#321 are untouched.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
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