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
useTransition for Layout OperationsAfter analyzing useAnimatedLayout.ts, useTransition does not apply to these layout hooks:
startTransitiononly captures synchronous state updates within its callback- Layout state updates (
updateNodePosition) happen asynchronously viarequestAnimationFrameinsideanimate() - The rAF-driven animation pattern already yields to the browser between frames
- Deprioritizing animation updates via
startTransitionwould 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.tsx — useCommandContext
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
Not Recommended (and Why)
| 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 |