canvas/docs/plan-react19-canvas-optimization.md
2026-03-11 18:42:08 -07:00

6.9 KiB

React 19 Canvas Optimization Plan

Status: React 19.2.3 with Compiler Active — Phase 1 COMPLETE

The canvas package is running React 19.2.3 with the React Compiler active via babel-plugin-react-compiler + @vitejs/plugin-react. Phase 1 (core gesture/input files) was completed in v3.0.0. This plan documents remaining opportunities.


Phase 1: Remove Redundant Manual Memoization — COMPLETED in v3.0.0

Completed files: useGestureSystem.ts (4 useCallback + 1 useMemo), GestureProvider.tsx (1 useCallback + 1 useMemo), useGestureResolver.ts (1 useCallback), keyboard.ts (1 useMemo)

Remaining files (not yet cleaned):

The React Compiler auto-memoizes all components and hooks. Manual useMemo/useCallback calls are now redundant — they add cognitive overhead and dep-array maintenance burden without benefit.

1a. components/Minimap.tsx — Remove 4 useCallback wrappers

Before:

const getMinimapTransform = useCallback(() => { ... }, [graphBounds, width, height]);
const minimapToWorld = useCallback((mx, my) => { ... }, [getMinimapTransform]);
const panToWorld = useCallback((worldX, worldY) => { ... }, [zoom, viewportRect, setPan]);

After:

const getMinimapTransform = () => { ... };
const minimapToWorld = (mx: number, my: number) => { ... };
const panToWorld = (worldX: number, worldY: number) => { ... };

The compiler tracks graphBounds, width, height, zoom, viewportRect, and setPan as dependencies automatically via its SSA analysis.

1b. db/provider.tsx — Remove useMemo wrapper

Before:

const adapter = useMemo(() => {
  if ('adapter' in props) return props.adapter;
  // ... legacy Supabase path
}, ['adapter' in props ? props.adapter : ...]);

After:

const adapter = (() => {
  if ('adapter' in props) return props.adapter;
  // ... legacy Supabase path
})();

Note: The dependency array here is unusual (contains a string expression). The compiler's approach is strictly better — it tracks the actual object identity of props.adapter rather than a stringified URL.

Alternative (cleaner): Extract to a local variable:

const adapter = 'adapter' in props
  ? props.adapter
  : createLegacyAdapter(props.supabaseUrl, props.supabaseAnonKey, setLegacyClient);

1c. hooks/useSplitGesture.ts — Remove 3 useCallback wrappers

Before:

const onPointerDown = useCallback((e: React.PointerEvent) => { ... }, []);
const onPointerMove = useCallback((e: React.PointerEvent) => { ... }, [nodeId, splitNode, screenToWorld]);
const onPointerUp = useCallback((e: React.PointerEvent) => { ... }, []);

After:

const onPointerDown = (e: React.PointerEvent) => { ... };
const onPointerMove = (e: React.PointerEvent) => { ... };
const onPointerUp = (e: React.PointerEvent) => { ... };

Verification

After each file change:

pnpm --filter @blinksgg/canvas build
pnpm --filter @blinksgg/canvas check-types

Confirm compiler output still has $[ cache slots for these functions (the compiler will still memoize them, just without the manual wrapper).


Phase 2: Add useTransition for Layout Operations SKIPPED

After analyzing useAnimatedLayout.ts, useTransition does not apply to these layout hooks:

  • startTransition only captures synchronous state updates within its callback
  • Layout state updates (updateNodePosition) happen asynchronously via requestAnimationFrame inside animate()
  • The rAF-driven animation pattern already yields to the browser between frames
  • Deprioritizing animation updates via startTransition would make animations janky

useTransition is designed for cases like "update a search filter while keeping input responsive" — not rAF-driven animations that already cooperate with the browser's frame budget.


Phase 3: use(Context) for Conditional Context Reads (Optional, Low Priority)

3a. commands/CommandProvider.tsxuseCommandContext

Current:

export function useCommandContext(): CommandContextValue {
  const context = useContext(CommandContextContext);
  if (!context) throw new Error('...');
  return context;
}

React 19:

import { use } from 'react';

export function useCommandContext(): CommandContextValue {
  const context = use(CommandContextContext);
  if (!context) throw new Error('...');
  return context;
}

Minimal benefit here since these aren't conditional reads. Skip unless doing a broader cleanup pass.


Phase 4: Ref Cleanup Callbacks (Selective, Medium Priority)

React 19 allows ref callbacks to return cleanup functions. CanvasStyleProvider.tsx already uses this pattern. Other candidates:

4a. components/Viewport.tsx (15 refs — audit for cleanup opportunities)

Look for patterns like:

useEffect(() => {
  const el = someRef.current;
  if (!el) return;
  const observer = new ResizeObserver(...);
  observer.observe(el);
  return () => observer.disconnect();
}, []);

These can become:

const refCallback = (node: HTMLElement | null) => {
  if (!node) return;
  const observer = new ResizeObserver(...);
  observer.observe(node);
  return () => observer.disconnect();
};

This is cleaner because the ref and its side effect are co-located, but only worth doing when the useEffect exists solely to set up/tear down something on the DOM element.


Phase 5: Verify Compiler Correctness (Testing)

5a. Compare bundle sizes

# Before changes
pnpm --filter @blinksgg/canvas build
ls -la packages/canvas/dist/index.mjs  # note size

# After changes
pnpm --filter @blinksgg/canvas build
ls -la packages/canvas/dist/index.mjs  # compare

Removing useMemo/useCallback imports should slightly reduce the bundle since fewer React runtime calls are needed.

5b. Run existing tests

pnpm --filter @blinksgg/canvas test

5c. Manual testing

  • Minimap: click/drag panning still works
  • Split gesture: two-finger split on touch device
  • Layout: tree/grid/force layouts complete without blocking UI
  • DB provider: adapter initialization with both adapter prop and legacy Supabase props

Idea Why Skip
Add React.memo() wrapping Compiler handles it better with fine-grained caching
Convert all useContext to use() No conditional reads, marginal benefit
Add Suspense boundaries Jotai atoms handle loading states; canvas is synchronous
Server Components Canvas is inherently client-side (DOM, gestures, canvas 2D)
useDeferredValue for search Search store is already fast with Jotai; defer if perf issue arises

Effort Estimate

Phase Files Complexity Priority
1: Remove manual memo 3 Trivial High
2: Add useTransition - - Skipped (rAF incompatible)
3: use(Context) 3 Trivial Low
4: Ref cleanup callbacks 1-2 Medium Medium
5: Verification 0 Low Required