From 4cc603db8977058fb67f615ebcde75d53c03c2bb Mon Sep 17 00:00:00 2001 From: Alex Date: Sat, 12 Sep 2026 20:05:20 +0100 Subject: [PATCH] fix(frontend): guard CopyButton against concurrent copies The button is disabled once isCopied is true, but copy() is now async, so isCopied and the disabled prop only catch up after it resolves. Clicks landing inside that window both passed the guard and started a write. Nothing leaked, since the existing clearTimeout already handled the duplicate timer, but the window did not exist before copy-to-clipboard 4 made the call async. A ref cleared in finally closes it. Raised by CodeRabbit on #2762. 598 tests pass, build and lint unchanged. --- frontend/src/components/CopyButton.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/CopyButton.tsx b/frontend/src/components/CopyButton.tsx index c8d37a8e..69285193 100644 --- a/frontend/src/components/CopyButton.tsx +++ b/frontend/src/components/CopyButton.tsx @@ -35,6 +35,9 @@ export default function CopyButton({ const { t } = useTranslation(); const [isCopied, setIsCopied] = useState(false); const timeoutIdRef = useRef(null); + // `copy` is async, so `isCopied` and the disabled prop only catch up after it + // resolves. Guard that window so rapid clicks cannot start a second write. + const copyInFlightRef = useRef(false); const iconWrapperClasses = clsx( 'flex items-center justify-center rounded-full transition-colors duration-150 ease-in-out', @@ -72,7 +75,8 @@ export default function CopyButton({ : t('conversation.copy'); const handleCopy = useCallback(async () => { - if (isCopied) return; + if (isCopied || copyInFlightRef.current) return; + copyInFlightRef.current = true; try { const success = await copy(textToCopy); @@ -92,6 +96,8 @@ export default function CopyButton({ } } catch (error) { console.error('Failed to copy text:', error); + } finally { + copyInFlightRef.current = false; } }, [textToCopy, copiedDuration, isCopied]);