From d7d3bcfc6653592be78ef5c6ea3298153eea4209 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Thu, 17 Sep 2026 20:11:21 -0700 Subject: [PATCH] fix(renderer): cancel copied prompt reset on unmount (#20906) * fix(renderer): cancel copied prompt reset on unmount * fix: address memory PR review regressions and withdraw false positives --------- Co-authored-by: m4air Co-authored-by: m4air --- .../settings/EphemeralVmsPane.test.tsx | 45 +++++++++++++++++++ .../components/settings/EphemeralVmsPane.tsx | 20 ++++++++- 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/components/settings/EphemeralVmsPane.test.tsx b/src/renderer/src/components/settings/EphemeralVmsPane.test.tsx index 8a68688f624..39400a827d8 100644 --- a/src/renderer/src/components/settings/EphemeralVmsPane.test.tsx +++ b/src/renderer/src/components/settings/EphemeralVmsPane.test.tsx @@ -138,6 +138,51 @@ describe('EphemeralVmsPane', () => { }) }) + it('does not schedule a reset when clipboard completion arrives after unmount', async () => { + let finishClipboard!: () => void + vi.mocked(window.api.ui.writeClipboardText).mockReturnValueOnce( + new Promise((resolve) => { + finishClipboard = resolve + }) + ) + const container = await renderPane() + const setTimeout = vi.spyOn(window, 'setTimeout') + try { + await act(async () => { + container.querySelector('button[aria-label="Copy"]')?.click() + }) + await act(async () => roots.pop()?.unmount()) + setTimeout.mockClear() + await act(async () => { + finishClipboard() + await Promise.resolve() + }) + expect(setTimeout.mock.calls.filter(([, delay]) => delay === 1500)).toHaveLength(0) + } finally { + setTimeout.mockRestore() + } + }) + + it('shows copied feedback while mounted and releases its reset on unmount', async () => { + const container = await renderPane() + const setTimeout = vi.spyOn(window, 'setTimeout') + const clearTimeout = vi.spyOn(window, 'clearTimeout') + try { + await act(async () => { + container.querySelector('button[aria-label="Copy"]')?.click() + }) + expect(container.querySelector('button[aria-label="Copy"]')?.textContent).toBe('Copied') + const timerIndex = setTimeout.mock.calls.findIndex(([, delay]) => delay === 1500) + expect(timerIndex).toBeGreaterThanOrEqual(0) + const timer = setTimeout.mock.results[timerIndex].value + await act(async () => roots.pop()?.unmount()) + expect(clearTimeout).toHaveBeenCalledWith(timer) + } finally { + setTimeout.mockRestore() + clearTimeout.mockRestore() + } + }) + it('refreshes the catalog when plugin content changes', async () => { const listRecipeCatalog = window.api.ephemeralVm.listRecipeCatalog as ReturnType const container = await renderPane() diff --git a/src/renderer/src/components/settings/EphemeralVmsPane.tsx b/src/renderer/src/components/settings/EphemeralVmsPane.tsx index acd042444c3..7b0086ae78b 100644 --- a/src/renderer/src/components/settings/EphemeralVmsPane.tsx +++ b/src/renderer/src/components/settings/EphemeralVmsPane.tsx @@ -45,6 +45,15 @@ export function EphemeralVmsPane(): React.JSX.Element { const [promptCopied, setPromptCopied] = useState(false) const mountedRef = useMountedRef() const refreshGenerationRef = useRef(0) + const promptResetTimerRef = useRef(null) + + useEffect(() => { + return () => { + if (promptResetTimerRef.current !== null) { + window.clearTimeout(promptResetTimerRef.current) + } + } + }, []) // Why: an absent runtime still resolves to the local host, which is what the // seven sibling panes rely on to reach the Windows npx preflight. @@ -126,8 +135,17 @@ export function EphemeralVmsPane(): React.JSX.Element { try { await window.api.ui.writeClipboardText(AGENT_PROMPT) useAppStore.getState().recordFeatureInteraction('ephemeral-vm-setup') + if (!mountedRef.current) { + return + } setPromptCopied(true) - setTimeout(() => setPromptCopied(false), 1500) + if (promptResetTimerRef.current !== null) { + window.clearTimeout(promptResetTimerRef.current) + } + promptResetTimerRef.current = window.setTimeout(() => { + promptResetTimerRef.current = null + setPromptCopied(false) + }, 1500) } catch { toast.error( translate(