213 lines
6.9 KiB
Markdown
213 lines
6.9 KiB
Markdown
# 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 |
|