fix(frontend): fallback Streamdown clipboard copy (#3397)

* fix(frontend): fallback streamdown clipboard copy

* fix(frontend): address clipboard fallback review

* fix(frontend): normalize clipboard fallback rejection

* fix(frontend): harden clipboard fallback install

* fix(frontend): clarify clipboard fallback errors

* fix(frontend): cover clipboard fallback edge cases

* fix(frontend): tighten clipboard fallback cleanup

* fix(frontend): reduce clipboard fallback copy window

* fix(frontend): guard clipboard item fallback install

* fix(frontend): clean up clipboard fallback on selection errors

* Address clipboard fallback review feedback

* fix(frontend): guard clipboard fallback install during SSR
This commit is contained in:
Admire
2026-06-09 22:09:13 +08:00
committed by GitHub
parent 63ce88f874
commit 5b81588b87
9 changed files with 901 additions and 36 deletions
@@ -18,7 +18,8 @@ import {
} from "lucide-react"; } from "lucide-react";
import type { ComponentProps, HTMLAttributes, ReactElement } from "react"; import type { ComponentProps, HTMLAttributes, ReactElement } from "react";
import { createContext, memo, useContext, useEffect, useState } from "react"; import { createContext, memo, useContext, useEffect, useState } from "react";
import { Streamdown } from "streamdown";
import { ClipboardSafeStreamdown } from "./streamdown";
export type MessageProps = HTMLAttributes<HTMLDivElement> & { export type MessageProps = HTMLAttributes<HTMLDivElement> & {
from: UIMessage["role"]; from: UIMessage["role"];
@@ -302,11 +303,13 @@ export const MessageBranchPage = ({
); );
}; };
export type MessageResponseProps = ComponentProps<typeof Streamdown>; export type MessageResponseProps = ComponentProps<
typeof ClipboardSafeStreamdown
>;
export const MessageResponse = memo( export const MessageResponse = memo(
({ className, ...props }: MessageResponseProps) => ( ({ className, ...props }: MessageResponseProps) => (
<Streamdown <ClipboardSafeStreamdown
className={cn( className={cn(
"size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0", "size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
className, className,
@@ -10,9 +10,9 @@ import { cn } from "@/lib/utils";
import { BrainIcon, ChevronDownIcon } from "lucide-react"; import { BrainIcon, ChevronDownIcon } from "lucide-react";
import type { ComponentProps, ReactNode } from "react"; import type { ComponentProps, ReactNode } from "react";
import { createContext, memo, useContext, useEffect, useState } from "react"; import { createContext, memo, useContext, useEffect, useState } from "react";
import { Streamdown } from "streamdown";
import { reasoningPlugins } from "@/core/streamdown/plugins"; import { reasoningPlugins } from "@/core/streamdown/plugins";
import { Shimmer } from "./shimmer"; import { Shimmer } from "./shimmer";
import { ClipboardSafeStreamdown } from "./streamdown";
type ReasoningContextValue = { type ReasoningContextValue = {
isStreaming: boolean; isStreaming: boolean;
@@ -178,7 +178,9 @@ export const ReasoningContent = memo(
)} )}
{...props} {...props}
> >
<Streamdown {...reasoningPlugins}>{children}</Streamdown> <ClipboardSafeStreamdown {...reasoningPlugins}>
{children}
</ClipboardSafeStreamdown>
</CollapsibleContent> </CollapsibleContent>
), ),
); );
@@ -0,0 +1,17 @@
"use client";
import { type ComponentProps } from "react";
import { Streamdown } from "streamdown";
import { installClipboardFallback } from "@/core/clipboard";
export type ClipboardSafeStreamdownProps = ComponentProps<typeof Streamdown>;
// Only patch browser globals in client context; skip during SSR
if (typeof document !== "undefined") {
installClipboardFallback();
}
export function ClipboardSafeStreamdown(props: ClipboardSafeStreamdownProps) {
return <Streamdown {...props} />;
}
@@ -10,7 +10,6 @@ import {
} from "lucide-react"; } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Streamdown } from "streamdown";
import { import {
Artifact, Artifact,
@@ -20,6 +19,7 @@ import {
ArtifactHeader, ArtifactHeader,
ArtifactTitle, ArtifactTitle,
} from "@/components/ai-elements/artifact"; } from "@/components/ai-elements/artifact";
import { ClipboardSafeStreamdown } from "@/components/ai-elements/streamdown";
import { Select, SelectItem } from "@/components/ui/select"; import { Select, SelectItem } from "@/components/ui/select";
import { import {
SelectContent, SelectContent,
@@ -400,13 +400,13 @@ export function ArtifactFilePreview({
if (language === "markdown") { if (language === "markdown") {
return ( return (
<div className="size-full px-4"> <div className="size-full px-4">
<Streamdown <ClipboardSafeStreamdown
className="size-full" className="size-full"
{...streamdownPlugins} {...streamdownPlugins}
components={{ a: ArtifactLink }} components={{ a: ArtifactLink }}
> >
{content ?? ""} {content ?? ""}
</Streamdown> </ClipboardSafeStreamdown>
</div> </div>
); );
} }
@@ -6,7 +6,6 @@ import {
XCircleIcon, XCircleIcon,
} from "lucide-react"; } from "lucide-react";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { Streamdown } from "streamdown";
import { import {
ChainOfThought, ChainOfThought,
@@ -14,6 +13,7 @@ import {
ChainOfThoughtStep, ChainOfThoughtStep,
} from "@/components/ai-elements/chain-of-thought"; } from "@/components/ai-elements/chain-of-thought";
import { Shimmer } from "@/components/ai-elements/shimmer"; import { Shimmer } from "@/components/ai-elements/shimmer";
import { ClipboardSafeStreamdown } from "@/components/ai-elements/streamdown";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ShineBorder } from "@/components/ui/shine-border"; import { ShineBorder } from "@/components/ui/shine-border";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
@@ -126,12 +126,12 @@ export function SubtaskCard({
{task.prompt && ( {task.prompt && (
<ChainOfThoughtStep <ChainOfThoughtStep
label={ label={
<Streamdown <ClipboardSafeStreamdown
{...streamdownPluginsWithWordAnimation} {...streamdownPluginsWithWordAnimation}
components={{ a: CitationLink }} components={{ a: CitationLink }}
> >
{task.prompt} {task.prompt}
</Streamdown> </ClipboardSafeStreamdown>
} }
></ChainOfThoughtStep> ></ChainOfThoughtStep>
)} )}
@@ -1,9 +1,9 @@
"use client"; "use client";
import { Streamdown } from "streamdown"; import { ClipboardSafeStreamdown } from "@/components/ai-elements/streamdown";
import { aboutMarkdown } from "./about-content"; import { aboutMarkdown } from "./about-content";
export function AboutSettingsPage() { export function AboutSettingsPage() {
return <Streamdown>{aboutMarkdown}</Streamdown>; return <ClipboardSafeStreamdown>{aboutMarkdown}</ClipboardSafeStreamdown>;
} }
@@ -10,8 +10,8 @@ import {
import Link from "next/link"; import Link from "next/link";
import { useDeferredValue, useId, useRef, useState } from "react"; import { useDeferredValue, useId, useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Streamdown } from "streamdown";
import { ClipboardSafeStreamdown } from "@/components/ai-elements/streamdown";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Dialog, Dialog,
@@ -639,12 +639,12 @@ export function MemorySettingsPage() {
<div className="text-muted-foreground mb-4 text-sm"> <div className="text-muted-foreground mb-4 text-sm">
{summaryReadOnly} {summaryReadOnly}
</div> </div>
<Streamdown <ClipboardSafeStreamdown
className="size-full min-w-0 [overflow-wrap:anywhere] [&>*:first-child]:mt-0 [&>*:last-child]:mb-0" className="size-full min-w-0 [overflow-wrap:anywhere] [&>*:first-child]:mt-0 [&>*:last-child]:mb-0"
{...streamdownPlugins} {...streamdownPlugins}
> >
{summariesToMarkdown(memory, filteredSectionGroups, t)} {summariesToMarkdown(memory, filteredSectionGroups, t)}
</Streamdown> </ClipboardSafeStreamdown>
</div> </div>
) : null} ) : null}
+239 -12
View File
@@ -1,14 +1,17 @@
export async function writeTextToClipboard(text: string): Promise<boolean> { type ClipboardItemLike = {
try { types?: readonly string[];
const clipboard = globalThis.navigator?.clipboard; getType?: (type: string) => Promise<Blob>;
if (clipboard?.writeText) { items?: Record<string, Blob | string>;
await clipboard.writeText(text); };
return true;
}
function copyTextWithExecCommand(text: string): boolean {
const document = globalThis.document; const document = globalThis.document;
if (!document?.body?.appendChild || !document.execCommand) { if (
return false; typeof document?.createElement !== "function" ||
typeof document.body?.appendChild !== "function" ||
typeof document.execCommand !== "function"
) {
throw new Error("Clipboard DOM fallback not available");
} }
const textarea = document.createElement("textarea"); const textarea = document.createElement("textarea");
@@ -17,15 +20,239 @@ export async function writeTextToClipboard(text: string): Promise<boolean> {
textarea.style.position = "fixed"; textarea.style.position = "fixed";
textarea.style.top = "-9999px"; textarea.style.top = "-9999px";
textarea.style.left = "-9999px"; textarea.style.left = "-9999px";
document.body.appendChild(textarea);
textarea.select();
let copied = false;
let appended = false;
try { try {
return document.execCommand("copy"); document.body.appendChild(textarea);
appended = true;
textarea.select();
copied = document.execCommand("copy");
} finally { } finally {
if (appended) {
const parentNode = textarea.parentNode;
if (typeof textarea.remove === "function") {
textarea.remove(); textarea.remove();
} else if (typeof parentNode?.removeChild === "function") {
parentNode.removeChild(textarea);
} }
}
}
return copied;
}
export async function writeTextToClipboard(text: string): Promise<boolean> {
try {
const clipboard = globalThis.navigator?.clipboard;
if (clipboard?.writeText) {
await clipboard.writeText(text);
return true;
}
return copyTextWithExecCommand(text);
} catch { } catch {
return false; return false;
} }
} }
function fallbackWriteText(text: string): Promise<void> {
try {
if (!copyTextWithExecCommand(text)) {
return Promise.reject(new Error("Clipboard copy command failed"));
}
} catch (error) {
return Promise.reject(
error instanceof Error ? error : new Error(String(error)),
);
}
return Promise.resolve();
}
function hasUsableClipboardItem(): boolean {
return typeof globalThis.ClipboardItem === "function";
}
async function readPlainTextFromClipboardItem(
item: ClipboardItemLike,
): Promise<string> {
const plainText = item.items?.["text/plain"];
if (typeof plainText === "string") {
return plainText;
}
if (plainText instanceof Blob) {
return await plainText.text();
}
if (item.types && !item.types.includes("text/plain")) {
throw new Error("Clipboard item is missing text/plain data");
}
if (typeof item.getType !== "function") {
throw new Error("Clipboard item cannot read text/plain data");
}
const blob = await item.getType("text/plain");
if (blob instanceof Blob) {
return await blob.text();
}
throw new Error("Clipboard item text/plain data is not a Blob");
}
function canDefineNavigatorClipboard(
navigator: Navigator,
descriptor: PropertyDescriptor | undefined,
): boolean {
if (descriptor) {
return descriptor.configurable === true;
}
return Object.isExtensible(navigator);
}
/**
* Installs browser clipboard fallbacks for Streamdown copy controls by patching
* missing navigator.clipboard methods and ClipboardItem when the host permits it.
*/
export function installClipboardFallback(): void {
const navigator = globalThis.navigator;
if (!navigator) {
return;
}
const rawClipboard = navigator.clipboard;
const clipboard =
typeof rawClipboard === "object" && rawClipboard !== null
? (rawClipboard as Partial<Clipboard>)
: undefined;
const clipboardDescriptor = Object.getOwnPropertyDescriptor(
navigator,
"clipboard",
);
const hasWriteText = typeof clipboard?.writeText === "function";
const hasWrite = typeof clipboard?.write === "function";
const hasClipboardItem = hasUsableClipboardItem();
if (hasWriteText && hasWrite && hasClipboardItem) {
return;
}
const writeText = hasWriteText
? clipboard.writeText!.bind(clipboard)
: fallbackWriteText;
const write = hasWrite
? clipboard.write!.bind(clipboard)
: (items: ClipboardItemLike[]) => {
const firstItem = items[0];
if (!firstItem) {
return Promise.reject(new Error("Clipboard item not available"));
}
return readPlainTextFromClipboardItem(firstItem).then(writeText);
};
const fallbackClipboard = clipboard ?? {};
try {
const missingMethods: PropertyDescriptorMap = {};
if (!hasWrite) {
missingMethods.write = {
configurable: true,
value: write,
writable: true,
};
}
if (!hasWriteText) {
missingMethods.writeText = {
configurable: true,
value: writeText,
writable: true,
};
}
Object.defineProperties(fallbackClipboard, missingMethods);
if (
!clipboard &&
canDefineNavigatorClipboard(navigator, clipboardDescriptor)
) {
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: fallbackClipboard,
});
}
} catch {
if (!canDefineNavigatorClipboard(navigator, clipboardDescriptor)) {
// The ClipboardItem fallback below is independent from navigator.clipboard.
if (hasClipboardItem) {
return;
}
} else {
const replacement = Object.create(clipboard ?? null);
for (const methodName of ["read", "readText"] as const) {
const method = clipboard?.[methodName];
if (typeof method === "function") {
Object.defineProperty(replacement, methodName, {
configurable: true,
value: method.bind(clipboard),
writable: true,
});
}
}
Object.defineProperties(replacement, {
write: {
configurable: true,
value: write,
writable: true,
},
writeText: {
configurable: true,
value: writeText,
writable: true,
},
});
try {
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: replacement,
});
} catch {
// The ClipboardItem fallback below is independent from navigator.clipboard.
}
}
}
if (!hasClipboardItem) {
class ClipboardItemFallback {
items: Record<string, Blob | string>;
types: string[];
constructor(items: Record<string, Blob | string>) {
this.items = items;
this.types = Object.keys(items);
}
getType(type: string): Promise<Blob> {
const value = this.items[type];
if (value instanceof Blob) {
return Promise.resolve(value);
}
if (typeof value === "string") {
return Promise.resolve(new Blob([value], { type }));
}
return Promise.reject(
new Error(`Clipboard item is missing ${type} data`),
);
}
}
try {
Object.defineProperty(globalThis, "ClipboardItem", {
configurable: true,
value: ClipboardItemFallback,
});
} catch {
return;
}
}
}
+617 -1
View File
@@ -1,11 +1,18 @@
import { afterEach, expect, test, vi } from "vitest"; import { afterEach, expect, test, vi } from "vitest";
import { writeTextToClipboard } from "@/core/clipboard"; import {
installClipboardFallback,
writeTextToClipboard,
} from "@/core/clipboard";
const originalNavigator = globalThis.navigator; const originalNavigator = globalThis.navigator;
const hadOriginalNavigator = "navigator" in globalThis; const hadOriginalNavigator = "navigator" in globalThis;
const originalDocument = globalThis.document; const originalDocument = globalThis.document;
const hadOriginalDocument = "document" in globalThis; const hadOriginalDocument = "document" in globalThis;
const originalClipboardItemDescriptor = Object.getOwnPropertyDescriptor(
globalThis,
"ClipboardItem",
);
afterEach(() => { afterEach(() => {
vi.restoreAllMocks(); vi.restoreAllMocks();
@@ -26,6 +33,16 @@ afterEach(() => {
value: originalDocument, value: originalDocument,
}); });
} }
if (!originalClipboardItemDescriptor) {
Reflect.deleteProperty(globalThis, "ClipboardItem");
} else {
Object.defineProperty(
globalThis,
"ClipboardItem",
originalClipboardItemDescriptor,
);
}
}); });
test("writes text with the Clipboard API when available", async () => { test("writes text with the Clipboard API when available", async () => {
@@ -90,6 +107,95 @@ test("falls back to execCommand when Clipboard API is unavailable", async () =>
expect(textarea.remove).toHaveBeenCalled(); expect(textarea.remove).toHaveBeenCalled();
}); });
test("falls back to parent removal when textarea.remove is unavailable", async () => {
const parentNode = {
removeChild: vi.fn(),
};
const textarea = {
parentNode,
select: vi.fn(),
setAttribute: vi.fn(),
style: {},
value: "",
};
const execCommand = vi.fn().mockReturnValue(true);
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: {},
});
Object.defineProperty(globalThis, "document", {
configurable: true,
value: {
body: {
appendChild: vi.fn(),
},
createElement: vi.fn().mockReturnValue(textarea),
execCommand,
},
});
await expect(writeTextToClipboard("hello")).resolves.toBe(true);
expect(parentNode.removeChild).toHaveBeenCalledWith(textarea);
});
test("does not fail cleanup when textarea removal APIs are unavailable", async () => {
const textarea = {
parentNode: {},
select: vi.fn(),
setAttribute: vi.fn(),
style: {},
value: "",
};
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: {},
});
Object.defineProperty(globalThis, "document", {
configurable: true,
value: {
body: {
appendChild: vi.fn(),
},
createElement: vi.fn().mockReturnValue(textarea),
execCommand: vi.fn().mockReturnValue(true),
},
});
await expect(writeTextToClipboard("hello")).resolves.toBe(true);
});
test("cleans up the textarea when selecting text fails", async () => {
const textarea = {
remove: vi.fn(),
select: vi.fn(() => {
throw new Error("selection failed");
}),
setAttribute: vi.fn(),
style: {},
value: "",
};
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: {},
});
Object.defineProperty(globalThis, "document", {
configurable: true,
value: {
body: {
appendChild: vi.fn(),
},
createElement: vi.fn().mockReturnValue(textarea),
execCommand: vi.fn(),
},
});
await expect(writeTextToClipboard("hello")).resolves.toBe(false);
expect(textarea.remove).toHaveBeenCalled();
});
test("returns false when execCommand fallback fails", async () => { test("returns false when execCommand fallback fails", async () => {
const textarea = { const textarea = {
remove: vi.fn(), remove: vi.fn(),
@@ -118,6 +224,24 @@ test("returns false when execCommand fallback fails", async () => {
expect(textarea.remove).toHaveBeenCalled(); expect(textarea.remove).toHaveBeenCalled();
}); });
test("returns false when execCommand fallback cannot create an element", async () => {
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: {},
});
Object.defineProperty(globalThis, "document", {
configurable: true,
value: {
body: {
appendChild: vi.fn(),
},
execCommand: vi.fn(),
},
});
await expect(writeTextToClipboard("hello")).resolves.toBe(false);
});
test("returns false when navigator is unavailable", async () => { test("returns false when navigator is unavailable", async () => {
Object.defineProperty(globalThis, "navigator", { Object.defineProperty(globalThis, "navigator", {
configurable: true, configurable: true,
@@ -144,3 +268,495 @@ test("returns false when Clipboard API rejects", async () => {
await expect(writeTextToClipboard("hello")).resolves.toBe(false); await expect(writeTextToClipboard("hello")).resolves.toBe(false);
}); });
test("installs a writeText fallback when Clipboard API is unavailable", async () => {
const textarea = {
remove: vi.fn(),
select: vi.fn(),
setAttribute: vi.fn(),
style: {},
value: "",
};
const appendChild = vi.fn();
const execCommand = vi.fn().mockReturnValue(true);
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: {},
});
Object.defineProperty(globalThis, "document", {
configurable: true,
value: {
body: {
appendChild,
},
createElement: vi.fn().mockReturnValue(textarea),
execCommand,
},
});
installClipboardFallback();
await expect(globalThis.navigator.clipboard.writeText("hello")).resolves.toBe(
undefined,
);
expect(textarea.value).toBe("hello");
expect(appendChild).toHaveBeenCalledWith(textarea);
expect(textarea.select).toHaveBeenCalled();
expect(execCommand).toHaveBeenCalledWith("copy");
expect(textarea.remove).toHaveBeenCalled();
});
test("installed writeText fallback rejects instead of throwing synchronously", async () => {
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: {},
});
Object.defineProperty(globalThis, "document", {
configurable: true,
value: undefined,
});
installClipboardFallback();
const result = globalThis.navigator.clipboard.writeText("hello");
expect(result).toBeInstanceOf(Promise);
await expect(result).rejects.toThrow("Clipboard DOM fallback not available");
});
test("installed writeText fallback converts thrown DOM failures to rejections", async () => {
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: {},
});
Object.defineProperty(globalThis, "document", {
configurable: true,
value: {
body: {
appendChild: vi.fn(),
},
createElement: vi.fn(() => {
throw new Error("dom unavailable");
}),
execCommand: vi.fn(),
},
});
installClipboardFallback();
const result = globalThis.navigator.clipboard.writeText("hello");
expect(result).toBeInstanceOf(Promise);
await expect(result).rejects.toThrow("dom unavailable");
});
test("installed writeText fallback distinguishes copy command failure", async () => {
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: {},
});
Object.defineProperty(globalThis, "document", {
configurable: true,
value: {
body: {
appendChild: vi.fn(),
},
createElement: vi.fn().mockReturnValue({
remove: vi.fn(),
select: vi.fn(),
setAttribute: vi.fn(),
style: {},
value: "",
}),
execCommand: vi.fn().mockReturnValue(false),
},
});
installClipboardFallback();
await expect(
globalThis.navigator.clipboard.writeText("hello"),
).rejects.toThrow("Clipboard copy command failed");
});
test("installs a write fallback for ClipboardItem text/plain payloads", async () => {
const textarea = {
remove: vi.fn(),
select: vi.fn(),
setAttribute: vi.fn(),
style: {},
value: "",
};
const execCommand = vi.fn().mockReturnValue(true);
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: {},
});
Object.defineProperty(globalThis, "document", {
configurable: true,
value: {
body: {
appendChild: vi.fn(),
},
createElement: vi.fn().mockReturnValue(textarea),
execCommand,
},
});
Reflect.deleteProperty(globalThis, "ClipboardItem");
installClipboardFallback();
const item = new globalThis.ClipboardItem({
"text/html": new Blob(["<table></table>"], { type: "text/html" }),
"text/plain": "| A |\n| B |",
});
await expect(globalThis.navigator.clipboard.write([item])).resolves.toBe(
undefined,
);
expect(textarea.value).toBe("| A |\n| B |");
expect(execCommand).toHaveBeenCalledWith("copy");
});
test("installed write fallback rejects when ClipboardItem lacks text/plain", async () => {
const execCommand = vi.fn().mockReturnValue(true);
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: {},
});
Object.defineProperty(globalThis, "document", {
configurable: true,
value: {
body: {
appendChild: vi.fn(),
},
createElement: vi.fn().mockReturnValue({
remove: vi.fn(),
select: vi.fn(),
setAttribute: vi.fn(),
style: {},
value: "",
}),
execCommand,
},
});
Reflect.deleteProperty(globalThis, "ClipboardItem");
installClipboardFallback();
const item = new globalThis.ClipboardItem({
"text/html": new Blob(["<table></table>"], { type: "text/html" }),
});
await expect(globalThis.navigator.clipboard.write([item])).rejects.toThrow(
"Clipboard item is missing text/plain data",
);
expect(execCommand).not.toHaveBeenCalled();
});
test("installed write fallback rejects when getType cannot provide text/plain", async () => {
const execCommand = vi.fn().mockReturnValue(true);
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: {},
});
Object.defineProperty(globalThis, "document", {
configurable: true,
value: {
body: {
appendChild: vi.fn(),
},
createElement: vi.fn().mockReturnValue({
remove: vi.fn(),
select: vi.fn(),
setAttribute: vi.fn(),
style: {},
value: "",
}),
execCommand,
},
});
installClipboardFallback();
await expect(
globalThis.navigator.clipboard.write([
{
getType: vi.fn().mockRejectedValue(new Error("missing")),
types: ["text/plain"],
} as unknown as ClipboardItem,
]),
).rejects.toThrow("missing");
expect(execCommand).not.toHaveBeenCalled();
});
test("installed write fallback rejects before getType when item types exclude text/plain", async () => {
const getType = vi.fn().mockResolvedValue(new Blob(["ignored"]));
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: {},
});
Object.defineProperty(globalThis, "document", {
configurable: true,
value: undefined,
});
installClipboardFallback();
await expect(
globalThis.navigator.clipboard.write([
{
getType,
types: ["text/html"],
} as unknown as ClipboardItem,
]),
).rejects.toThrow("Clipboard item is missing text/plain data");
expect(getType).not.toHaveBeenCalled();
});
test("installed write fallback rejects when getType is missing", async () => {
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: {},
});
Object.defineProperty(globalThis, "document", {
configurable: true,
value: undefined,
});
installClipboardFallback();
await expect(
globalThis.navigator.clipboard.write([
{
types: ["text/plain"],
} as unknown as ClipboardItem,
]),
).rejects.toThrow("Clipboard item cannot read text/plain data");
});
test("installed write fallback rejects when getType returns a non-Blob", async () => {
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: {},
});
Object.defineProperty(globalThis, "document", {
configurable: true,
value: undefined,
});
installClipboardFallback();
await expect(
globalThis.navigator.clipboard.write([
{
getType: vi.fn().mockResolvedValue("plain text"),
types: ["text/plain"],
} as unknown as ClipboardItem,
]),
).rejects.toThrow("Clipboard item text/plain data is not a Blob");
});
test("installed write fallback preserves existing clipboard prototype methods", async () => {
const readText = vi.fn().mockResolvedValue("existing");
const clipboard = Object.create({
readText,
});
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: {
clipboard,
},
});
Object.defineProperty(globalThis, "document", {
configurable: true,
value: undefined,
});
Reflect.deleteProperty(globalThis, "ClipboardItem");
installClipboardFallback();
expect(globalThis.navigator.clipboard).toBe(clipboard);
await expect(globalThis.navigator.clipboard.readText()).resolves.toBe(
"existing",
);
expect(readText).toHaveBeenCalled();
await expect(
globalThis.navigator.clipboard.writeText("hello"),
).rejects.toThrow("Clipboard DOM fallback not available");
});
test("installClipboardFallback does not replace existing clipboard methods when only ClipboardItem is missing", async () => {
const write = vi.fn().mockResolvedValue(undefined);
const writeText = vi.fn().mockResolvedValue(undefined);
const clipboard = {
write,
writeText,
};
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: {
clipboard,
},
});
Reflect.deleteProperty(globalThis, "ClipboardItem");
installClipboardFallback();
expect(globalThis.navigator.clipboard).toBe(clipboard);
expect(Reflect.get(globalThis.navigator.clipboard, "write")).toBe(write);
expect(Reflect.get(globalThis.navigator.clipboard, "writeText")).toBe(
writeText,
);
expect(typeof globalThis.ClipboardItem).toBe("function");
});
test("installClipboardFallback is idempotent for the same navigator", async () => {
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: {},
});
Object.defineProperty(globalThis, "document", {
configurable: true,
value: undefined,
});
Reflect.deleteProperty(globalThis, "ClipboardItem");
installClipboardFallback();
const clipboard = globalThis.navigator.clipboard;
const ClipboardItemFallback = globalThis.ClipboardItem;
installClipboardFallback();
expect(globalThis.navigator.clipboard).toBe(clipboard);
expect(globalThis.ClipboardItem).toBe(ClipboardItemFallback);
});
test("installClipboardFallback can recover when the same navigator loses fallback globals", async () => {
const navigator = {};
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: navigator,
});
Object.defineProperty(globalThis, "document", {
configurable: true,
value: undefined,
});
Reflect.deleteProperty(globalThis, "ClipboardItem");
installClipboardFallback();
Reflect.deleteProperty(globalThis, "ClipboardItem");
Reflect.deleteProperty(navigator, "clipboard");
installClipboardFallback();
expect(typeof globalThis.navigator.clipboard.writeText).toBe("function");
expect(typeof globalThis.ClipboardItem).toBe("function");
});
test("installClipboardFallback defines writable fallback methods", async () => {
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: {},
});
Object.defineProperty(globalThis, "document", {
configurable: true,
value: undefined,
});
installClipboardFallback();
expect(
Object.getOwnPropertyDescriptor(globalThis.navigator.clipboard, "write")
?.writable,
).toBe(true);
expect(
Object.getOwnPropertyDescriptor(globalThis.navigator.clipboard, "writeText")
?.writable,
).toBe(true);
});
test("installClipboardFallback skips missing clipboard on non-extensible navigator while installing ClipboardItem", async () => {
const navigator = {};
Object.preventExtensions(navigator);
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: navigator,
});
Reflect.deleteProperty(globalThis, "ClipboardItem");
installClipboardFallback();
expect("clipboard" in globalThis.navigator).toBe(false);
expect(typeof globalThis.ClipboardItem).toBe("function");
});
test("installClipboardFallback handles non-object navigator.clipboard values", async () => {
const navigator = {};
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: "locked",
});
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: navigator,
});
Object.defineProperty(globalThis, "document", {
configurable: true,
value: undefined,
});
installClipboardFallback();
expect(typeof globalThis.navigator.clipboard.writeText).toBe("function");
await expect(
globalThis.navigator.clipboard.writeText("hello"),
).rejects.toThrow("Clipboard DOM fallback not available");
});
test("installClipboardFallback does not throw when ClipboardItem cannot be defined", async () => {
const originalDefineProperty = Object.defineProperty;
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: {},
});
Object.defineProperty(globalThis, "document", {
configurable: true,
value: undefined,
});
Reflect.deleteProperty(globalThis, "ClipboardItem");
vi.spyOn(Object, "defineProperty").mockImplementation(
(target, property, descriptor) => {
if (target === globalThis && property === "ClipboardItem") {
throw new Error("locked global");
}
return originalDefineProperty(target, property, descriptor);
},
);
expect(() => installClipboardFallback()).not.toThrow();
expect(typeof globalThis.navigator.clipboard.writeText).toBe("function");
expect("ClipboardItem" in globalThis).toBe(false);
});
test("installs ClipboardItem fallback when the global property exists but is unusable", async () => {
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: {
clipboard: {
write: vi.fn().mockResolvedValue(undefined),
writeText: vi.fn().mockResolvedValue(undefined),
},
},
});
Object.defineProperty(globalThis, "ClipboardItem", {
configurable: true,
value: undefined,
});
installClipboardFallback();
expect(typeof globalThis.ClipboardItem).toBe("function");
});