From 31070ceda9d045e8dc5327400a83edd745bd7d12 Mon Sep 17 00:00:00 2001 From: Leo Date: Thu, 10 Sep 2026 14:00:39 -0400 Subject: [PATCH 1/2] fix(web): copy text over plain HTTP Remote web pages served over HTTP have no Clipboard API. Copy now falls back to a synchronous execCommand path during the user gesture, restores focus, and still reports a structured error when both paths fail. Adapted from pingdotgg/t3code#8023. --- apps/web/src/hooks/useCopyToClipboard.test.ts | 87 +++++++++++++++++-- apps/web/src/hooks/useCopyToClipboard.ts | 45 ++++++++-- 2 files changed, 120 insertions(+), 12 deletions(-) diff --git a/apps/web/src/hooks/useCopyToClipboard.test.ts b/apps/web/src/hooks/useCopyToClipboard.test.ts index ccac333cd488..0e0600c921ec 100644 --- a/apps/web/src/hooks/useCopyToClipboard.test.ts +++ b/apps/web/src/hooks/useCopyToClipboard.test.ts @@ -14,6 +14,7 @@ describe("writeTextToClipboard", () => { it("reports unavailable clipboard support with structural context", async () => { vi.stubGlobal("window", {}); vi.stubGlobal("navigator", {}); + vi.stubGlobal("document", undefined); const error = await writeTextToClipboard("plan contents", "plan").then( () => undefined, @@ -27,6 +28,72 @@ describe("writeTextToClipboard", () => { expect((error as Error).message).not.toContain("plan contents"); }); + it.each(["success", "denied", "throws"] as const)( + "cleans up the Clipboard API fallback when copying %s", + async (result) => { + const focus = vi.fn(); + const restoreFocus = vi.fn(); + const appendChild = vi.fn(); + const execCommand = vi.fn(() => { + if (result === "throws") throw new Error("copy command failed"); + return result === "success"; + }); + const remove = vi.fn(); + const select = vi.fn(); + const setAttribute = vi.fn(); + const setSelectionRange = vi.fn(); + const textarea = { + focus, + remove, + select, + setAttribute, + setSelectionRange, + style: {}, + value: "", + }; + + vi.stubGlobal("window", {}); + vi.stubGlobal("navigator", {}); + vi.stubGlobal("document", { + activeElement: { focus: restoreFocus }, + body: { appendChild }, + createElement: vi.fn(() => textarea), + execCommand, + }); + + const pendingCopy = writeTextToClipboard("remote command", "command"); + // The fallback must run during the original user gesture, before any await. + expect(execCommand).toHaveBeenCalledWith("copy"); + if (result === "success") { + await expect(pendingCopy).resolves.toBe(true); + } else { + await expect(pendingCopy).rejects.toBeInstanceOf(ClipboardApiUnavailableError); + } + + expect(textarea.value).toBe("remote command"); + expect(textarea.style).toMatchObject({ fontSize: "16px" }); + expect(appendChild).toHaveBeenCalledWith(textarea); + expect(focus).toHaveBeenCalledWith({ preventScroll: true }); + expect(select).toHaveBeenCalledOnce(); + expect(setSelectionRange).toHaveBeenCalledWith(0, "remote command".length); + expect(remove).toHaveBeenCalledOnce(); + expect(restoreFocus).toHaveBeenCalledOnce(); + }, + ); + + it("uses the Clipboard API without touching the fallback when it is available", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + const execCommand = vi.fn(); + vi.stubGlobal("window", {}); + vi.stubGlobal("navigator", { clipboard: { writeText } }); + vi.stubGlobal("document", { execCommand }); + + await expect(writeTextToClipboard("remote command", "command")).resolves.toBe(true); + + expect(writeText).toHaveBeenCalledWith("remote command"); + expect(execCommand).not.toHaveBeenCalled(); + }); + it("preserves the exact clipboard failure without exposing copied contents", async () => { const cause = new Error("browser clipboard failure"); const writeText = vi.fn().mockRejectedValue(cause); @@ -47,12 +114,18 @@ describe("writeTextToClipboard", () => { expect((error as Error).message).not.toContain("secret clipboard contents"); }); - it("keeps empty values as a no-op when clipboard support is available", async () => { - const writeText = vi.fn(); - vi.stubGlobal("window", {}); - vi.stubGlobal("navigator", { clipboard: { writeText } }); + it.each([true, false])( + "keeps empty values as a no-op with Clipboard API support: %s", + async (available) => { + const writeText = vi.fn(); + vi.stubGlobal("window", {}); + const execCommand = vi.fn(); + vi.stubGlobal("navigator", available ? { clipboard: { writeText } } : {}); + vi.stubGlobal("document", { execCommand }); - await expect(writeTextToClipboard("", "plan")).resolves.toBe(false); - expect(writeText).not.toHaveBeenCalled(); - }); + await expect(writeTextToClipboard("", "plan")).resolves.toBe(false); + expect(writeText).not.toHaveBeenCalled(); + expect(execCommand).not.toHaveBeenCalled(); + }, + ); }); diff --git a/apps/web/src/hooks/useCopyToClipboard.ts b/apps/web/src/hooks/useCopyToClipboard.ts index ef66410f7db4..8723aaeb941a 100644 --- a/apps/web/src/hooks/useCopyToClipboard.ts +++ b/apps/web/src/hooks/useCopyToClipboard.ts @@ -47,12 +47,40 @@ export class ClipboardReadError extends Schema.TaggedErrorClass Date: Thu, 10 Sep 2026 14:15:00 -0400 Subject: [PATCH 2/2] fix(web): keep clipboard fallback errors structured without a body The execCommand path threw a raw TypeError when document existed but body did not. Return the same ClipboardApiUnavailableError callers already handle. --- apps/web/src/hooks/useCopyToClipboard.test.ts | 14 ++++++++++++++ apps/web/src/hooks/useCopyToClipboard.ts | 8 +++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/apps/web/src/hooks/useCopyToClipboard.test.ts b/apps/web/src/hooks/useCopyToClipboard.test.ts index 0e0600c921ec..e59c0f5783ce 100644 --- a/apps/web/src/hooks/useCopyToClipboard.test.ts +++ b/apps/web/src/hooks/useCopyToClipboard.test.ts @@ -81,6 +81,20 @@ describe("writeTextToClipboard", () => { }, ); + it("reports unavailable clipboard support when document has no body", async () => { + vi.stubGlobal("window", {}); + vi.stubGlobal("navigator", {}); + vi.stubGlobal("document", { execCommand: vi.fn(), body: null }); + + const error = await writeTextToClipboard("remote command", "command").then( + () => undefined, + (cause: unknown) => cause, + ); + + expect(error).toBeInstanceOf(ClipboardApiUnavailableError); + expect(error).toMatchObject({ target: "command" }); + }); + it("uses the Clipboard API without touching the fallback when it is available", async () => { const writeText = vi.fn().mockResolvedValue(undefined); const execCommand = vi.fn(); diff --git a/apps/web/src/hooks/useCopyToClipboard.ts b/apps/web/src/hooks/useCopyToClipboard.ts index 8723aaeb941a..f87137a28303 100644 --- a/apps/web/src/hooks/useCopyToClipboard.ts +++ b/apps/web/src/hooks/useCopyToClipboard.ts @@ -49,7 +49,13 @@ export class ClipboardReadError extends Schema.TaggedErrorClass