diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index a71753eaa5d..7de270d6330 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -24,6 +24,7 @@ import { Trash2 } from 'lucide-react' import { useAppStore } from '@/store' +import { createLineageToggleHandlerCache } from './worktree-lineage-toggle-handler-cache' import { useShallow } from 'zustand/react/shallow' import type { AppState } from '@/store/types' import { @@ -2449,6 +2450,12 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp }, [recordCurrentScrollAnchor, toggleGroup] ) + // Why: memo'd WorktreeCard needs a per-group-key stable onLineageToggle + // identity to bail out of re-renders; see worktree-lineage-toggle-handler-cache. + const getLineageToggleHandler = useMemo( + () => createLineageToggleHandlerCache(toggleGroupWithScrollAnchor), + [toggleGroupWithScrollAnchor] + ) const navigateWorktree = useCallback( (direction: 'up' | 'down') => { @@ -4856,11 +4863,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp lineageChildrenStyle={lineageChildrenStyle} onLineageToggle={ lineageToggleGroupKey - ? (event) => { - event.preventDefault() - event.stopPropagation() - toggleGroupWithScrollAnchor(lineageToggleGroupKey) - } + ? getLineageToggleHandler(lineageToggleGroupKey) : undefined } /> diff --git a/src/renderer/src/components/sidebar/worktree-lineage-toggle-handler-cache.test.ts b/src/renderer/src/components/sidebar/worktree-lineage-toggle-handler-cache.test.ts new file mode 100644 index 00000000000..67056009295 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-lineage-toggle-handler-cache.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it, vi } from 'vitest' +import type React from 'react' +import { createLineageToggleHandlerCache } from './worktree-lineage-toggle-handler-cache' + +const makeEvent = (): React.MouseEvent => + ({ + preventDefault: vi.fn(), + stopPropagation: vi.fn() + }) as unknown as React.MouseEvent + +describe('createLineageToggleHandlerCache', () => { + it('returns a referentially stable handler for the same group key across calls', () => { + const getHandler = createLineageToggleHandlerCache(vi.fn()) + + const first = getHandler('lineage:alpha') + const second = getHandler('lineage:alpha') + + // Why: stable identity is what lets React.memo'd WorktreeCard bail out. + expect(second).toBe(first) + }) + + it('returns distinct handlers for distinct group keys', () => { + const getHandler = createLineageToggleHandlerCache(vi.fn()) + + expect(getHandler('lineage:alpha')).not.toBe(getHandler('lineage:beta')) + }) + + it('prevents default, stops propagation, and toggles the bound group key', () => { + const toggleGroup = vi.fn() + const getHandler = createLineageToggleHandlerCache(toggleGroup) + const event = makeEvent() + + getHandler('lineage:alpha')(event) + + expect(event.preventDefault).toHaveBeenCalledTimes(1) + expect(event.stopPropagation).toHaveBeenCalledTimes(1) + expect(toggleGroup).toHaveBeenCalledExactlyOnceWith('lineage:alpha') + }) + + it('keeps each cached handler bound to its own group key', () => { + const toggleGroup = vi.fn() + const getHandler = createLineageToggleHandlerCache(toggleGroup) + + getHandler('lineage:beta')(makeEvent()) + + expect(toggleGroup).toHaveBeenCalledExactlyOnceWith('lineage:beta') + }) +}) diff --git a/src/renderer/src/components/sidebar/worktree-lineage-toggle-handler-cache.ts b/src/renderer/src/components/sidebar/worktree-lineage-toggle-handler-cache.ts new file mode 100644 index 00000000000..de9012f41ae --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-lineage-toggle-handler-cache.ts @@ -0,0 +1,26 @@ +import type React from 'react' + +export type LineageToggleHandler = (event: React.MouseEvent) => void + +// Why: WorktreeCard is React.memo'd; an inline arrow per row would give +// onLineageToggle a fresh identity every render and defeat the memo bail-out +// for every lineage-parent card on each sort/status epoch bump. Cache one +// handler per group key (bounded by lineage-group count) so identity is stable. +export const createLineageToggleHandlerCache = ( + toggleGroup: (groupKey: string) => void +): ((groupKey: string) => LineageToggleHandler) => { + const handlersByGroupKey = new Map() + return (groupKey: string) => { + const cached = handlersByGroupKey.get(groupKey) + if (cached) { + return cached + } + const handler: LineageToggleHandler = (event) => { + event.preventDefault() + event.stopPropagation() + toggleGroup(groupKey) + } + handlersByGroupKey.set(groupKey, handler) + return handler + } +}