diff --git a/.changeset/clipboard-http-fallback.md b/.changeset/clipboard-http-fallback.md
new file mode 100644
index 0000000000..bd3fbcb2b5
--- /dev/null
+++ b/.changeset/clipboard-http-fallback.md
@@ -0,0 +1,5 @@
+---
+"@hyperdx/app": patch
+---
+
+Fix copy buttons silently failing when HyperDX is served over plain HTTP. Add a `document.execCommand('copy')` fallback for non-secure contexts and a clear toast when both paths fail.
diff --git a/packages/app/src/components/DBRowJsonViewer.test.tsx b/packages/app/src/components/DBRowJsonViewer.test.tsx
index 161a5abfe6..b7fd2a65ca 100644
--- a/packages/app/src/components/DBRowJsonViewer.test.tsx
+++ b/packages/app/src/components/DBRowJsonViewer.test.tsx
@@ -1,5 +1,5 @@
import React from 'react';
-import { fireEvent, screen, within } from '@testing-library/react';
+import { fireEvent, screen, waitFor, within } from '@testing-library/react';
import { buildJSONExtractQuery, DBRowJsonViewer } from './DBRowJsonViewer';
import { RowSidePanelContext } from './DBRowSidePanel';
@@ -159,40 +159,56 @@ describe('DBRowJsonViewer', () => {
const mockClipboard = jest.fn();
beforeEach(() => {
+ // copyTextToClipboard short-circuits to the execCommand fallback unless
+ // both `window.isSecureContext` is true AND `navigator.clipboard.writeText`
+ // exists, so seed both for these unit tests.
+ Object.defineProperty(window, 'isSecureContext', {
+ value: true,
+ writable: true,
+ configurable: true,
+ });
+ mockClipboard.mockReset();
+ mockClipboard.mockResolvedValue(undefined);
Object.assign(navigator, {
clipboard: { writeText: mockClipboard },
});
});
- it('copies array elements from expanded stringified JSON', () => {
+ it('copies array elements from expanded stringified JSON', async () => {
const arrayObject = { status: 'True', type: 'PodReady' };
const data = { conditions: JSON.stringify([arrayObject]) };
renderComponent(data);
expandAndClickButton('conditions', '0', 'Copy Object');
- expect(mockClipboard).toHaveBeenCalledWith(
- JSON.stringify(arrayObject, null, 2),
- );
+ await waitFor(() => {
+ expect(mockClipboard).toHaveBeenCalledWith(
+ JSON.stringify(arrayObject, null, 2),
+ );
+ });
});
- it('copies entire stringified value when not expanded', () => {
+ it('copies entire stringified value when not expanded', async () => {
const arrayData = [{ type: 'Ready' }, { type: 'Init' }];
const data = { conditions: JSON.stringify(arrayData) };
renderComponent(data);
clickLineButton('conditions', 'Copy Value');
- expect(mockClipboard).toHaveBeenCalledWith(JSON.stringify(arrayData));
+ await waitFor(() => {
+ expect(mockClipboard).toHaveBeenCalledWith(JSON.stringify(arrayData));
+ });
});
- it('copies regular nested objects', () => {
+ it('copies regular nested objects', async () => {
renderComponent(logData);
clickLineButton('nested', 'Copy Object');
- expect(mockClipboard).toHaveBeenCalledWith(
- JSON.stringify({ field3: 'nested value' }, null, 2),
- );
+ await waitFor(() => {
+ expect(mockClipboard).toHaveBeenCalledWith(
+ JSON.stringify({ field3: 'nested value' }, null, 2),
+ );
+ });
});
});
diff --git a/packages/app/src/components/DBRowJsonViewer.tsx b/packages/app/src/components/DBRowJsonViewer.tsx
index f1eac9fa43..ba3552f7fd 100644
--- a/packages/app/src/components/DBRowJsonViewer.tsx
+++ b/packages/app/src/components/DBRowJsonViewer.tsx
@@ -29,6 +29,7 @@ import {
import HyperJson, { GetLineActions, LineAction } from '@/components/HyperJson';
import { mergePath } from '@/utils';
+import { copyTextWithToast } from '@/utils/clipboard';
type JSONExtractFn =
| 'JSONExtractString'
@@ -219,16 +220,14 @@ function HyperJsonMenu({ rowData }: { rowData: any }) {
{rowData != null && (
{
- window.navigator.clipboard.writeText(
+ void copyTextWithToast(
typeof rowData === 'string'
? rowData
: JSON.stringify(rowData, null, 2),
+ 'Value copied to clipboard',
);
- notifications.show({
- color: 'green',
- message: `Value copied to clipboard`,
- });
}}
variant="copy"
title={'Copy row as JSON'}
@@ -559,13 +558,10 @@ export function DBRowJsonViewer({
copiedObj = keyPath.length === 0 ? rowData : get(rowData, keyPath);
}
- window.navigator.clipboard.writeText(
+ void copyTextWithToast(
JSON.stringify(copiedObj, null, 2),
+ 'Copied object to clipboard',
);
- notifications.show({
- color: 'green',
- message: `Copied object to clipboard`,
- });
};
if (typeof value === 'object') {
@@ -584,15 +580,12 @@ export function DBRowJsonViewer({
),
onClick: () => {
- window.navigator.clipboard.writeText(
+ void copyTextWithToast(
typeof value === 'string'
? value
: JSON.stringify(value, null, 2),
+ 'Value copied to clipboard',
);
- 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..43382b3cce 100644
--- a/packages/app/src/components/DBTable/DBRowTableFieldWithPopover.tsx
+++ b/packages/app/src/components/DBTable/DBRowTableFieldWithPopover.tsx
@@ -4,6 +4,8 @@ import { Popover } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { IconCopy, IconFilter, IconFilterX } from '@tabler/icons-react';
+import { copyTextWithToast } from '@/utils/clipboard';
+
import { RowSidePanelContext } from '../DBRowSidePanel';
import { DBRowTableIconButton } from './DBRowTableIconButton';
@@ -32,16 +34,27 @@ const DBRowTableFieldWithPopover = ({
const [hoverDisabled, setHoverDisabled] = useState(false);
const timeoutRef = useRef(undefined);
const hoverDisableTimeoutRef = useRef(undefined);
-
- // Cleanup timeouts on unmount to prevent memory leaks
+ // Tracks the post-copy "isCopied -> isCopied=false" reset so unmount can
+ // cancel it (popover and its parent row are virtualised; either can recycle
+ // mid-await).
+ const copyResetTimeoutRef = useRef(undefined);
+ const isMountedRef = useRef(true);
+
+ // Cleanup timeouts on unmount to prevent memory leaks and setState-after-
+ // unmount warnings.
useEffect(() => {
+ isMountedRef.current = true;
return () => {
+ isMountedRef.current = false;
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
if (hoverDisableTimeoutRef.current) {
clearTimeout(hoverDisableTimeoutRef.current);
}
+ if (copyResetTimeoutRef.current) {
+ clearTimeout(copyResetTimeoutRef.current);
+ }
};
}, []);
@@ -83,16 +96,19 @@ 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 ok = await copyTextWithToast(value, 'Copied field value');
+ if (!ok || !isMountedRef.current) return;
+ setIsCopied(true);
+ if (copyResetTimeoutRef.current) {
+ clearTimeout(copyResetTimeoutRef.current);
}
+ copyResetTimeoutRef.current = setTimeout(() => {
+ if (isMountedRef.current) {
+ setIsCopied(false);
+ }
+ }, 2000);
};
const addFilter = () => {
@@ -160,6 +176,7 @@ const DBRowTableFieldWithPopover = ({
tabIndex={-1}
aria-hidden="true"
style={{ cursor: 'pointer' }}
+ data-testid="field-popover-trigger"
>
{children}
@@ -182,6 +199,7 @@ const DBRowTableFieldWithPopover = ({
variant="copy"
isActive={isCopied}
title={isCopied ? 'Copied!' : 'Copy field value'}
+ data-testid="field-copy-value-button"
>
diff --git a/packages/app/src/components/DBTable/DBRowTableIconButton.tsx b/packages/app/src/components/DBTable/DBRowTableIconButton.tsx
index 0a36409c9a..f4c87e7c5d 100644
--- a/packages/app/src/components/DBTable/DBRowTableIconButton.tsx
+++ b/packages/app/src/components/DBTable/DBRowTableIconButton.tsx
@@ -14,6 +14,7 @@ interface DBRowTableIconButtonProps {
variant?: 'copy' | 'default';
isActive?: boolean;
iconSize?: number;
+ 'data-testid'?: string;
}
export const DBRowTableIconButton: React.FC = ({
@@ -25,6 +26,7 @@ export const DBRowTableIconButton: React.FC = ({
variant = 'default',
isActive = false,
iconSize = 14,
+ 'data-testid': dataTestId,
}) => {
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation();
@@ -55,6 +57,7 @@ export const DBRowTableIconButton: React.FC = ({
className={baseClasses}
tabIndex={tabIndex}
role="button"
+ data-testid={dataTestId}
>
{isActive ? : children}
diff --git a/packages/app/src/components/DBTable/DBRowTableRowButtons.tsx b/packages/app/src/components/DBTable/DBRowTableRowButtons.tsx
index 6e0fc23fd2..1d472e935f 100644
--- a/packages/app/src/components/DBTable/DBRowTableRowButtons.tsx
+++ b/packages/app/src/components/DBTable/DBRowTableRowButtons.tsx
@@ -1,7 +1,8 @@
-import React, { useState } from 'react';
+import React, { useEffect, useRef, useState } from 'react';
import { IconCopy, IconLink, IconTextWrap } from '@tabler/icons-react';
import { INTERNAL_ROW_FIELDS, RowWhereResult } from '@/hooks/useRowWhere';
+import { copyTextWithToast } from '@/utils/clipboard';
import { DBRowTableIconButton } from './DBRowTableIconButton';
@@ -24,60 +25,74 @@ const DBRowTableRowButtons: React.FC = ({
}) => {
const [isCopied, setIsCopied] = useState(false);
const [isUrlCopied, setIsUrlCopied] = useState(false);
+ // Rows are virtualised and recycle on scroll; cancel pending state resets on
+ // unmount so we don't setState on a recycled row.
+ const copyResetRef = useRef(undefined);
+ const urlCopyResetRef = useRef(undefined);
+ const isMountedRef = useRef(true);
- const copyRowData = async () => {
- try {
- // Filter out internal metadata fields that start with __ or are generated IDs
+ useEffect(() => {
+ isMountedRef.current = true;
+ return () => {
+ isMountedRef.current = false;
+ if (copyResetRef.current) clearTimeout(copyResetRef.current);
+ if (urlCopyResetRef.current) clearTimeout(urlCopyResetRef.current);
+ };
+ }, []);
- const { [INTERNAL_ROW_FIELDS.ID]: _id, ...cleanRow } = row;
+ const copyRowData = async () => {
+ // Filter out internal metadata fields that start with __ or are generated IDs
+ const { [INTERNAL_ROW_FIELDS.ID]: _id, ...cleanRow } = row;
- // Parse JSON string fields to make them proper JSON objects
- const parsedRow = Object.entries(cleanRow).reduce(
- (acc, [key, value]) => {
- if (
- typeof value === 'string' &&
- (value.startsWith('{') || value.startsWith('['))
- ) {
- try {
- acc[key] = JSON.parse(value);
- } catch {
- // If parsing fails, keep the original string
- acc[key] = value;
- }
- } else {
+ // Parse JSON string fields to make them proper JSON objects
+ const parsedRow = Object.entries(cleanRow).reduce(
+ (acc, [key, value]) => {
+ if (
+ typeof value === 'string' &&
+ (value.startsWith('{') || value.startsWith('['))
+ ) {
+ try {
+ acc[key] = JSON.parse(value);
+ } catch {
+ // If parsing fails, keep the original string
acc[key] = value;
}
- return acc;
- },
- {} as Record,
- );
+ } else {
+ acc[key] = value;
+ }
+ return acc;
+ },
+ {} as Record,
+ );
- const rowData = JSON.stringify(parsedRow, null, 2);
- await navigator.clipboard.writeText(rowData);
- 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
- }
+ const rowData = JSON.stringify(parsedRow, null, 2);
+ const ok = await copyTextWithToast(rowData, 'Copied row as JSON');
+ if (!ok || !isMountedRef.current) return;
+ setIsCopied(true);
+ if (copyResetRef.current) clearTimeout(copyResetRef.current);
+ copyResetRef.current = setTimeout(() => {
+ if (isMountedRef.current) setIsCopied(false);
+ }, 2000);
};
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 ok = await copyTextWithToast(
+ currentUrl.toString(),
+ 'Copied shareable link',
+ );
+ if (!ok || !isMountedRef.current) return;
+ setIsUrlCopied(true);
+ if (urlCopyResetRef.current) clearTimeout(urlCopyResetRef.current);
+ urlCopyResetRef.current = setTimeout(() => {
+ if (isMountedRef.current) setIsUrlCopied(false);
+ }, 2000);
};
return (
@@ -98,6 +113,7 @@ const DBRowTableRowButtons: React.FC = ({
title={
isCopied ? 'Copied entire row as JSON!' : 'Copy entire row as JSON'
}
+ data-testid="row-copy-json-button"
>
@@ -110,6 +126,7 @@ const DBRowTableRowButtons: React.FC = ({
? 'Copied shareable link!'
: 'Copy shareable link to this specific row'
}
+ data-testid="row-copy-link-button"
>
diff --git a/packages/app/src/components/HyperJson.tsx b/packages/app/src/components/HyperJson.tsx
index 4f8ee4c236..72d566f954 100644
--- a/packages/app/src/components/HyperJson.tsx
+++ b/packages/app/src/components/HyperJson.tsx
@@ -55,35 +55,55 @@ const ValueRenderer = React.memo(
}
if (isString(value)) {
return (
-
+
{value}
);
}
if (isNumber(value)) {
return (
-
+
{value}
);
}
if (isBoolean(value)) {
return (
-
+
{value ? 'true' : 'false'}
);
}
if (isPlainObject(value)) {
return (
-
+
{'{}'} {Object.keys(value).length} keys
);
}
if (isArray(value)) {
return (
-
+
{'[]'} {value.length} items
);
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..517b5b6749
--- /dev/null
+++ b/packages/app/src/utils/__tests__/clipboard.test.ts
@@ -0,0 +1,300 @@
+import { copyTextToClipboard, copyTextWithToast } from '../clipboard';
+
+const mantineShow = jest.fn();
+jest.mock('@mantine/notifications', () => ({
+ notifications: {
+ show: (...args: any[]) => mantineShow(...args),
+ },
+}));
+
+const FAILURE_MESSAGE =
+ "Couldn't copy to clipboard. If you're on plain HTTP, switch to HTTPS or localhost.";
+
+function setSecureContext(secure: boolean) {
+ Object.defineProperty(window, 'isSecureContext', {
+ value: secure,
+ writable: true,
+ configurable: true,
+ });
+}
+
+describe('copyTextToClipboard', () => {
+ let warnSpy: jest.SpyInstance;
+
+ beforeEach(() => {
+ mantineShow.mockClear();
+ Object.defineProperty(navigator, 'clipboard', {
+ value: undefined,
+ writable: true,
+ configurable: true,
+ });
+ setSecureContext(true);
+ document.execCommand = jest.fn().mockReturnValue(true);
+ warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
+ });
+
+ afterEach(() => {
+ warnSpy.mockRestore();
+ });
+
+ it('uses navigator.clipboard when secure-context with the API', async () => {
+ const writeText = jest.fn().mockResolvedValue(undefined);
+ Object.defineProperty(navigator, 'clipboard', {
+ value: { writeText },
+ writable: true,
+ configurable: true,
+ });
+ const execSpy = document.execCommand as jest.Mock;
+
+ const result = await copyTextToClipboard('hello');
+
+ expect(result).toBe(true);
+ expect(writeText).toHaveBeenCalledWith('hello');
+ expect(execSpy).not.toHaveBeenCalled();
+ });
+
+ it('routes straight to execCommand when isSecureContext is false (preserves user activation)', async () => {
+ const writeText = jest.fn().mockResolvedValue(undefined);
+ // Even though writeText exists, a non-secure-context page should not
+ // burn the click activation on an awaited modern attempt.
+ Object.defineProperty(navigator, 'clipboard', {
+ value: { writeText },
+ writable: true,
+ configurable: true,
+ });
+ setSecureContext(false);
+ const execSpy = document.execCommand as jest.Mock;
+
+ const result = await copyTextToClipboard('hello');
+
+ expect(result).toBe(true);
+ expect(writeText).not.toHaveBeenCalled();
+ expect(execSpy).toHaveBeenCalledWith('copy');
+ });
+
+ it('falls back to execCommand when navigator.clipboard.writeText rejects', async () => {
+ const writeText = jest
+ .fn()
+ .mockRejectedValue(new Error('permission denied'));
+ Object.defineProperty(navigator, 'clipboard', {
+ value: { writeText },
+ writable: true,
+ configurable: true,
+ });
+ const execSpy = document.execCommand as jest.Mock;
+
+ const result = await copyTextToClipboard('hello');
+
+ expect(result).toBe(true);
+ expect(writeText).toHaveBeenCalledWith('hello');
+ expect(execSpy).toHaveBeenCalledWith('copy');
+ expect(warnSpy).toHaveBeenCalledWith(
+ expect.stringContaining('writeText failed'),
+ expect.any(Error),
+ );
+ });
+
+ it('falls back to execCommand when navigator.clipboard is undefined', async () => {
+ const execSpy = document.execCommand as jest.Mock;
+
+ const result = await copyTextToClipboard('hello');
+
+ expect(result).toBe(true);
+ expect(execSpy).toHaveBeenCalledWith('copy');
+ });
+
+ it('returns false when both paths fail', async () => {
+ const writeText = jest.fn().mockRejectedValue(new Error('denied'));
+ Object.defineProperty(navigator, 'clipboard', {
+ value: { writeText },
+ writable: true,
+ configurable: true,
+ });
+ document.execCommand = jest.fn().mockReturnValue(false);
+
+ const result = await copyTextToClipboard('hello');
+
+ expect(result).toBe(false);
+ });
+
+ it('removes the textarea from the DOM after the fallback runs', async () => {
+ const result = await copyTextToClipboard('hello');
+
+ expect(result).toBe(true);
+ expect(document.querySelectorAll('textarea').length).toBe(0);
+ });
+
+ it('removes the textarea even when execCommand throws', async () => {
+ document.execCommand = jest.fn().mockImplementation(() => {
+ throw new Error('boom');
+ });
+
+ const result = await copyTextToClipboard('hello');
+
+ expect(result).toBe(false);
+ expect(document.querySelectorAll('textarea').length).toBe(0);
+ expect(warnSpy).toHaveBeenCalledWith(
+ expect.stringContaining('execCommand fallback failed'),
+ expect.any(Error),
+ );
+ });
+
+ it('restores the existing selection after the fallback runs', async () => {
+ const p = document.createElement('p');
+ p.textContent = 'preserved selection target';
+ document.body.appendChild(p);
+
+ const range = document.createRange();
+ range.selectNodeContents(p);
+
+ const selection = document.getSelection();
+ if (!selection) {
+ throw new Error('jsdom should provide getSelection');
+ }
+ selection.removeAllRanges();
+ selection.addRange(range);
+
+ const result = await copyTextToClipboard('hello');
+
+ expect(result).toBe(true);
+ expect(selection.rangeCount).toBe(1);
+ expect(selection.getRangeAt(0).toString()).toBe(
+ 'preserved selection target',
+ );
+
+ document.body.removeChild(p);
+ });
+
+ it('restores the previously focused element after the fallback runs', async () => {
+ const input = document.createElement('input');
+ input.type = 'text';
+ document.body.appendChild(input);
+ input.focus();
+ expect(document.activeElement).toBe(input);
+
+ const result = await copyTextToClipboard('hello');
+
+ expect(result).toBe(true);
+ expect(document.activeElement).toBe(input);
+
+ document.body.removeChild(input);
+ });
+
+ it('refuses to fall back for payloads larger than the size cap', async () => {
+ const big = 'x'.repeat(1_000_001);
+ const execSpy = document.execCommand as jest.Mock;
+
+ const result = await copyTextToClipboard(big);
+
+ expect(result).toBe(false);
+ expect(execSpy).not.toHaveBeenCalled();
+ expect(warnSpy).toHaveBeenCalledWith(
+ expect.stringContaining('fallback refused'),
+ );
+ });
+
+ it('still uses the modern API for large payloads when available', async () => {
+ const writeText = jest.fn().mockResolvedValue(undefined);
+ Object.defineProperty(navigator, 'clipboard', {
+ value: { writeText },
+ writable: true,
+ configurable: true,
+ });
+ const big = 'x'.repeat(1_000_001);
+
+ const result = await copyTextToClipboard(big);
+
+ expect(result).toBe(true);
+ expect(writeText).toHaveBeenCalledWith(big);
+ });
+
+ it('handles empty-string input correctly', async () => {
+ const writeText = jest.fn().mockResolvedValue(undefined);
+ Object.defineProperty(navigator, 'clipboard', {
+ value: { writeText },
+ writable: true,
+ configurable: true,
+ });
+
+ const result = await copyTextToClipboard('');
+
+ expect(result).toBe(true);
+ expect(writeText).toHaveBeenCalledWith('');
+ });
+});
+
+describe('copyTextWithToast', () => {
+ beforeEach(() => {
+ mantineShow.mockClear();
+ Object.defineProperty(navigator, 'clipboard', {
+ value: undefined,
+ writable: true,
+ configurable: true,
+ });
+ setSecureContext(true);
+ document.execCommand = jest.fn().mockReturnValue(true);
+ jest.spyOn(console, 'warn').mockImplementation(() => {});
+ });
+
+ afterEach(() => {
+ jest.restoreAllMocks();
+ });
+
+ it('shows a green success toast on success', async () => {
+ const writeText = jest.fn().mockResolvedValue(undefined);
+ Object.defineProperty(navigator, 'clipboard', {
+ value: { writeText },
+ writable: true,
+ configurable: true,
+ });
+
+ const result = await copyTextWithToast('hello');
+
+ expect(result).toBe(true);
+ expect(mantineShow).toHaveBeenCalledWith({
+ color: 'green',
+ message: 'Copied to clipboard',
+ });
+ });
+
+ it('uses a custom success message when provided', async () => {
+ const writeText = jest.fn().mockResolvedValue(undefined);
+ Object.defineProperty(navigator, 'clipboard', {
+ value: { writeText },
+ writable: true,
+ configurable: true,
+ });
+
+ await copyTextWithToast('hello', 'Value copied to clipboard');
+
+ expect(mantineShow).toHaveBeenCalledWith({
+ color: 'green',
+ message: 'Value copied to clipboard',
+ });
+ });
+
+ it('shows a red failure toast when both paths fail', async () => {
+ document.execCommand = jest.fn().mockReturnValue(false);
+
+ const result = await copyTextWithToast('hello');
+
+ expect(result).toBe(false);
+ expect(mantineShow).toHaveBeenCalledWith({
+ color: 'red',
+ message: FAILURE_MESSAGE,
+ });
+ });
+
+ it('shows a red failure toast for oversized payloads on non-secure contexts', async () => {
+ setSecureContext(false);
+ const big = 'x'.repeat(1_000_001);
+
+ const result = await copyTextWithToast(big);
+
+ expect(result).toBe(false);
+ expect(mantineShow).toHaveBeenCalledWith({
+ color: 'red',
+ message: FAILURE_MESSAGE,
+ });
+ });
+});
diff --git a/packages/app/src/utils/clipboard.ts b/packages/app/src/utils/clipboard.ts
new file mode 100644
index 0000000000..285e3a9a04
--- /dev/null
+++ b/packages/app/src/utils/clipboard.ts
@@ -0,0 +1,144 @@
+import { notifications } from '@mantine/notifications';
+
+/**
+ * Refuse to fall back to `document.execCommand('copy')` for payloads larger
+ * than this. The fallback path is fully synchronous (textarea + select +
+ * execCommand) and freezes the main thread for multi-MB JSON copies.
+ */
+const FALLBACK_MAX_BYTES = 1_000_000;
+
+/**
+ * Copy text to the clipboard, with a fallback for non-secure contexts.
+ *
+ * `navigator.clipboard` is only defined when the page is served over HTTPS
+ * or `localhost`. When HyperDX is reached over plain HTTP (Tailscale tunnel,
+ * corporate VPN, non-localhost host), the modern API is undefined and any
+ * call to `navigator.clipboard.writeText` throws.
+ *
+ * In non-secure contexts we route straight to the synchronous textarea +
+ * `document.execCommand('copy')` fallback. Doing the modern attempt first
+ * would burn the click's user-activation token on an awaited rejection, so
+ * the fallback that runs afterwards has lost activation and silently fails.
+ *
+ * Returns `true` on success, `false` if both paths fail or the payload is
+ * larger than the fallback limit and the modern path isn't available.
+ */
+export async function copyTextToClipboard(text: string): Promise {
+ if (isSecureContextWithClipboard()) {
+ try {
+ await navigator.clipboard.writeText(text);
+ return true;
+ } catch (err) {
+ // Permission denied / sandboxed iframe / another race. Log and fall
+ // through to the legacy fallback so we still have a chance to copy.
+
+ console.warn(
+ 'clipboard writeText failed; trying execCommand fallback',
+ err,
+ );
+ }
+ }
+
+ if (text.length > FALLBACK_MAX_BYTES) {
+ console.warn(
+ `clipboard fallback refused: payload ${text.length} > ${FALLBACK_MAX_BYTES} bytes`,
+ );
+ return false;
+ }
+
+ return execCommandFallback(text);
+}
+
+function isSecureContextWithClipboard(): boolean {
+ if (typeof window === 'undefined' || typeof navigator === 'undefined') {
+ return false;
+ }
+ // `isSecureContext` is true on HTTPS and on `http://localhost`. Browsers
+ // only expose `navigator.clipboard.writeText` in secure contexts; checking
+ // both guards against the rare browser that exposes the API but blocks
+ // the call.
+ return (
+ window.isSecureContext === true && Boolean(navigator.clipboard?.writeText)
+ );
+}
+
+function execCommandFallback(text: string): boolean {
+ if (typeof document === 'undefined') {
+ return false;
+ }
+
+ // Capture focus and selection state before we mount the scratch textarea so
+ // we can restore both in the finally block. Use `instanceof HTMLElement` so
+ // SVG / MathML elements (which subclass `Element` but not `HTMLElement`)
+ // don't slip through.
+ const active = document.activeElement;
+ const previouslyFocused = active instanceof HTMLElement ? active : null;
+ const selection = document.getSelection();
+ const previousRange =
+ selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null;
+
+ 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.opacity = '0';
+ textarea.style.pointerEvents = 'none';
+
+ document.body.appendChild(textarea);
+ textarea.select();
+
+ let succeeded = false;
+ try {
+ succeeded = document.execCommand('copy');
+ } catch (err) {
+ console.warn('clipboard execCommand fallback failed', err);
+ succeeded = false;
+ } finally {
+ document.body.removeChild(textarea);
+ if (previousRange && selection) {
+ selection.removeAllRanges();
+ selection.addRange(previousRange);
+ }
+ // Restore focus so typing context is not silently lost on the HTTP
+ // fallback path.
+ previouslyFocused?.focus();
+ }
+
+ return succeeded;
+}
+
+/**
+ * Failure message shown when the modern API is unavailable AND the fallback
+ * path also fails. The wording deliberately leaves room for permission-denied
+ * and sandboxed-iframe cases (it doesn't assert "you must switch to HTTPS"),
+ * since the secure-context check above means we only reach this message via
+ * an actual fallback failure, not via a never-attempted secure context.
+ */
+const FAILURE_MESSAGE =
+ "Couldn't copy to clipboard. If you're on plain HTTP, switch to HTTPS or localhost.";
+
+/**
+ * Copy text and show a Mantine toast on success or failure. Mirrors the
+ * `notifications.show` pattern already used elsewhere in the app for copy
+ * actions.
+ */
+export async function copyTextWithToast(
+ text: string,
+ successMessage = 'Copied to clipboard',
+): Promise {
+ const ok = await copyTextToClipboard(text);
+ if (ok) {
+ notifications.show({
+ color: 'green',
+ message: successMessage,
+ });
+ } else {
+ notifications.show({
+ color: 'red',
+ message: FAILURE_MESSAGE,
+ });
+ }
+ return ok;
+}
diff --git a/packages/app/tests/e2e/features/search/clipboard.spec.ts b/packages/app/tests/e2e/features/search/clipboard.spec.ts
new file mode 100644
index 0000000000..e234876159
--- /dev/null
+++ b/packages/app/tests/e2e/features/search/clipboard.spec.ts
@@ -0,0 +1,396 @@
+/**
+ * Verifies the clipboard fallback (`packages/app/src/utils/clipboard.ts`)
+ * across the six call sites that previously called `navigator.clipboard.writeText`
+ * directly. See https://github.com/hyperdxio/hyperdx/issues/2135.
+ *
+ * Tests are hook-based: an init script wires `navigator.clipboard.writeText`
+ * and `document.execCommand('copy')` to a shared `window.__copyHistory` array
+ * so we can assert which path each call site actually took without depending
+ * on Chromium clipboard permissions or polluted state from previous tests.
+ *
+ * One test (`row JSON button writes through to the real OS clipboard via the
+ * fallback`) does NOT install the hook so it exercises the real
+ * `document.execCommand('copy')` path end-to-end and reads the result via
+ * `navigator.clipboard.readText()`. That keeps us honest: the hook only
+ * confirms code-path routing, not that the OS clipboard actually receives
+ * the text.
+ */
+import { Page } from '@playwright/test';
+
+import { SearchPage } from '../../page-objects/SearchPage';
+import { expect, test } from '../../utils/base-test';
+
+type CopyEntry = { source: 'modern' | 'fallback'; text: string };
+
+declare global {
+ interface Window {
+ __copyHistory: CopyEntry[];
+ __forceModernThrow: boolean;
+ __forceExecCommandFalse: boolean;
+ }
+}
+
+// Note: addInitScript serialises the function via .toString(), which strips
+// closure variables. Pass `mode` as the Playwright arg parameter so it is
+// properly serialised and available inside the page.
+async function installCopyHook(
+ page: Page,
+ mode: 'modern' | 'no-modern' | 'fail-both' = 'modern',
+) {
+ await page.addInitScript((m: string) => {
+ window.__copyHistory = [];
+ window.__forceModernThrow = false;
+ window.__forceExecCommandFalse = false;
+
+ const realExec = document.execCommand.bind(document);
+ document.execCommand = function (cmd: string, ...rest: any[]): boolean {
+ if (cmd === 'copy') {
+ const ta = document.activeElement as HTMLTextAreaElement | null;
+ const text = ta && 'value' in ta ? ta.value : '';
+ if (window.__forceExecCommandFalse) {
+ return false;
+ }
+ window.__copyHistory.push({ source: 'fallback', text });
+ return true;
+ }
+ return realExec(cmd, ...(rest as []));
+ };
+
+ if (m === 'modern') {
+ Object.defineProperty(window, 'isSecureContext', {
+ value: true,
+ configurable: true,
+ });
+ Object.defineProperty(navigator, 'clipboard', {
+ value: {
+ writeText: async (text: string) => {
+ if (window.__forceModernThrow) {
+ throw new Error('forced rejection');
+ }
+ window.__copyHistory.push({ source: 'modern', text });
+ },
+ readText: async () => {
+ const last = window.__copyHistory[window.__copyHistory.length - 1];
+ return last ? last.text : '';
+ },
+ },
+ configurable: true,
+ });
+ } else {
+ // Simulate non-secure context end-to-end: both `isSecureContext` and
+ // the clipboard API are absent so `copyTextToClipboard` routes
+ // straight to the synchronous execCommand fallback.
+ Object.defineProperty(window, 'isSecureContext', {
+ value: false,
+ configurable: true,
+ });
+ Object.defineProperty(navigator, 'clipboard', {
+ value: undefined,
+ configurable: true,
+ });
+ if (m === 'fail-both') {
+ window.__forceExecCommandFalse = true;
+ }
+ }
+ }, mode);
+}
+
+async function getCopyHistory(page: Page): Promise {
+ return await page.evaluate(() => window.__copyHistory ?? []);
+}
+
+const FAILURE_TOAST =
+ "Couldn't copy to clipboard. If you're on plain HTTP, switch to HTTPS or localhost.";
+
+async function openSearchAndFirstRow(searchPage: SearchPage) {
+ await searchPage.goto();
+ await searchPage.submitEmptySearch();
+ await expect(searchPage.table.firstRow).toBeVisible({ timeout: 10000 });
+}
+
+async function openParsedTab(searchPage: SearchPage) {
+ await openSearchAndFirstRow(searchPage);
+ await searchPage.table.clickFirstRow();
+ await searchPage.sidePanel.clickTab('parsed');
+}
+
+async function hoverFirstRowAndClickCopyJson(searchPage: SearchPage) {
+ await searchPage.table.firstRow.hover();
+ await searchPage.table.firstRow.getByTestId('row-copy-json-button').click();
+}
+
+test.describe('Clipboard fallback', { tag: ['@search'] }, () => {
+ test('row JSON button copies via the modern API and shows a success toast', async ({
+ page,
+ }) => {
+ await installCopyHook(page, 'modern');
+ const searchPage = new SearchPage(page);
+ await openSearchAndFirstRow(searchPage);
+
+ await hoverFirstRowAndClickCopyJson(searchPage);
+
+ await expect(page.getByText('Copied row as JSON')).toBeVisible();
+
+ const history = await getCopyHistory(page);
+ expect(history).toHaveLength(1);
+ expect(history[0].source).toBe('modern');
+ expect(() => JSON.parse(history[0].text)).not.toThrow();
+ });
+
+ test('row URL button copies a shareable link with rowWhere and rowSource params', async ({
+ page,
+ }) => {
+ await installCopyHook(page, 'modern');
+ const searchPage = new SearchPage(page);
+ await openSearchAndFirstRow(searchPage);
+
+ await searchPage.table.firstRow.hover();
+ await searchPage.table.firstRow.getByTestId('row-copy-link-button').click();
+
+ await expect(page.getByText('Copied shareable link')).toBeVisible();
+
+ const history = await getCopyHistory(page);
+ expect(history).toHaveLength(1);
+ expect(history[0].source).toBe('modern');
+ expect(history[0].text).toContain('rowWhere=');
+ });
+
+ test('row URL button falls back to execCommand when the modern API is unavailable', async ({
+ page,
+ }) => {
+ await installCopyHook(page, 'no-modern');
+ const searchPage = new SearchPage(page);
+ await openSearchAndFirstRow(searchPage);
+
+ await searchPage.table.firstRow.hover();
+ await searchPage.table.firstRow.getByTestId('row-copy-link-button').click();
+
+ await expect(page.getByText('Copied shareable link')).toBeVisible();
+
+ const history = await getCopyHistory(page);
+ expect(history).toHaveLength(1);
+ expect(history[0].source).toBe('fallback');
+ expect(history[0].text).toContain('rowWhere=');
+ });
+
+ test('field-value popover copy button uses the modern API and shows a success toast', async ({
+ page,
+ }) => {
+ await installCopyHook(page, 'modern');
+ const searchPage = new SearchPage(page);
+ await openSearchAndFirstRow(searchPage);
+
+ // Target the span that triggers DBRowTableFieldWithPopover directly.
+ // Hovering the outer td can miss the span's onMouseEnter (does not bubble).
+ const firstFieldTrigger = page.getByTestId('field-popover-trigger').first();
+ await firstFieldTrigger.hover();
+ const copyFieldButton = page.getByTestId('field-copy-value-button');
+ await expect(copyFieldButton).toBeVisible({ timeout: 10000 });
+ await copyFieldButton.click();
+
+ await expect(page.getByText('Copied field value')).toBeVisible();
+
+ const history = await getCopyHistory(page);
+ expect(history).toHaveLength(1);
+ expect(history[0].source).toBe('modern');
+ });
+
+ test('parsed-tab "Copy row as JSON" icon copies via the modern API', async ({
+ page,
+ }) => {
+ await installCopyHook(page, 'modern');
+ const searchPage = new SearchPage(page);
+ await openParsedTab(searchPage);
+
+ await page.getByTestId('json-viewer-copy-row').click();
+
+ await expect(
+ page.getByText('Value copied to clipboard').first(),
+ ).toBeVisible();
+
+ const history = await getCopyHistory(page);
+ expect(history.length).toBeGreaterThanOrEqual(1);
+ const last = history[history.length - 1];
+ expect(last.source).toBe('modern');
+ // Should be a JSON object string (parses cleanly).
+ expect(() => JSON.parse(last.text)).not.toThrow();
+ });
+
+ test('parsed-tab line action "Copy Value" copies via the modern API', async ({
+ page,
+ }) => {
+ await installCopyHook(page, 'modern');
+ const searchPage = new SearchPage(page);
+ await openParsedTab(searchPage);
+
+ const stringEl = page.getByTestId('hyperjson-value-string').first();
+ await expect(stringEl).toBeVisible();
+ await stringEl.hover();
+
+ await page
+ .getByRole('button', { name: /Copy Value/ })
+ .first()
+ .click();
+
+ await expect(
+ page.getByText('Value copied to clipboard').first(),
+ ).toBeVisible();
+
+ const history = await getCopyHistory(page);
+ expect(history.length).toBeGreaterThanOrEqual(1);
+ expect(history[history.length - 1].source).toBe('modern');
+ });
+
+ test('parsed-tab line action "Copy Object" copies a nested object as JSON', async ({
+ page,
+ }) => {
+ await installCopyHook(page, 'modern');
+ const searchPage = new SearchPage(page);
+ await openParsedTab(searchPage);
+
+ const objectEl = page.getByTestId('hyperjson-value-object').first();
+ await expect(objectEl).toBeVisible();
+ await objectEl.hover();
+
+ await page
+ .getByRole('button', { name: /Copy Object/ })
+ .first()
+ .click();
+
+ await expect(
+ page.getByText('Copied object to clipboard').first(),
+ ).toBeVisible();
+
+ const history = await getCopyHistory(page);
+ expect(history.length).toBeGreaterThanOrEqual(1);
+ const last = history[history.length - 1];
+ expect(last.source).toBe('modern');
+ // Copy Object always serialises a JSON object; assert it parses.
+ const parsed = JSON.parse(last.text);
+ expect(typeof parsed).toBe('object');
+ expect(parsed).not.toBeNull();
+ });
+
+ test('row JSON button falls back to execCommand when the modern API is unavailable', async ({
+ page,
+ }) => {
+ await installCopyHook(page, 'no-modern');
+ const searchPage = new SearchPage(page);
+ await openSearchAndFirstRow(searchPage);
+
+ await hoverFirstRowAndClickCopyJson(searchPage);
+
+ await expect(page.getByText('Copied row as JSON')).toBeVisible();
+
+ const history = await getCopyHistory(page);
+ expect(history).toHaveLength(1);
+ expect(history[0].source).toBe('fallback');
+ expect(() => JSON.parse(history[0].text)).not.toThrow();
+ });
+
+ test('row JSON button writes through to the real OS clipboard via the fallback', async ({
+ page,
+ browserName,
+ }) => {
+ test.skip(
+ browserName !== 'chromium',
+ 'Clipboard read permission flow tested only on chromium',
+ );
+
+ // Force the fallback path WITHOUT installing the hook. The browser's
+ // real document.execCommand('copy') should land the text on the OS
+ // clipboard, which we then read via navigator.clipboard.readText.
+ await page.addInitScript(() => {
+ Object.defineProperty(window, 'isSecureContext', {
+ value: false,
+ configurable: true,
+ });
+ });
+ await page
+ .context()
+ .grantPermissions(['clipboard-read', 'clipboard-write']);
+
+ const searchPage = new SearchPage(page);
+ await openSearchAndFirstRow(searchPage);
+
+ await hoverFirstRowAndClickCopyJson(searchPage);
+
+ await expect(page.getByText('Copied row as JSON')).toBeVisible();
+
+ // Re-grant clipboard permissions and read what landed on the OS clipboard.
+ const onClipboard = await page.evaluate(async () => {
+ try {
+ return await navigator.clipboard.readText();
+ } catch {
+ return '';
+ }
+ });
+ expect(onClipboard.length).toBeGreaterThan(0);
+ expect(() => JSON.parse(onClipboard)).not.toThrow();
+ });
+
+ test('row JSON button shows the failure toast when both paths fail and keeps isCopied false', async ({
+ page,
+ }) => {
+ const consoleErrors: string[] = [];
+ page.on('pageerror', err => consoleErrors.push(err.message));
+
+ await installCopyHook(page, 'fail-both');
+ const searchPage = new SearchPage(page);
+ await openSearchAndFirstRow(searchPage);
+
+ await hoverFirstRowAndClickCopyJson(searchPage);
+
+ await expect(page.getByText(FAILURE_TOAST)).toBeVisible();
+
+ // Regression test for the `if (ok) setIsCopied(true)` guard: the icon's
+ // tooltip must NOT switch to "Copied entire row as JSON!" on a failed
+ // copy. The Mantine Tooltip exposes the title via the wrapped div.
+ const copyJsonButton = searchPage.table.firstRow.getByTestId(
+ 'row-copy-json-button',
+ );
+ await expect(copyJsonButton).toBeVisible();
+ // The inner Tooltip target wraps the data-testid'd div. Inspect the
+ // sibling Tooltip-rendered label after hovering to surface the current
+ // tooltip text.
+ await copyJsonButton.hover();
+ await expect(
+ page.getByText('Copy entire row as JSON', { exact: true }),
+ ).toBeVisible();
+ await expect(
+ page.getByText('Copied entire row as JSON!', { exact: true }),
+ ).toBeHidden();
+
+ const history = await getCopyHistory(page);
+ expect(history).toHaveLength(0);
+ expect(consoleErrors).toEqual([]);
+ });
+
+ test('field-value popover shows the failure toast when both paths fail', async ({
+ page,
+ }) => {
+ await installCopyHook(page, 'fail-both');
+ const searchPage = new SearchPage(page);
+ await openSearchAndFirstRow(searchPage);
+
+ const firstCell = searchPage.table.firstRow.locator('td').nth(1);
+ await firstCell.hover();
+ const copyFieldButton = page.getByTestId('field-copy-value-button');
+ await expect(copyFieldButton).toBeVisible();
+ await copyFieldButton.click();
+
+ await expect(page.getByText(FAILURE_TOAST)).toBeVisible();
+ });
+
+ test('parsed-tab "Copy row as JSON" shows the failure toast when both paths fail', async ({
+ page,
+ }) => {
+ await installCopyHook(page, 'fail-both');
+ const searchPage = new SearchPage(page);
+ await openParsedTab(searchPage);
+
+ await page.getByTestId('json-viewer-copy-row').click();
+
+ await expect(page.getByText(FAILURE_TOAST)).toBeVisible();
+ });
+});