diff --git a/src/renderer/src/components/right-sidebar/useSourceControlSelection.concurrent.test.tsx b/src/renderer/src/components/right-sidebar/useSourceControlSelection.concurrent.test.tsx new file mode 100644 index 00000000000..7b7a17a187c --- /dev/null +++ b/src/renderer/src/components/right-sidebar/useSourceControlSelection.concurrent.test.tsx @@ -0,0 +1,88 @@ +// @vitest-environment happy-dom + +import { act, Suspense, startTransition, useRef, useState } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { GitStatusEntry } from '../../../../shared/types' +import { useSourceControlSelection, type FlatEntry } from './useSourceControlSelection' + +const OLD_ENTRY: FlatEntry = { + key: 'unstaged::old.ts', + area: 'unstaged', + entry: { path: 'old.ts', area: 'unstaged', status: 'modified' } +} + +const NEW_ENTRY: FlatEntry = { + key: 'unstaged::new.ts', + area: 'unstaged', + entry: { path: 'new.ts', area: 'unstaged', status: 'modified' } +} + +describe('useSourceControlSelection committed event state', () => { + let container: HTMLDivElement + let root: Root + + beforeEach(() => { + container = document.createElement('div') + document.body.append(container) + root = createRoot(container) + }) + + afterEach(async () => { + await act(async () => root.unmount()) + container.remove() + }) + + it('keeps the visible row callback while the next render is suspended', async () => { + const openedOld = vi.fn<(entry: GitStatusEntry) => void>() + const openedNew = vi.fn<(entry: GitStatusEntry) => void>() + const neverResolves = new Promise(() => {}) + let showNext!: () => void + + function SelectionHarness({ next }: { next: boolean }): React.JSX.Element { + const containerRef = useRef(null) + const selection = useSourceControlSelection({ + flatEntries: next ? [NEW_ENTRY] : [OLD_ENTRY], + onOpenDiff: next ? openedNew : openedOld, + containerRef + }) + + if (next) { + throw neverResolves + } + + return ( +
+ +
+ ) + } + + function App(): React.JSX.Element { + const [next, setNext] = useState(false) + showNext = () => startTransition(() => setNext(true)) + return ( + Loading next worktree}> + + + ) + } + + await act(async () => root.render()) + await act(async () => { + showNext() + await Promise.resolve() + }) + + const visibleButton = container.querySelector('button') + expect(visibleButton?.textContent).toBe('Open old row') + visibleButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + + expect(openedOld).toHaveBeenCalledWith(OLD_ENTRY.entry) + expect(openedNew).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/right-sidebar/useSourceControlSelection.ts b/src/renderer/src/components/right-sidebar/useSourceControlSelection.ts index e14ee5cdc5d..9a17e3d3cb7 100644 --- a/src/renderer/src/components/right-sidebar/useSourceControlSelection.ts +++ b/src/renderer/src/components/right-sidebar/useSourceControlSelection.ts @@ -1,4 +1,4 @@ -import { useState, useCallback, useEffect, useRef, type RefObject } from 'react' +import { useState, useCallback, useEffect, useLayoutEffect, useRef, type RefObject } from 'react' import type { GitStatusEntry } from '../../../../shared/types' import type { SourceControlRowOpenEvent } from './source-control-split-open' @@ -91,25 +91,15 @@ export function useSourceControlSelection({ const onOpenDiffRef = useRef(onOpenDiff) const shouldOpenAsSplitRef = useRef(shouldOpenAsSplit) - useEffect(() => { + useLayoutEffect(() => { + // Why: stable row handlers must read one committed selection/worktree + // snapshot; render-time writes can leak values from an interrupted render. flatEntriesRef.current = flatEntries - }, [flatEntries]) - - useEffect(() => { anchorKeyRef.current = anchorKey - }, [anchorKey]) - - useEffect(() => { selectedKeysRef.current = selectedKeys - }, [selectedKeys]) - - useEffect(() => { onOpenDiffRef.current = onOpenDiff - }, [onOpenDiff]) - - useEffect(() => { shouldOpenAsSplitRef.current = shouldOpenAsSplit - }, [shouldOpenAsSplit]) + }, [flatEntries, anchorKey, selectedKeys, onOpenDiff, shouldOpenAsSplit]) const reconciledSelection = reconcileSourceControlSelectionState({ selectedKeys,