From 1f819fbe18c5a48301f544bc17d4cff97ec42775 Mon Sep 17 00:00:00 2001 From: Chris Date: Sat, 21 Mar 2026 17:11:48 +0800 Subject: [PATCH 1/2] fix(components): use ClipboardItem API for Safari-compatible clipboard writes Safari requires clipboard writes to occur synchronously within the user-gesture call stack. The previous async/await pattern with fetch() broke this chain, causing NotAllowedError in Safari when copying export content that requires fetching from a URL. Replace async handleClick with synchronous ClipboardItem + Promise pattern. ClipboardItem accepts a Promise value, keeping the clipboard.write() call in the user-gesture context while resolving data internally. Includes a fallback to writeText() for browsers where ClipboardItem is not available. Closes inveniosoftware/invenio-app-rdm#3378 Refs zenodo/zenodo-rdm#1266 --- .../invenio_app_rdm/components/CopyButton.js | 34 +++++++++++++------ 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/invenio_app_rdm/theme/assets/semantic-ui/js/invenio_app_rdm/components/CopyButton.js b/invenio_app_rdm/theme/assets/semantic-ui/js/invenio_app_rdm/components/CopyButton.js index 6baea00eee..bfd02febf5 100644 --- a/invenio_app_rdm/theme/assets/semantic-ui/js/invenio_app_rdm/components/CopyButton.js +++ b/invenio_app_rdm/theme/assets/semantic-ui/js/invenio_app_rdm/components/CopyButton.js @@ -10,19 +10,33 @@ import { Button, Popup } from "semantic-ui-react"; import { i18next } from "@translations/invenio_app_rdm/i18next"; class SimpleCopyButton extends React.Component { - fetchUrl = async (url) => { - return await (await fetch(url)).text(); - }; - - handleClick = async () => { + handleClick = () => { const { url, text, onCopy } = this.props; - let textToCopy = text; + if (url) { - textToCopy = await this.fetchUrl(url); + // When a URL is provided, the content must be fetched before copying. + // Safari requires clipboard writes to occur synchronously within the + // user-gesture call stack. Using async/await with fetch() breaks this + // chain. The ClipboardItem API accepts a Promise, keeping the + // clipboard.write() call synchronous while resolving data internally. + const textPromise = fetch(url).then((response) => response.text()); + + if (typeof ClipboardItem !== "undefined") { + const item = new ClipboardItem({ + "text/plain": textPromise.then( + (fetchedText) => new Blob([fetchedText], { type: "text/plain" }) + ), + }); + navigator.clipboard.write([item]).then(() => onCopy(text)); + } else { + // Fallback for browsers where ClipboardItem is not available + textPromise.then((fetchedText) => + navigator.clipboard.writeText(fetchedText).then(() => onCopy(text)) + ); + } + } else { + navigator.clipboard.writeText(text).then(() => onCopy(text)); } - - await navigator.clipboard.writeText(textToCopy); - onCopy(text); }; render() { From 3e3a1bf1be8ede9b291d12fa0f9a2943945802e9 Mon Sep 17 00:00:00 2001 From: Chris Date: Tue, 24 Mar 2026 17:56:13 +0900 Subject: [PATCH 2/2] refactor(components): simplify CopyButton with unified dataPromise Address review feedback from @Samk13: unify the url-fetch and direct-text paths into a single dataPromise, removing the outer if/else duplication. Also fix prettier formatting for the ClipboardItem callback. --- .../invenio_app_rdm/components/CopyButton.js | 40 +++++++++---------- 1 file changed, 18 insertions(+), 22 deletions(-) diff --git a/invenio_app_rdm/theme/assets/semantic-ui/js/invenio_app_rdm/components/CopyButton.js b/invenio_app_rdm/theme/assets/semantic-ui/js/invenio_app_rdm/components/CopyButton.js index bfd02febf5..abc715bc20 100644 --- a/invenio_app_rdm/theme/assets/semantic-ui/js/invenio_app_rdm/components/CopyButton.js +++ b/invenio_app_rdm/theme/assets/semantic-ui/js/invenio_app_rdm/components/CopyButton.js @@ -13,29 +13,25 @@ class SimpleCopyButton extends React.Component { handleClick = () => { const { url, text, onCopy } = this.props; - if (url) { - // When a URL is provided, the content must be fetched before copying. - // Safari requires clipboard writes to occur synchronously within the - // user-gesture call stack. Using async/await with fetch() breaks this - // chain. The ClipboardItem API accepts a Promise, keeping the - // clipboard.write() call synchronous while resolving data internally. - const textPromise = fetch(url).then((response) => response.text()); - - if (typeof ClipboardItem !== "undefined") { - const item = new ClipboardItem({ - "text/plain": textPromise.then( - (fetchedText) => new Blob([fetchedText], { type: "text/plain" }) - ), - }); - navigator.clipboard.write([item]).then(() => onCopy(text)); - } else { - // Fallback for browsers where ClipboardItem is not available - textPromise.then((fetchedText) => - navigator.clipboard.writeText(fetchedText).then(() => onCopy(text)) - ); - } + // Resolve what to copy: fetch from URL or use text directly. + // Safari requires clipboard writes to occur synchronously within the + // user-gesture call stack. Using async/await with fetch() breaks this + // chain. The ClipboardItem API accepts a Promise, keeping the + // clipboard.write() call synchronous while resolving data internally. + const dataPromise = url + ? fetch(url).then((response) => response.text()) + : Promise.resolve(text); + + if (typeof ClipboardItem !== "undefined") { + const item = new ClipboardItem({ + "text/plain": dataPromise.then((t) => new Blob([t], { type: "text/plain" })), + }); + navigator.clipboard.write([item]).then(() => onCopy(text)); } else { - navigator.clipboard.writeText(text).then(() => onCopy(text)); + // Fallback for browsers where ClipboardItem is not available + dataPromise.then((t) => + navigator.clipboard.writeText(t).then(() => onCopy(text)) + ); } };