From a3dd24d3dfab3a2f71573ad131d44b0c3a306e93 Mon Sep 17 00:00:00 2001 From: Nish2005karsh Date: Sat, 13 Jun 2026 23:37:27 +0530 Subject: [PATCH] fix(frontend): add execCommand clipboard fallback for Firefox Closes #3912 The "Create WebAssembly link"/molab permalink actions await an async readCode() before copying the resulting URL. In Firefox this consumes the transient user activation that navigator.clipboard.writeText requires, so the write rejects and the permalink is never copied. Add a hidden-textarea + document.execCommand("copy") fallback to the shared copyToClipboard util, which has laxer user-activation requirements and so succeeds after an await. Because every copy in the app routes through this util, the fix applies to permalinks and all other copy actions. The user's existing selection is preserved and restored, and window.prompt remains as the last resort. Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/src/utils/__tests__/copy.test.ts | 63 +++++++++++++++++- frontend/src/utils/copy.ts | 81 ++++++++++++++++++++--- 2 files changed, 132 insertions(+), 12 deletions(-) diff --git a/frontend/src/utils/__tests__/copy.test.ts b/frontend/src/utils/__tests__/copy.test.ts index 82b19d6cdc2..3548d0bf56c 100644 --- a/frontend/src/utils/__tests__/copy.test.ts +++ b/frontend/src/utils/__tests__/copy.test.ts @@ -1,6 +1,67 @@ /* Copyright 2026 Marimo. All rights reserved. */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { copyImageToClipboard, isSafari } from "../copy"; +import { copyImageToClipboard, copyToClipboard, isSafari } from "../copy"; + +describe("copyToClipboard", () => { + let execCommandMock: ReturnType; + let promptMock: ReturnType; + + beforeEach(() => { + execCommandMock = vi.fn().mockReturnValue(true); + document.execCommand = execCommandMock; + promptMock = vi.fn(); + vi.stubGlobal("prompt", promptMock); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it("uses navigator.clipboard.writeText when available", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.assign(navigator, { clipboard: { writeText } }); + + await copyToClipboard("hello"); + + expect(writeText).toHaveBeenCalledWith("hello"); + expect(execCommandMock).not.toHaveBeenCalled(); + expect(promptMock).not.toHaveBeenCalled(); + }); + + it("falls back to execCommand when writeText rejects (Firefox, #3912)", async () => { + const writeText = vi.fn().mockRejectedValue(new Error("not allowed")); + Object.assign(navigator, { clipboard: { writeText } }); + + await copyToClipboard("permalink-url"); + + expect(writeText).toHaveBeenCalledWith("permalink-url"); + expect(execCommandMock).toHaveBeenCalledWith("copy"); + expect(promptMock).not.toHaveBeenCalled(); + }); + + it("falls back to execCommand when navigator.clipboard is undefined", async () => { + Object.assign(navigator, { clipboard: undefined }); + + await copyToClipboard("text"); + + expect(execCommandMock).toHaveBeenCalledWith("copy"); + expect(promptMock).not.toHaveBeenCalled(); + }); + + it("prompts the user when both writeText and execCommand fail", async () => { + const writeText = vi.fn().mockRejectedValue(new Error("not allowed")); + Object.assign(navigator, { clipboard: { writeText } }); + execCommandMock.mockReturnValue(false); + + await copyToClipboard("text"); + + expect(promptMock).toHaveBeenCalledWith( + "Copy to clipboard: Ctrl+C, Enter", + "text", + ); + }); +}); describe("isSafari", () => { afterEach(() => { diff --git a/frontend/src/utils/copy.ts b/frontend/src/utils/copy.ts index fb55772d2bd..1cc435ab14f 100644 --- a/frontend/src/utils/copy.ts +++ b/frontend/src/utils/copy.ts @@ -8,15 +8,17 @@ import { Logger } from "./Logger"; * * As of 2024-10-29, Safari does not support navigator.clipboard.writeText * when running localhost http. + * + * Falls back to a hidden textarea + `document.execCommand("copy")` when the + * async Clipboard API is unavailable or rejects. This happens in Firefox when + * the copy follows an awaited async operation, since the transient user + * activation required by `navigator.clipboard.writeText` has been consumed by + * then (see #3912). `execCommand` has more lenient activation requirements. */ export async function copyToClipboard(text: string, html?: string) { - if (navigator.clipboard === undefined) { - Logger.warn("navigator.clipboard is not supported"); - window.prompt("Copy to clipboard: Ctrl+C, Enter", text); - return; - } - - if (html && navigator.clipboard.write) { + // Rich text requires the async Clipboard API; only attempt it when both the + // API and an `html` payload are present. + if (html && navigator.clipboard?.write) { try { const item = new ClipboardItem({ "text/html": new Blob([html], { type: "text/html" }), @@ -29,10 +31,67 @@ export async function copyToClipboard(text: string, html?: string) { } } - await navigator.clipboard.writeText(text).catch(() => { - Logger.warn("Failed to copy to clipboard using navigator.clipboard"); - window.prompt("Copy to clipboard: Ctrl+C, Enter", text); - }); + if (navigator.clipboard?.writeText) { + try { + await navigator.clipboard.writeText(text); + return; + } catch { + Logger.warn( + "navigator.clipboard.writeText failed, falling back to execCommand", + ); + } + } + + if (copyWithExecCommand(text)) { + return; + } + + // Last resort: let the user copy manually. + Logger.warn("Failed to copy to clipboard, prompting user"); + window.prompt("Copy to clipboard: Ctrl+C, Enter", text); +} + +/** + * Synchronously copies `text` using a hidden textarea and the legacy + * `document.execCommand("copy")`. Returns whether the copy succeeded. + * + * The previous selection is preserved and restored so this is invisible to + * the user. + */ +function copyWithExecCommand(text: string): boolean { + if (typeof document === "undefined" || !document.body) { + return false; + } + + const selection = document.getSelection(); + const previousRange = + selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null; + + const textArea = document.createElement("textarea"); + textArea.value = text; + textArea.setAttribute("readonly", ""); + // Position off-screen so focusing it doesn't scroll the page. + textArea.style.cssText = "position:fixed;left:-9999px;top:0;opacity:0;"; + document.body.append(textArea); + textArea.focus(); + textArea.select(); + + let success = false; + try { + success = document.execCommand("copy"); + } catch { + success = false; + } + + textArea.remove(); + + // Restore the user's prior selection, if any. + if (selection && previousRange) { + selection.removeAllRanges(); + selection.addRange(previousRange); + } + + return success; } /**