mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 01:15:03 +03:00
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:
1 parent
3f205627e0
commit
2c5b0a5ca0
2 files changed
+48
-15
No files matched your search
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in new issue
Block a user