Skip to content

useClipboard has no error handling around navigator.clipboard.writeText, causing an unhandled promise rejection in insecure/permission-denied contexts #22

Description

@abayomicornelius

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    GrantFox OSSIssue tracked in GrantFox OSSMaybe RewardedIssue may be eligible for a GrantFox rewardOfficial Campaign | FWC26Campaign: Official Campaign | FWC26bugSomething isn't workingvery hardVery difficult / senior-level bounty issue

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions