Problem
src/hooks/useClipboard.ts:
export function useClipboard(timeout = 2000) {
const [copied, setCopied] = useState(false)
const copy = useCallback(async (text: string) => {
await navigator.clipboard.writeText(text)
setCopied(true); setTimeout(() => setCopied(false), timeout)
}, [timeout])
return { copy, copied }
}
navigator.clipboard.writeText rejects in several realistic, non-exotic situations:
- The page is served over plain HTTP (non-secure context) — the Clipboard API is only available in secure contexts, and on some browsers
navigator.clipboard itself is undefined, so this throws a TypeError on the property access before writeText is even called.
- The user has denied clipboard-write permission, or the document doesn't have focus at the moment
copy() fires (a common real-world case: the click handler races with a modal closing/opening).
- Some embedded/iframe contexts restrict clipboard access entirely.
None of these are caught. copy is an async function with no try/catch, so any rejection becomes an unhandled promise rejection in every caller.
Why it matters
src/components/ContactBook/ContactItem.tsx calls this directly from an onClick handler with no .catch() of its own:
const { copy, copied } = useClipboard()
...
<button onClick={() => copy(contact.address)}>{copied ? 'Copied!' : 'Copy'}</button>
If writeText rejects, copied never becomes true, the button silently does nothing from the user's perspective (no error message, no visual feedback at all), and the rejection surfaces only as an unhandled promise rejection in the console — invisible to the end user, who just sees "Copy" not do anything and has no idea why.
- Compare this to
src/lib/utils.ts's separate copyToClipboard helper, which does have a fallback path (document.execCommand('copy') via a hidden textarea) for exactly the case where navigator.clipboard isn't available — but that fallback lives in a completely different module (lib/utils.ts) than the one this hook uses directly (navigator.clipboard.writeText inline), so useClipboard's callers get none of that resilience. This is the same duplicate-implementation pattern flagged elsewhere in this batch (see the isValidStellarAddress/formatAmount issue), just for clipboard access instead of address formatting.
Reproduction
- Call
useClipboard().copy(text) in a test environment where navigator.clipboard.writeText is mocked to reject (e.g. Object.defineProperty(navigator, 'clipboard', { value: { writeText: () => Promise.reject(new DOMException('Document is not focused')) } })) and observe the rejection is unhandled and copied never flips to true, with no error surfaced to the caller.
Suggested fix
- Wrap the
writeText call in try/catch, and either (a) delegate to lib/utils.ts's copyToClipboard (which already has the execCommand fallback) instead of calling navigator.clipboard.writeText directly, unifying the two clipboard code paths, or (b) add an equivalent fallback directly in this hook.
- Add an
error (or copied: false + an explicit error state) return value so callers like ContactItem can show a "Couldn't copy — copy manually: G..." message instead of a silently inert button.
Edge cases
- Rapid repeated clicks before the
timeout fires — copied state and the pending setTimeout aren't cancelled/cleared on unmount or on a new copy() call, so a fast second copy followed by unmount could call setCopied on an unmounted component (a stale-closure warning in React, low severity but worth fixing alongside the primary bug since the file is being touched anyway).
Testing strategy
- Unit test
useClipboard with a mocked rejecting navigator.clipboard.writeText and assert the hook surfaces an error state rather than throwing unhandled.
- Unit test
ContactItem's copy button with the same rejecting mock and assert it shows a user-visible failure state instead of silently doing nothing.
Related issues in this batch
Same "two independent implementations of the same primitive, only one of which is robust" shape as the isValidStellarAddress/truncateAddress/formatAmount duplication issue filed in this batch.
Problem
src/hooks/useClipboard.ts:navigator.clipboard.writeTextrejects in several realistic, non-exotic situations:navigator.clipboarditself isundefined, so this throws aTypeErroron the property access beforewriteTextis even called.copy()fires (a common real-world case: the click handler races with a modal closing/opening).None of these are caught.
copyis anasyncfunction with notry/catch, so any rejection becomes an unhandled promise rejection in every caller.Why it matters
src/components/ContactBook/ContactItem.tsxcalls this directly from anonClickhandler with no.catch()of its own:writeTextrejects,copiednever becomestrue, the button silently does nothing from the user's perspective (no error message, no visual feedback at all), and the rejection surfaces only as an unhandled promise rejection in the console — invisible to the end user, who just sees "Copy" not do anything and has no idea why.src/lib/utils.ts's separatecopyToClipboardhelper, which does have a fallback path (document.execCommand('copy')via a hidden textarea) for exactly the case wherenavigator.clipboardisn't available — but that fallback lives in a completely different module (lib/utils.ts) than the one this hook uses directly (navigator.clipboard.writeTextinline), souseClipboard's callers get none of that resilience. This is the same duplicate-implementation pattern flagged elsewhere in this batch (see theisValidStellarAddress/formatAmountissue), just for clipboard access instead of address formatting.Reproduction
useClipboard().copy(text)in a test environment wherenavigator.clipboard.writeTextis mocked to reject (e.g.Object.defineProperty(navigator, 'clipboard', { value: { writeText: () => Promise.reject(new DOMException('Document is not focused')) } })) and observe the rejection is unhandled andcopiednever flips totrue, with no error surfaced to the caller.Suggested fix
writeTextcall intry/catch, and either (a) delegate tolib/utils.ts'scopyToClipboard(which already has theexecCommandfallback) instead of callingnavigator.clipboard.writeTextdirectly, unifying the two clipboard code paths, or (b) add an equivalent fallback directly in this hook.error(orcopied: false+ an expliciterrorstate) return value so callers likeContactItemcan show a "Couldn't copy — copy manually: G..." message instead of a silently inert button.Edge cases
timeoutfires —copiedstate and the pendingsetTimeoutaren't cancelled/cleared on unmount or on a newcopy()call, so a fast second copy followed by unmount could callsetCopiedon an unmounted component (a stale-closure warning in React, low severity but worth fixing alongside the primary bug since the file is being touched anyway).Testing strategy
useClipboardwith a mocked rejectingnavigator.clipboard.writeTextand assert the hook surfaces an error state rather than throwing unhandled.ContactItem's copy button with the same rejecting mock and assert it shows a user-visible failure state instead of silently doing nothing.Related issues in this batch
Same "two independent implementations of the same primitive, only one of which is robust" shape as the
isValidStellarAddress/truncateAddress/formatAmountduplication issue filed in this batch.