From 91b7df64c20734f91e310671fa8fc5660a9d6fc8 Mon Sep 17 00:00:00 2001 From: Miranda Limonczenko Date: Wed, 16 Sep 2026 12:01:21 -0700 Subject: [PATCH] fix(ui): report clipboard write failures instead of rejecting (#50292) Closes DOCS-1390 ## Problem Sentry [DOCS-AA](https://supabase.sentry.io/issues/7727380816/) reports `NotAllowedError: Failed to execute 'write' on 'Clipboard': Write permission denied.` as an unhandled promise rejection. The error names `write`, not `writeText`, which places it in the `ClipboardItem` branch of `copyToClipboard`. That branch has two problems: - The write runs inside a `setTimeout`, so the surrounding `try/catch` has already returned by the time it executes. A denied write routes to the promise's `reject`. - No caller attaches a `catch`. All call sites either fire-and-forget or `await` inside an async handler with no `try/catch`, so the rejection surfaces as an unhandled rejection. The user-visible effect is worse than the Sentry noise. On that branch the copy fails with no feedback at all, because the `toast.error` in the outer `catch` is unreachable from inside the `setTimeout`. The `writeText` branch does show the toast, so the two paths disagree. The issue is filed against auth docs, where it surfaced, but the fix belongs in `packages/ui`. The same branch runs in Studio and www. ## Solution - Handle the failure inside the `setTimeout`, where it happens: report it and resolve. - `copyToClipboard` no longer rejects on either path, matching what the `writeText` branch already did. No caller relied on rejection. - Add regression tests for a denied write on both branches. ## Manual testing 1. Run the unit tests. Four `copyToClipboard` cases pass, including the two new denial cases. ``` pnpm --filter studio exec vitest run lib/helpers.test.ts -t copyToClipboard ``` 2. Confirm the new test is a real guard. Revert `clipboard.ts` and rerun. The write case fails with `promise rejected ... instead of resolving`. 3. Confirm the ratchet is unchanged. ``` pnpm --filter studio run lint:ratchet ``` ## Summary by CodeRabbit * **Bug Fixes** * Clipboard write failures now display an error notification instead of causing an unhandled rejection. * Copy operations resolve consistently when clipboard access is denied or unavailable, including Safari clipboard support. * Failed copy attempts no longer trigger completion callbacks, preventing misleading success behavior. --- apps/studio/lib/helpers.test.ts | 29 ++++++++++++++++++++++++++ packages/ui/src/lib/utils/clipboard.ts | 17 +++------------ 2 files changed, 32 insertions(+), 14 deletions(-) diff --git a/apps/studio/lib/helpers.test.ts b/apps/studio/lib/helpers.test.ts index c3cd19dff73..defae2e2b43 100644 --- a/apps/studio/lib/helpers.test.ts +++ b/apps/studio/lib/helpers.test.ts @@ -1,3 +1,4 @@ +import { toast } from 'sonner' import { copyToClipboard } from 'ui' import { v4 as _uuidV4 } from 'uuid' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -31,6 +32,10 @@ import { uuidv4, } from './helpers' +vi.mock('sonner', () => ({ + toast: { error: vi.fn(), success: vi.fn() }, +})) + vi.mock('uuid', () => ({ v4: vi.fn(() => 'mocked-uuid'), })) @@ -218,6 +223,30 @@ describe('copyToClipboard', () => { await copyToClipboard('hello') expect(writeTextMock).toHaveBeenCalledWith('hello') }) + + it('resolves and reports 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(promise).resolves.toBeUndefined() + expect(callback).not.toHaveBeenCalled() + expect(toast.error).toHaveBeenCalledWith('Unable to copy to clipboard') + }) + + it('resolves and reports when writeText is denied', async () => { + writeTextMock.mockRejectedValue(new DOMException('Write permission denied.')) + vi.stubGlobal('navigator', { clipboard: { writeText: writeTextMock } }) + const callback = vi.fn() + + await expect(copyToClipboard('hello', callback)).resolves.toBeUndefined() + expect(callback).not.toHaveBeenCalled() + expect(toast.error).toHaveBeenCalledWith('Unable to copy to clipboard') + }) }) describe('detectBrowser', () => { diff --git a/packages/ui/src/lib/utils/clipboard.ts b/packages/ui/src/lib/utils/clipboard.ts index 58b80a4e382..8fe3b5e1154 100644 --- a/packages/ui/src/lib/utils/clipboard.ts +++ b/packages/ui/src/lib/utils/clipboard.ts @@ -30,20 +30,9 @@ export const copyToClipboard = async (str: ClipboardText, callback = noop) => { 'text/plain': Promise.resolve(str).then((text) => new Blob([text], { type: 'text/plain' })), }) - let resolve = () => {} - let reject = () => {} - const promise = new Promise((res, rej) => { - resolve = res - reject = rej - }) - // Safari also seems to require that the promise resolve soon after the - // clipboard write call, adding a setTimeout with 0 delay seems to work. - // Returning the promise to ensure the caller can await the clipboard - // copy operation intuitively. - setTimeout(() => { - navigator.clipboard.write([text]).then(callback).then(resolve).catch(reject) - }, 0) - return promise + await navigator.clipboard.write([text]) + callback() + return } // NOTE: Firefox has support for ClipboardItem and navigator.clipboard.write,