diff --git a/.changeset/clipboard-copy-fallback.md b/.changeset/clipboard-copy-fallback.md
new file mode 100644
index 0000000000..317dbac796
--- /dev/null
+++ b/.changeset/clipboard-copy-fallback.md
@@ -0,0 +1,6 @@
+---
+'@hyperdx/app': patch
+---
+
+Fall back when the browser Clipboard API is unavailable and show a clear error
+if copying still fails.
diff --git a/packages/app/src/components/DBRowJsonViewer.tsx b/packages/app/src/components/DBRowJsonViewer.tsx
index f1eac9fa43..65abd56115 100644
--- a/packages/app/src/components/DBRowJsonViewer.tsx
+++ b/packages/app/src/components/DBRowJsonViewer.tsx
@@ -29,6 +29,10 @@ import {
import HyperJson, { GetLineActions, LineAction } from '@/components/HyperJson';
import { mergePath } from '@/utils';
+import {
+ CLIPBOARD_ERROR_MESSAGE,
+ copyTextToClipboard,
+} from '@/utils/clipboard';
type JSONExtractFn =
| 'JSONExtractString'
@@ -219,12 +223,19 @@ function HyperJsonMenu({ rowData }: { rowData: any }) {
{rowData != null && (
{
- window.navigator.clipboard.writeText(
+ onClick={async () => {
+ const copied = await copyTextToClipboard(
typeof rowData === 'string'
? rowData
: JSON.stringify(rowData, null, 2),
);
+ if (!copied) {
+ notifications.show({
+ color: 'red',
+ message: CLIPBOARD_ERROR_MESSAGE,
+ });
+ return;
+ }
notifications.show({
color: 'green',
message: `Value copied to clipboard`,
@@ -547,7 +558,7 @@ export function DBRowJsonViewer({
});
}
- const handleCopyObject = () => {
+ const handleCopyObject = async () => {
let copiedObj;
// When in parsed JSON context (e.g., expanded stringified JSON),
@@ -559,9 +570,16 @@ export function DBRowJsonViewer({
copiedObj = keyPath.length === 0 ? rowData : get(rowData, keyPath);
}
- window.navigator.clipboard.writeText(
+ const copied = await copyTextToClipboard(
JSON.stringify(copiedObj, null, 2),
);
+ if (!copied) {
+ notifications.show({
+ color: 'red',
+ message: CLIPBOARD_ERROR_MESSAGE,
+ });
+ return;
+ }
notifications.show({
color: 'green',
message: `Copied object to clipboard`,
@@ -583,12 +601,19 @@ export function DBRowJsonViewer({
Copy Value
),
- onClick: () => {
- window.navigator.clipboard.writeText(
+ onClick: async () => {
+ const copied = await copyTextToClipboard(
typeof value === 'string'
? value
: JSON.stringify(value, null, 2),
);
+ if (!copied) {
+ notifications.show({
+ color: 'red',
+ message: CLIPBOARD_ERROR_MESSAGE,
+ });
+ return;
+ }
notifications.show({
color: 'green',
message: `Value copied to clipboard`,
diff --git a/packages/app/src/components/DBTable/DBRowTableFieldWithPopover.tsx b/packages/app/src/components/DBTable/DBRowTableFieldWithPopover.tsx
index 0f410db20e..a36fc15f37 100644
--- a/packages/app/src/components/DBTable/DBRowTableFieldWithPopover.tsx
+++ b/packages/app/src/components/DBTable/DBRowTableFieldWithPopover.tsx
@@ -2,8 +2,14 @@ import React, { useContext, useEffect, useRef, useState } from 'react';
import cx from 'classnames';
import { Popover } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
+import { notifications } from '@mantine/notifications';
import { IconCopy, IconFilter, IconFilterX } from '@tabler/icons-react';
+import {
+ CLIPBOARD_ERROR_MESSAGE,
+ copyTextToClipboard,
+} from '@/utils/clipboard';
+
import { RowSidePanelContext } from '../DBRowSidePanel';
import { DBRowTableIconButton } from './DBRowTableIconButton';
@@ -83,16 +89,18 @@ const DBRowTableFieldWithPopover = ({
};
const copyFieldValue = async () => {
- try {
- const value =
- typeof cellValue === 'string' ? cellValue : String(cellValue ?? '');
- await navigator.clipboard.writeText(value);
- setIsCopied(true);
- setTimeout(() => setIsCopied(false), 2000);
- } catch (error) {
- console.error('Failed to copy to clipboard:', error);
- // Optionally show an error toast notification to the user
+ const value =
+ typeof cellValue === 'string' ? cellValue : String(cellValue ?? '');
+ const copied = await copyTextToClipboard(value);
+ if (!copied) {
+ notifications.show({
+ color: 'red',
+ message: CLIPBOARD_ERROR_MESSAGE,
+ });
+ return;
}
+ setIsCopied(true);
+ setTimeout(() => setIsCopied(false), 2000);
};
const addFilter = () => {
diff --git a/packages/app/src/components/DBTable/DBRowTableRowButtons.tsx b/packages/app/src/components/DBTable/DBRowTableRowButtons.tsx
index 6e0fc23fd2..fb08da39e3 100644
--- a/packages/app/src/components/DBTable/DBRowTableRowButtons.tsx
+++ b/packages/app/src/components/DBTable/DBRowTableRowButtons.tsx
@@ -1,7 +1,12 @@
import React, { useState } from 'react';
+import { notifications } from '@mantine/notifications';
import { IconCopy, IconLink, IconTextWrap } from '@tabler/icons-react';
import { INTERNAL_ROW_FIELDS, RowWhereResult } from '@/hooks/useRowWhere';
+import {
+ CLIPBOARD_ERROR_MESSAGE,
+ copyTextToClipboard,
+} from '@/utils/clipboard';
import { DBRowTableIconButton } from './DBRowTableIconButton';
@@ -53,31 +58,42 @@ const DBRowTableRowButtons: React.FC = ({
);
const rowData = JSON.stringify(parsedRow, null, 2);
- await navigator.clipboard.writeText(rowData);
+ const copied = await copyTextToClipboard(rowData);
+ if (!copied) {
+ notifications.show({
+ color: 'red',
+ message: CLIPBOARD_ERROR_MESSAGE,
+ });
+ return;
+ }
setIsCopied(true);
setTimeout(() => setIsCopied(false), 2000);
- } catch (error) {
- console.error('Failed to copy row data to clipboard:', error);
- // Optionally show an error toast notification to the user
+ } catch {
+ notifications.show({
+ color: 'red',
+ message: CLIPBOARD_ERROR_MESSAGE,
+ });
}
};
const copyRowUrl = async () => {
- try {
- const rowWhereResult = getRowWhere(row);
- const currentUrl = new URL(window.location.href);
- // Add the row identifier as query parameters
- currentUrl.searchParams.set('rowWhere', rowWhereResult.where);
- if (sourceId) {
- currentUrl.searchParams.set('rowSource', sourceId);
- }
- await navigator.clipboard.writeText(currentUrl.toString());
- setIsUrlCopied(true);
- setTimeout(() => setIsUrlCopied(false), 2000);
- } catch (error) {
- console.error('Failed to copy URL to clipboard:', error);
- // Optionally show an error toast notification to the user
+ const rowWhereResult = getRowWhere(row);
+ const currentUrl = new URL(window.location.href);
+ // Add the row identifier as query parameters
+ currentUrl.searchParams.set('rowWhere', rowWhereResult.where);
+ if (sourceId) {
+ currentUrl.searchParams.set('rowSource', sourceId);
+ }
+ const copied = await copyTextToClipboard(currentUrl.toString());
+ if (!copied) {
+ notifications.show({
+ color: 'red',
+ message: CLIPBOARD_ERROR_MESSAGE,
+ });
+ return;
}
+ setIsUrlCopied(true);
+ setTimeout(() => setIsUrlCopied(false), 2000);
};
return (
diff --git a/packages/app/src/utils/__tests__/clipboard.test.ts b/packages/app/src/utils/__tests__/clipboard.test.ts
new file mode 100644
index 0000000000..a9fba724f9
--- /dev/null
+++ b/packages/app/src/utils/__tests__/clipboard.test.ts
@@ -0,0 +1,131 @@
+import { copyTextToClipboard } from '../clipboard';
+
+describe('copyTextToClipboard', () => {
+ const originalClipboard = navigator.clipboard;
+ const originalExecCommand = document.execCommand;
+
+ afterEach(() => {
+ Object.defineProperty(navigator, 'clipboard', {
+ configurable: true,
+ value: originalClipboard,
+ });
+ document.execCommand = originalExecCommand;
+ document.body.innerHTML = '';
+ jest.restoreAllMocks();
+ });
+
+ it('uses the Clipboard API when it is available', async () => {
+ const writeText = jest.fn().mockResolvedValue(undefined);
+ Object.defineProperty(navigator, 'clipboard', {
+ configurable: true,
+ value: { writeText },
+ });
+
+ await expect(copyTextToClipboard('hello')).resolves.toBe(true);
+
+ expect(writeText).toHaveBeenCalledWith('hello');
+ });
+
+ it('falls back to a textarea copy when the Clipboard API is unavailable', async () => {
+ Object.defineProperty(navigator, 'clipboard', {
+ configurable: true,
+ value: undefined,
+ });
+ document.execCommand = jest.fn().mockReturnValue(true);
+ const initialChildCount = document.body.childElementCount;
+
+ await expect(copyTextToClipboard('fallback text')).resolves.toBe(true);
+
+ expect(document.execCommand).toHaveBeenCalledWith('copy');
+ expect(document.body.childElementCount).toBe(initialChildCount);
+ });
+
+ it('falls back to a textarea copy when the Clipboard API rejects', async () => {
+ const writeText = jest.fn().mockRejectedValue(new Error('denied'));
+ Object.defineProperty(navigator, 'clipboard', {
+ configurable: true,
+ value: { writeText },
+ });
+ document.execCommand = jest.fn().mockReturnValue(true);
+
+ await expect(copyTextToClipboard('blocked')).resolves.toBe(true);
+
+ expect(writeText).toHaveBeenCalledWith('blocked');
+ expect(document.execCommand).toHaveBeenCalledWith('copy');
+ });
+
+ it('removes the fallback textarea when execCommand throws', async () => {
+ Object.defineProperty(navigator, 'clipboard', {
+ configurable: true,
+ value: undefined,
+ });
+ document.execCommand = jest.fn(() => {
+ throw new Error('copy failed');
+ });
+ const initialChildCount = document.body.childElementCount;
+
+ await expect(copyTextToClipboard('throwing copy')).resolves.toBe(false);
+
+ expect(document.body.childElementCount).toBe(initialChildCount);
+ });
+
+ it('restores the previous selection after the textarea fallback', async () => {
+ Object.defineProperty(navigator, 'clipboard', {
+ configurable: true,
+ value: undefined,
+ });
+ document.execCommand = jest.fn().mockReturnValue(true);
+ const selectedText = document.createTextNode('selected text');
+ const container = document.createElement('div');
+ container.appendChild(selectedText);
+ document.body.appendChild(container);
+ const range = document.createRange();
+ range.selectNodeContents(selectedText);
+ const selection = document.getSelection();
+ selection?.removeAllRanges();
+ selection?.addRange(range);
+
+ await expect(copyTextToClipboard('copy text')).resolves.toBe(true);
+
+ expect(selection?.rangeCount).toBe(1);
+ expect(selection?.getRangeAt(0).toString()).toBe('selected text');
+ });
+
+ it('still reports the copy result if selection restoration fails', async () => {
+ Object.defineProperty(navigator, 'clipboard', {
+ configurable: true,
+ value: undefined,
+ });
+ document.execCommand = jest.fn().mockReturnValue(true);
+ const selectedText = document.createTextNode('selected text');
+ const container = document.createElement('div');
+ container.appendChild(selectedText);
+ document.body.appendChild(container);
+ const range = document.createRange();
+ range.selectNodeContents(selectedText);
+ const selection = document.getSelection();
+ selection?.removeAllRanges();
+ selection?.addRange(range);
+ const addRange = jest
+ .spyOn(selection!, 'addRange')
+ .mockImplementation(() => {
+ throw new Error('range detached');
+ });
+
+ await expect(copyTextToClipboard('copy text')).resolves.toBe(true);
+
+ expect(addRange).toHaveBeenCalled();
+ });
+
+ it('reports failure when both copy methods are unavailable', async () => {
+ Object.defineProperty(navigator, 'clipboard', {
+ configurable: true,
+ value: undefined,
+ });
+ document.execCommand = jest.fn().mockReturnValue(false);
+
+ await expect(copyTextToClipboard('nope')).resolves.toBe(false);
+
+ expect(document.execCommand).toHaveBeenCalledWith('copy');
+ });
+});
diff --git a/packages/app/src/utils/clipboard.ts b/packages/app/src/utils/clipboard.ts
new file mode 100644
index 0000000000..1333823afa
--- /dev/null
+++ b/packages/app/src/utils/clipboard.ts
@@ -0,0 +1,69 @@
+export const CLIPBOARD_ERROR_MESSAGE =
+ 'Could not access the clipboard. Check browser permissions or use HTTPS.';
+
+export async function copyTextToClipboard(text: string): Promise {
+ if (navigator.clipboard?.writeText) {
+ try {
+ await navigator.clipboard.writeText(text);
+ return true;
+ } catch {
+ return copyTextWithTextarea(text);
+ }
+ }
+
+ return copyTextWithTextarea(text);
+}
+
+function copyTextWithTextarea(text: string): boolean {
+ if (typeof document === 'undefined' || !document.body) {
+ return false;
+ }
+
+ const textArea = document.createElement('textarea');
+ textArea.value = text;
+ textArea.setAttribute('readonly', '');
+ textArea.style.position = 'fixed';
+ textArea.style.top = '0';
+ textArea.style.left = '0';
+ textArea.style.width = '1px';
+ textArea.style.height = '1px';
+ textArea.style.padding = '0';
+ textArea.style.border = 'none';
+ textArea.style.outline = 'none';
+ textArea.style.boxShadow = 'none';
+ textArea.style.background = 'transparent';
+
+ const selection = document.getSelection();
+ const previousRange =
+ selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null;
+ const previousActiveElement =
+ document.activeElement instanceof HTMLElement
+ ? document.activeElement
+ : null;
+
+ document.body.appendChild(textArea);
+ textArea.select();
+ textArea.setSelectionRange(0, textArea.value.length);
+
+ let copied = false;
+ try {
+ copied = document.execCommand('copy');
+ } catch {
+ copied = false;
+ } finally {
+ document.body.removeChild(textArea);
+ if (previousActiveElement && document.contains(previousActiveElement)) {
+ previousActiveElement.focus({ preventScroll: true });
+ }
+ if (previousRange && selection) {
+ try {
+ selection.removeAllRanges();
+ selection.addRange(previousRange);
+ } catch {
+ // Ignore restore failures if the previous selection was detached.
+ }
+ }
+ }
+
+ return copied;
+}