Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 35 additions & 14 deletions src/components/therapy-compass/use-clipboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean> {
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<string | null>(null);
const timer = useRef<ReturnType<typeof setTimeout> | 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);
Comment thread
BigSimmo marked this conversation as resolved.
if (timer.current) clearTimeout(timer.current);
timer.current = setTimeout(() => setCopied(null), resetMs);
});
},
[resetMs],
);
Expand Down
100 changes: 100 additions & 0 deletions tests/therapy-compass-clipboard.dom.test.tsx
Original file line number Diff line number Diff line change
@@ -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<void>) | 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<void>((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");
});
});