fix(ui): fall back to writeText when clipboard.write fails (#50777)

## Problem

Safari can expose `navigator.clipboard.write()` while rejecting it with
`NotAllowedError`.
Studio's shared clipboard helper treated that rejection as a terminal
failure, so Copy buttons showed "Unable to copy to clipboard" even
though `writeText()` worked.

Fixes #50769

## Solution

- Fall back to `navigator.clipboard.writeText()` when the rich clipboard
write fails.
- Preserve the existing success callback and error behavior.
- Add regression coverage for fallback success, total failure, and
callback exceptions.

## Verification

- `pnpm exec vitest run lib/helpers.test.ts --pool=threads`
- `pnpm test:prettier`
- `pnpm --filter ui run typecheck`
- `pnpm --filter studio run typecheck`
- `pnpm --filter studio run lint`
- `npm run build -- --filter=studio`
- Rendered browser verification with `clipboard.write()` forced to
reject; `writeText()` received the expected payload and the Copy button
showed success.

## Review instructions

1. Review the fallback logic in
`packages/ui/src/lib/utils/clipboard.ts`.
2. Review the regression tests in `apps/studio/lib/helpers.test.ts`.
3. Confirm no generated or vendored files are changed.

- [x] I have read CONTRIBUTING.md
- [x] This PR does not change documentation content.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Copying now falls back to standard clipboard copying when rich
clipboard access is unavailable or denied.
* An error message is shown only when both clipboard methods fail.
Successful rich clipboard writes do not trigger a fallback if a
follow-up action fails.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Dongha authored and GitHub committed 2026-09-29 11:13:40 +10:00
1 parent 3f205627e0
commit 2c5b0a5ca0
2 files changed
+48 -15

No files matched your search

+24 -4
View File
@@ -224,16 +224,36 @@ describe('copyToClipboard', () => {
expect(writeTextMock).toHaveBeenCalledWith('hello')
})
it('resolves and reports when clipboard.write is denied', async () => {
it('falls back to writeText when clipboard.write is denied', async () => {
writeMock.mockRejectedValue(
new DOMException("Failed to execute 'write' on 'Clipboard': Write permission denied.")
)
const callback = vi.fn()
const promise = copyToClipboard('hello', callback)
vi.runAllTimers()
await expect(copyToClipboard('hello', callback)).resolves.toBeUndefined()
expect(writeTextMock).toHaveBeenCalledWith('hello')
expect(callback).toHaveBeenCalled()
expect(toast.error).not.toHaveBeenCalled()
})
await expect(promise).resolves.toBeUndefined()
it('does not retry after a successful rich write when the callback throws', async () => {
const callback = vi.fn(() => {
throw new Error('Callback failed')
})
await expect(copyToClipboard('hello', callback)).resolves.toBeUndefined()
expect(writeMock).toHaveBeenCalledOnce()
expect(writeTextMock).not.toHaveBeenCalled()
expect(callback).toHaveBeenCalledOnce()
expect(toast.error).toHaveBeenCalledWith('Unable to copy to clipboard')
})
it('reports when both clipboard methods are denied', async () => {
writeMock.mockRejectedValue(new DOMException('Write permission denied.'))
writeTextMock.mockRejectedValue(new DOMException('Write permission denied.'))
const callback = vi.fn()
await expect(copyToClipboard('hello', callback)).resolves.toBeUndefined()
expect(callback).not.toHaveBeenCalled()
expect(toast.error).toHaveBeenCalledWith('Unable to copy to clipboard')
})
+24 -11
View File
@@ -21,18 +21,31 @@ export const copyToClipboard = async (str: ClipboardText, callback = noop) => {
try {
if (typeof ClipboardItem !== 'undefined' && navigator.clipboard?.write) {
// NOTE: Safari locks down the clipboard API to only work when triggered
// by a direct user interaction. You can't use it async in a promise.
// But! You can wrap the promise in a ClipboardItem, and give that to
// the clipboard API.
// Found this on https://developer.apple.com/forums/thread/691873
const text = new ClipboardItem({
'text/plain': Promise.resolve(str).then((text) => new Blob([text], { type: 'text/plain' })),
})
let richWriteSucceeded = false
await navigator.clipboard.write([text])
callback()
return
try {
// NOTE: Safari locks down the clipboard API to only work when triggered
// by a direct user interaction. You can't use it async in a promise.
// But! You can wrap the promise in a ClipboardItem, and give that to
// the clipboard API.
// Found this on https://developer.apple.com/forums/thread/691873
const text = new ClipboardItem({
'text/plain': Promise.resolve(str).then(
(text) => new Blob([text], { type: 'text/plain' })
),
})
await navigator.clipboard.write([text])
richWriteSucceeded = true
} catch {
// Safari can expose clipboard.write() and still reject it. Fall back to
// writeText() before reporting that copying failed.
}
if (richWriteSucceeded) {
callback()
return
}
}
if (!navigator.clipboard) throw new Error('Clipboard API unavailable')