Skip to content

fix: use native Clipboard API in useClipboard, fall back to copy-to-clipboard - #1287

Merged
TaprootFreak merged 9 commits into
DFXswiss:developfrom
Danswar:fix/buy-clipboard-native-api
Aug 10, 2026
Merged

fix: use native Clipboard API in useClipboard, fall back to copy-to-clipboard#1287
TaprootFreak merged 9 commits into
DFXswiss:developfrom
Danswar:fix/buy-clipboard-native-api

Conversation

@Danswar

@Danswar Danswar commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Summary

useClipboard (src/hooks/clipboard.hook.ts) copies via the copy-to-clipboard package, which on every call does a synchronous, non-React DOM write: document.body.appendChild a hidden node → execCommand('copy')document.body.removeChild it, entirely self-contained (it never touches a node React itself is tracking).

On /buy, the payment-details view wires 11 separate copy buttons to this hook (payment-info-buy.tsx), each followed about a second later by a React-driven re-render (the button icon swapping back). Since the client-error boundary started reporting (#1227), telemetry has recorded a recurring crash on that route:

NotFoundError: Failed to execute 'insertBefore' on 'Node': The node before
which the new node is to be inserted is not a child of this node.

The stack is entirely inside React's own DOM-commit internals — the signature of something outside React's control having removed or moved a node React still expected to own. copy-to-clipboard's write doesn't collide with that node directly; the theory is that it opens a window, once per click, during which something else touching the page (a browser extension is the leading candidate) could interfere with nearby DOM structure before React's own follow-up commit runs. It's the only unmanaged DOM mutation reachable from /buy — everything else in that render tree, including the QR code, goes through React.

This PR switches useClipboard to navigator.clipboard.writeText, which needs no DOM node at all, and falls back to the existing copy-to-clipboard path only when the Clipboard API is unavailable or the write is rejected.

What this is and isn't

  • This removes the one identified raw-DOM-write window reachable from /buy for the primary path. It has not been proven to be the actual cause of the crash — no direct interference signal was captured, this is the only concrete lever available given the rest of the render tree is plain React. Not strictly true for the fallback: when writeText is unavailable or rejects, the fallback still routes through copy-to-clipboard and carries the same removeChild-based DOM write this PR otherwise removes.
  • The synchronous fallback branch (navigator.clipboard unavailable) now runs copy(text) inside try/finally, so isCopying still resets even if that call throws — mirroring the guarantee the async branch already has via .finally(resetIsCopying).
  • useClipboard's public interface (copy, isCopying) is unchanged, so the hook's other callers (payment-info-sell/-content, sell/swap-completion, connect-cli, user-data-panel, tfa, support-dashboard, payment-link-pos and the realunit screens) keep working as before.
  • Not covered: 11 files import copy from copy-to-clipboard directly and keep the unchanged synchronous DOM write — the account, settings, transaction, payment-routes, invoice, payment-link and blockchain-tx screens, safe/receive, cointracking, the recommendations section and qr-code.tsx (QrCopy). None of them sit on the /buy payment-details view, so they are out of scope here.
  • Known trade-off, not fixed here: the fallback (.catch(() => copy(text))) runs from a promise-rejection microtask rather than synchronously inside the click handler. On a browser where writeText rejects, this could run outside the strict synchronous-user-gesture window execCommand('copy') prefers on some engines — copy-to-clipboard's own internal fallback chain (execCommandclipboardDatawindow.prompt()) still applies if that happens, so the failure mode degrades to a manual-copy prompt rather than silently dropping. If that last-resort fallback itself throws, the chain now terminates in a deliberate swallow (.catch(() => undefined)) instead of escaping as an unhandled rejection. Not chasing this further here: on a top-level document a writeText rejection is uncommon, and the only way to fully close it would be reintroducing a synchronous DOM write on every click — the exact thing this PR removes. One deployment where the rejection is not uncommon: a cross-origin Iframe embed without allow="clipboard-write" never gets the native path, so every copy takes the fallback there — docs: set clipboard-write permission on the Iframe example #1296 adds the attribute to the documented Iframe example.
  • Doesn't touch the unrelated key={index} map in exchange-rate.tsx noted during investigation of this crash — that's a separate, lower-confidence smell that isn't itself known to cause this class of error.

Test plan

  • npm test — 74 suites / 802 tests pass, including new coverage for both the writeText path and its fallback: an explicit navigator.clipboard-undefined context asserting copy-to-clipboard is called (and that the reset still fires there), a throwing-fallback case asserting the chain settles without an unhandled rejection and still resets isCopying, and a case where copy-to-clipboard itself throws synchronously, asserting isCopying still resets — all verified by mutation (removing the trailing .catch, the else-branch reset, or the sync branch's try/finally each fail the suite)
  • npm run lint on changed files
  • Coverage: src/hooks/clipboard.hook.ts — 100% stmt/branch/func/line
  • Manual copy-button check on /buy in a real browser before merge

Danswar added 5 commits August 7, 2026 18:11
…lipboard

copy-to-clipboard does a synchronous document.body.appendChild/removeChild
of a hidden node on every copy. On /buy's payment-details view (11 copy
buttons across payment-info-buy.tsx) that raw DOM write is a plausible
collision window with anything else touching the page at the same instant
(browser extensions, translators), and is the only unmanaged DOM write
reachable from that screen. navigator.clipboard.writeText needs no DOM
node at all; fall back to the old path only where it's unavailable.
The existing suite only ever exercised the copy-to-clipboard fallback
since jsdom has no navigator.clipboard. Add cases for the primary path
and for falling back when writeText rejects.
The 500ms reset used to fire unconditionally, so on the writeText
rejection path the checkmark could revert before the copy-to-clipboard
fallback (and its possible window.prompt()) even ran.
Neither async-path test checked isCopying, so it stayed green against
the pre-fix code that reset it before the write actually settled.
Verified by mutation: these two fail on the unconditional-setTimeout
version and pass on the current .finally()-sequenced one.
@Danswar

Danswar commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

4 review passes before this was ready — each fix commit was re-reviewed fresh:

  1. Missing per-file coverage numbers in the description, and pre-existing formatting drift in the test file — both fixed.
  2. The isCopying reset could fire before the async clipboard write had actually settled — fixed by sequencing it after the write via .finally().
  3. The tests added for the async path didn't actually assert on that timing, so they wouldn't have caught a regression of [DEV-1436] combined context #2 — added two tests with manually-controlled promises that pin the ordering, verified by reintroducing the bug and confirming they fail.
  4. Stale test count in the description — updated.

All green since.

@Danswar
Danswar marked this pull request as ready for review August 7, 2026 21:54
@TaprootFreak

Copy link
Copy Markdown
Contributor

@mara-steiner please check

@Danswar

Danswar commented Aug 8, 2026

Copy link
Copy Markdown
Contributor Author

All three addressed:

  1. e1aea16 — the chain now terminates in a trailing .catch(() => undefined) after the .finally, so a throwing fallback settles instead of reaching the unhandledrejection reporter. Includes a test that isCopying still resets when the fallback throws.
  2. Description reworded — the six screens previously listed are direct copy-to-clipboard importers, not hook callers. The body now names the actual hook callers and lists the 11 untouched direct-import sites as explicitly out of scope.
  3. 351e4ff — explicit navigator.clipboard-undefined context asserting copy-to-clipboard is called with the text, per your suggestion.

Locally on the new head: lint and prettier clean, 74 suites / 801 tests green, hook coverage still 100% on all four metrics.

Thanks for the premise check — good to have the /buy reachability independently confirmed.

@Danswar

Danswar commented Aug 8, 2026

Copy link
Copy Markdown
Contributor Author

2 review passes on the review-response commits before settling:

  • The throwing-fallback test now asserts the chain actually settles: an unhandledRejection listener verifies nothing escapes, and removing the trailing .catch(() => undefined) makes the test fail.
  • The explicit navigator.clipboard-undefined context now also asserts the 500 ms reset.
  • Corrected the comment rationale on the trailing catch — the app's own unhandledrejection listener only reports chunk-load errors, so the earlier wording overclaimed what it would have caught.
  • The Iframe-integration gap surfaced during this pass (cross-origin embeds never get the native clipboard path in Chromium without allow="clipboard-write") is addressed separately in docs: set clipboard-write permission on the Iframe example #1296.

CI is green on 9d8a2ed.

copy() in the navigator.clipboard-unavailable branch could throw
(copy-to-clipboard's own removeChild is unguarded) and skip
resetIsCopying, leaving isCopying stuck true. The async branch was
already protected via .finally(); mirror that for the sync branch.

Also restores the original navigator.clipboard property descriptor
in tests instead of deleting it, so a future jsdom that ships a
Clipboard implementation isn't silently stripped between tests.
@Danswar

Danswar commented Aug 8, 2026

Copy link
Copy Markdown
Contributor Author

@mara-steiner addressed at dbea1b2: the sync fallback branch now runs copy(text) inside try/finally so isCopying can't stick if it throws, with a mutation-verified test. Also restored the navigator.clipboard descriptor in tests instead of deleting it, and reworded the PR body so the "one raw-DOM-write window" claim is scoped to the primary path only. CI is green. Ready for re-review.

@davidleomay

Copy link
Copy Markdown
Member

@mara-steiner please re-review!

@TaprootFreak

Copy link
Copy Markdown
Contributor

@mara-steiner please check

@TaprootFreak
TaprootFreak merged commit 89eef62 into DFXswiss:develop Aug 10, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants