diff --git a/src/components/therapy-compass/use-clipboard.ts b/src/components/therapy-compass/use-clipboard.ts index 4d62dcf676..c966a90e91 100644 --- a/src/components/therapy-compass/use-clipboard.ts +++ b/src/components/therapy-compass/use-clipboard.ts @@ -2,35 +2,56 @@ import { useCallback, useEffect, useRef, useState } from "react"; -/** Write text to the clipboard, guarded for SSR / unavailable API. Returns whether the write was attempted. */ -export function copyText(text: string): boolean { +/** + * Write text to the clipboard, guarded for SSR / unavailable API. Resolves to + * whether the write actually succeeded: a rejected write (permission denied, + * lost focus, a blocked user gesture) resolves to `false` instead of throwing, + * so callers never signal success for a copy that didn't happen and no unhandled + * promise rejection escapes. + */ +export async function copyText(text: string): Promise { if (typeof navigator === "undefined" || !navigator.clipboard || !text) return false; - void navigator.clipboard.writeText(text); - return true; + try { + await navigator.clipboard.writeText(text); + return true; + } catch { + return false; + } } /** * Clipboard copy with transient "copied" feedback. `copied` holds the key of the * most recently copied item (or null) and resets after `resetMs`, so a caller can - * flip a single button's label/icon without tracking its own timer. + * flip a single button's label/icon without tracking its own timer. The key is + * set only once the write actually succeeds, and never after the component has + * unmounted. */ export function useClipboard(resetMs = 1400) { const [copied, setCopied] = useState(null); const timer = useRef | null>(null); + const mounted = useRef(true); + const latestRequest = useRef(0); - useEffect( - () => () => { + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; if (timer.current) clearTimeout(timer.current); - }, - [], - ); + }; + }, []); const copy = useCallback( (text: string, key = "default") => { - if (!copyText(text)) return; - setCopied(key); - if (timer.current) clearTimeout(timer.current); - timer.current = setTimeout(() => setCopied(null), resetMs); + // One hook instance is shared across several controls, so tag each request + // and ignore stale completions: a later copy supersedes this one, and its + // (possibly out-of-order) success must not overwrite the newer feedback. + const request = ++latestRequest.current; + void copyText(text).then((ok) => { + if (!ok || !mounted.current || request !== latestRequest.current) return; + setCopied(key); + if (timer.current) clearTimeout(timer.current); + timer.current = setTimeout(() => setCopied(null), resetMs); + }); }, [resetMs], ); diff --git a/tests/therapy-compass-clipboard.dom.test.tsx b/tests/therapy-compass-clipboard.dom.test.tsx new file mode 100644 index 0000000000..7093e174e3 --- /dev/null +++ b/tests/therapy-compass-clipboard.dom.test.tsx @@ -0,0 +1,100 @@ +import { act, renderHook } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { copyText, useClipboard } from "@/components/therapy-compass/use-clipboard"; + +// The clipboard helper must only report success when the browser actually +// accepts the write. A rejected `writeText` (permission denied, lost focus, +// blocked gesture) must resolve to `false` without throwing, so callers never +// flip to "Copied" for a copy that didn't happen. + +const originalClipboard = Object.getOwnPropertyDescriptor(globalThis.navigator, "clipboard"); + +function setWriteText(writeText: ((text: string) => Promise) | null) { + Object.defineProperty(globalThis.navigator, "clipboard", { + value: writeText ? { writeText } : undefined, + configurable: true, + writable: true, + }); +} + +// Let the two-microtask copyText().then() chain settle inside act(). +async function settle() { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); +} + +afterEach(() => { + vi.restoreAllMocks(); + if (originalClipboard) { + Object.defineProperty(globalThis.navigator, "clipboard", originalClipboard); + } else { + setWriteText(null); + } +}); + +describe("copyText", () => { + it("resolves true and writes when the clipboard accepts the text", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + setWriteText(writeText); + await expect(copyText("hello")).resolves.toBe(true); + expect(writeText).toHaveBeenCalledWith("hello"); + }); + + it("resolves false — never throws — when the write rejects", async () => { + setWriteText(vi.fn().mockRejectedValue(new Error("NotAllowedError"))); + await expect(copyText("hello")).resolves.toBe(false); + }); + + it("resolves false for empty text without touching the clipboard", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + setWriteText(writeText); + await expect(copyText("")).resolves.toBe(false); + expect(writeText).not.toHaveBeenCalled(); + }); +}); + +describe("useClipboard", () => { + it("sets copied to the key only after a successful write", async () => { + setWriteText(vi.fn().mockResolvedValue(undefined)); + const { result } = renderHook(() => useClipboard()); + expect(result.current.copied).toBeNull(); + act(() => result.current.copy("hello", "step-1")); + await settle(); + expect(result.current.copied).toBe("step-1"); + }); + + it("leaves copied unset when the write rejects", async () => { + setWriteText(vi.fn().mockRejectedValue(new Error("NotAllowedError"))); + const { result } = renderHook(() => useClipboard()); + act(() => result.current.copy("hello", "step-1")); + await settle(); + expect(result.current.copied).toBeNull(); + }); + + it("ignores a stale out-of-order completion and keeps the most recent copy's key", async () => { + const resolvers: Array<() => void> = []; + setWriteText(vi.fn().mockImplementation(() => new Promise((resolve) => resolvers.push(resolve)))); + const { result } = renderHook(() => useClipboard()); + + act(() => result.current.copy("first", "k-first")); + act(() => result.current.copy("second", "k-second")); + expect(resolvers).toHaveLength(2); + + // The most recent (second) write resolves first — its key wins. + await act(async () => { + resolvers[1](); + await Promise.resolve(); + }); + expect(result.current.copied).toBe("k-second"); + + // The stale (first) write resolves later — it must NOT overwrite the newer feedback. + await act(async () => { + resolvers[0](); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(result.current.copied).toBe("k-second"); + }); +});