canvas/docs/plan-react19-canvas-optimization.md

214 lines
6.9 KiB
Markdown
Raw Permalink Normal View History

2026-03-11 18:42:08 -07:00
# 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:**
```tsx
const getMinimapTransform = useCallback(() => { ... }, [graphBounds, width, height]);
const minimapToWorld = useCallback((mx, my) => { ... }, [getMinimapTransform]);
const panToWorld = useCallback((worldX, worldY) => { ... }, [zoom, viewportRect, setPan]);
```
**After:**
```tsx
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:**
```tsx
const adapter = useMemo(() => {
if ('adapter' in props) return props.adapter;
// ... legacy Supabase path
}, ['adapter' in props ? props.adapter : ...]);
```
**After:**
```tsx
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:
```tsx
const adapter = 'adapter' in props
? props.adapter
: createLegacyAdapter(props.supabaseUrl, props.supabaseAnonKey, setLegacyClient);
```
### 1c. `hooks/useSplitGesture.ts` — Remove 3 `useCallback` wrappers
**Before:**
```tsx
const onPointerDown = useCallback((e: React.PointerEvent) => { ... }, []);
const onPointerMove = useCallback((e: React.PointerEvent) => { ... }, [nodeId, splitNode, screenToWorld]);
const onPointerUp = useCallback((e: React.PointerEvent) => { ... }, []);
```
**After:**
```tsx
const onPointerDown = (e: React.PointerEvent) => { ... };
const onPointerMove = (e: React.PointerEvent) => { ... };
const onPointerUp = (e: React.PointerEvent) => { ... };
```
### Verification
After each file change:
```bash
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.tsx` — `useCommandContext`
Current:
```tsx
export function useCommandContext(): CommandContextValue {
const context = useContext(CommandContextContext);
if (!context) throw new Error('...');
return context;
}
```
React 19:
```tsx
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:
```tsx
useEffect(() => {
const el = someRef.current;
if (!el) return;
const observer = new ResizeObserver(...);
observer.observe(el);
return () => observer.disconnect();
}, []);
```
These can become:
```tsx
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
```bash
# 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
```bash
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 |