From 374f013f3daf3fb05c1862da3e664480b7218075 Mon Sep 17 00:00:00 2001 From: AjTheSpidey Date: Thu, 21 May 2026 19:50:59 +0800 Subject: [PATCH 1/7] fix: copy current session share url --- .changeset/thin-bikes-share.md | 5 ++ packages/app/src/SessionSidePanel.tsx | 38 +++++---- .../src/__tests__/SessionSidePanel.test.tsx | 84 +++++++++++++++++++ 3 files changed, 109 insertions(+), 18 deletions(-) create mode 100644 .changeset/thin-bikes-share.md create mode 100644 packages/app/src/__tests__/SessionSidePanel.test.tsx diff --git a/.changeset/thin-bikes-share.md b/.changeset/thin-bikes-share.md new file mode 100644 index 0000000000..012ebac350 --- /dev/null +++ b/.changeset/thin-bikes-share.md @@ -0,0 +1,5 @@ +--- +'@hyperdx/app': patch +--- + +fix(app): copy the latest session URL from the Share Session button diff --git a/packages/app/src/SessionSidePanel.tsx b/packages/app/src/SessionSidePanel.tsx index 15a8ad907c..2b501b34e3 100644 --- a/packages/app/src/SessionSidePanel.tsx +++ b/packages/app/src/SessionSidePanel.tsx @@ -1,5 +1,4 @@ import { useCallback, useMemo, useState } from 'react'; -import CopyToClipboard from 'react-copy-to-clipboard'; import { useHotkeys } from 'react-hotkeys-hook'; import { DateRange, @@ -17,6 +16,10 @@ import { getInitialDrawerWidthPercent, } from '@/components/DrawerUtils'; import useResizable from '@/hooks/useResizable'; +import { + CLIPBOARD_ERROR_MESSAGE, + copyTextToClipboard, +} from '@/utils/clipboard'; import { Session } from './sessions'; import SessionSubpanel from './SessionSubpanel'; @@ -58,6 +61,14 @@ export default function SessionSidePanel({ setSize(isFullWidth ? getInitialDrawerWidthPercent() : 100); }, [isFullWidth, setSize]); + const handleShareSession = useCallback(async () => { + const copied = await copyTextToClipboard(window.location.href); + notifications.show({ + color: copied ? 'green' : 'red', + message: copied ? 'Copied link to clipboard' : CLIPBOARD_ERROR_MESSAGE, + }); + }, []); + useHotkeys( ['esc'], () => { @@ -125,24 +136,15 @@ export default function SessionSidePanel({ isFullWidth={isFullWidth} onToggle={toggleFullWidth} /> - { - notifications.show({ - color: 'green', - message: 'Copied link to clipboard', - }); - }} + - + Share Session + diff --git a/packages/app/src/__tests__/SessionSidePanel.test.tsx b/packages/app/src/__tests__/SessionSidePanel.test.tsx new file mode 100644 index 0000000000..efd13311c9 --- /dev/null +++ b/packages/app/src/__tests__/SessionSidePanel.test.tsx @@ -0,0 +1,84 @@ +import { fireEvent, screen, waitFor } from '@testing-library/react'; + +import SessionSidePanel from '../SessionSidePanel'; +import { copyTextToClipboard } from '../utils/clipboard'; + +jest.mock( + '../SessionSubpanel', + () => + function MockSessionSubpanel() { + return
; + }, +); + +jest.mock('@/hooks/useResizable', () => ({ + __esModule: true, + default: () => ({ + size: 50, + setSize: jest.fn(), + startResize: jest.fn(), + }), +})); + +jest.mock('../utils/clipboard', () => ({ + CLIPBOARD_ERROR_MESSAGE: 'Could not access the clipboard.', + copyTextToClipboard: jest.fn(), +})); + +const copyTextToClipboardMock = copyTextToClipboard as jest.Mock; + +function renderPanel() { + return renderWithMantine( + , + ); +} + +describe('SessionSidePanel', () => { + beforeEach(() => { + copyTextToClipboardMock.mockResolvedValue(true); + window.history.pushState( + {}, + '', + '/sessions?sessionSource=source-1&from=1&to=2', + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('copies the current session URL when the share button is clicked', async () => { + renderPanel(); + + window.history.pushState( + {}, + '', + '/sessions?sessionSource=source-1&from=1&to=2&sid=session-1&sfrom=10&sto=20', + ); + + fireEvent.click(screen.getByRole('button', { name: /share session/i })); + + await waitFor(() => { + expect(copyTextToClipboardMock).toHaveBeenCalledWith( + 'http://localhost/sessions?sessionSource=source-1&from=1&to=2&sid=session-1&sfrom=10&sto=20', + ); + }); + }); +}); From dce5f0f8bdbc7660a5c3a1112323bcddbcdd542c Mon Sep 17 00:00:00 2001 From: AjTheSpidey Date: Thu, 21 May 2026 21:03:06 +0800 Subject: [PATCH 2/7] test: cover session share notifications --- .../src/__tests__/SessionSidePanel.test.tsx | 32 +++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/packages/app/src/__tests__/SessionSidePanel.test.tsx b/packages/app/src/__tests__/SessionSidePanel.test.tsx index efd13311c9..56aa3892c2 100644 --- a/packages/app/src/__tests__/SessionSidePanel.test.tsx +++ b/packages/app/src/__tests__/SessionSidePanel.test.tsx @@ -1,7 +1,11 @@ +import { notifications } from '@mantine/notifications'; import { fireEvent, screen, waitFor } from '@testing-library/react'; import SessionSidePanel from '../SessionSidePanel'; -import { copyTextToClipboard } from '../utils/clipboard'; +import { + CLIPBOARD_ERROR_MESSAGE, + copyTextToClipboard, +} from '../utils/clipboard'; jest.mock( '../SessionSubpanel', @@ -21,11 +25,14 @@ jest.mock('@/hooks/useResizable', () => ({ })); jest.mock('../utils/clipboard', () => ({ - CLIPBOARD_ERROR_MESSAGE: 'Could not access the clipboard.', + ...jest.requireActual('../utils/clipboard'), copyTextToClipboard: jest.fn(), })); const copyTextToClipboardMock = copyTextToClipboard as jest.Mock; +const notificationsShowSpy = jest + .spyOn(notifications, 'show') + .mockImplementation(jest.fn()); function renderPanel() { return renderWithMantine( @@ -79,6 +86,27 @@ describe('SessionSidePanel', () => { expect(copyTextToClipboardMock).toHaveBeenCalledWith( 'http://localhost/sessions?sessionSource=source-1&from=1&to=2&sid=session-1&sfrom=10&sto=20', ); + expect(notificationsShowSpy).toHaveBeenCalledWith({ + color: 'green', + message: 'Copied link to clipboard', + }); + }); + }); + + it('shows an error notification when copying the session URL fails', async () => { + copyTextToClipboardMock.mockResolvedValue(false); + renderPanel(); + + fireEvent.click(screen.getByRole('button', { name: /share session/i })); + + await waitFor(() => { + expect(copyTextToClipboardMock).toHaveBeenCalledWith( + 'http://localhost/sessions?sessionSource=source-1&from=1&to=2', + ); + expect(notificationsShowSpy).toHaveBeenCalledWith({ + color: 'red', + message: CLIPBOARD_ERROR_MESSAGE, + }); }); }); }); From 14351922b6ed17e733dcfe07c63403179fb55f2a Mon Sep 17 00:00:00 2001 From: AjTheSpidey Date: Thu, 21 May 2026 22:57:16 +0800 Subject: [PATCH 3/7] fix: avoid duplicate session share copies --- packages/app/src/SessionSidePanel.tsx | 22 ++++++++++---- .../src/__tests__/SessionSidePanel.test.tsx | 29 +++++++++++++++++++ 2 files changed, 45 insertions(+), 6 deletions(-) diff --git a/packages/app/src/SessionSidePanel.tsx b/packages/app/src/SessionSidePanel.tsx index 2b501b34e3..5f21c26c20 100644 --- a/packages/app/src/SessionSidePanel.tsx +++ b/packages/app/src/SessionSidePanel.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useState } from 'react'; +import { useCallback, useMemo, useRef, useState } from 'react'; import { useHotkeys } from 'react-hotkeys-hook'; import { DateRange, @@ -52,6 +52,7 @@ export default function SessionSidePanel({ }) { // Keep track of sub-drawers so we can disable closing this root drawer const [subDrawerOpen, setSubDrawerOpen] = useState(false); + const isSharingSessionRef = useRef(false); const { size, setSize, startResize } = useResizable( getInitialDrawerWidthPercent(), @@ -62,11 +63,20 @@ export default function SessionSidePanel({ }, [isFullWidth, setSize]); const handleShareSession = useCallback(async () => { - const copied = await copyTextToClipboard(window.location.href); - notifications.show({ - color: copied ? 'green' : 'red', - message: copied ? 'Copied link to clipboard' : CLIPBOARD_ERROR_MESSAGE, - }); + if (isSharingSessionRef.current) { + return; + } + + isSharingSessionRef.current = true; + try { + const copied = await copyTextToClipboard(window.location.href); + notifications.show({ + color: copied ? 'green' : 'red', + message: copied ? 'Copied link to clipboard' : CLIPBOARD_ERROR_MESSAGE, + }); + } finally { + isSharingSessionRef.current = false; + } }, []); useHotkeys( diff --git a/packages/app/src/__tests__/SessionSidePanel.test.tsx b/packages/app/src/__tests__/SessionSidePanel.test.tsx index 56aa3892c2..ec1d53fc29 100644 --- a/packages/app/src/__tests__/SessionSidePanel.test.tsx +++ b/packages/app/src/__tests__/SessionSidePanel.test.tsx @@ -109,4 +109,33 @@ describe('SessionSidePanel', () => { }); }); }); + + it('ignores duplicate share clicks while copying is still pending', async () => { + let finishCopy: (copied: boolean) => void = (_copied: boolean): void => { + throw new Error('copy promise was not created'); + }; + copyTextToClipboardMock.mockImplementation( + () => + new Promise(resolve => { + finishCopy = resolve; + }), + ); + renderPanel(); + + const shareButton = screen.getByRole('button', { name: /share session/i }); + fireEvent.click(shareButton); + fireEvent.click(shareButton); + + expect(copyTextToClipboardMock).toHaveBeenCalledTimes(1); + + finishCopy(true); + + await waitFor(() => { + expect(notificationsShowSpy).toHaveBeenCalledTimes(1); + expect(notificationsShowSpy).toHaveBeenCalledWith({ + color: 'green', + message: 'Copied link to clipboard', + }); + }); + }); }); From 07c3edc0c96370da41a4a3eba516080ab3bbc547 Mon Sep 17 00:00:00 2001 From: AjTheSpidey Date: Thu, 21 May 2026 23:19:15 +0800 Subject: [PATCH 4/7] test: cover session share retry paths --- packages/app/src/SessionSidePanel.tsx | 13 ++++--- .../src/__tests__/SessionSidePanel.test.tsx | 37 +++++++++++++++++++ 2 files changed, 45 insertions(+), 5 deletions(-) diff --git a/packages/app/src/SessionSidePanel.tsx b/packages/app/src/SessionSidePanel.tsx index 5f21c26c20..9a0bab9001 100644 --- a/packages/app/src/SessionSidePanel.tsx +++ b/packages/app/src/SessionSidePanel.tsx @@ -68,15 +68,18 @@ export default function SessionSidePanel({ } isSharingSessionRef.current = true; + let copied = false; try { - const copied = await copyTextToClipboard(window.location.href); - notifications.show({ - color: copied ? 'green' : 'red', - message: copied ? 'Copied link to clipboard' : CLIPBOARD_ERROR_MESSAGE, - }); + copied = await copyTextToClipboard(window.location.href); + } catch { + copied = false; } finally { isSharingSessionRef.current = false; } + notifications.show({ + color: copied ? 'green' : 'red', + message: copied ? 'Copied link to clipboard' : CLIPBOARD_ERROR_MESSAGE, + }); }, []); useHotkeys( diff --git a/packages/app/src/__tests__/SessionSidePanel.test.tsx b/packages/app/src/__tests__/SessionSidePanel.test.tsx index ec1d53fc29..f3f8aae3f1 100644 --- a/packages/app/src/__tests__/SessionSidePanel.test.tsx +++ b/packages/app/src/__tests__/SessionSidePanel.test.tsx @@ -110,6 +110,33 @@ describe('SessionSidePanel', () => { }); }); + it('shows an error notification when the clipboard helper rejects', async () => { + copyTextToClipboardMock + .mockRejectedValueOnce(new Error('copy failed')) + .mockResolvedValueOnce(true); + renderPanel(); + + const shareButton = screen.getByRole('button', { name: /share session/i }); + fireEvent.click(shareButton); + + await waitFor(() => { + expect(notificationsShowSpy).toHaveBeenCalledWith({ + color: 'red', + message: CLIPBOARD_ERROR_MESSAGE, + }); + }); + + fireEvent.click(shareButton); + + await waitFor(() => { + expect(copyTextToClipboardMock).toHaveBeenCalledTimes(2); + expect(notificationsShowSpy).toHaveBeenCalledWith({ + color: 'green', + message: 'Copied link to clipboard', + }); + }); + }); + it('ignores duplicate share clicks while copying is still pending', async () => { let finishCopy: (copied: boolean) => void = (_copied: boolean): void => { throw new Error('copy promise was not created'); @@ -137,5 +164,15 @@ describe('SessionSidePanel', () => { message: 'Copied link to clipboard', }); }); + + fireEvent.click(shareButton); + + expect(copyTextToClipboardMock).toHaveBeenCalledTimes(2); + + finishCopy(true); + + await waitFor(() => { + expect(notificationsShowSpy).toHaveBeenCalledTimes(2); + }); }); }); From d0417bf2cdea74f00346bee22d60190984690b54 Mon Sep 17 00:00:00 2001 From: AjTheSpidey Date: Sat, 23 May 2026 02:38:44 +0800 Subject: [PATCH 5/7] fix: skip stale share notifications --- packages/app/src/SessionSidePanel.tsx | 12 +++++++++- .../src/__tests__/SessionSidePanel.test.tsx | 22 +++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/packages/app/src/SessionSidePanel.tsx b/packages/app/src/SessionSidePanel.tsx index 9a0bab9001..84e38c4f63 100644 --- a/packages/app/src/SessionSidePanel.tsx +++ b/packages/app/src/SessionSidePanel.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useHotkeys } from 'react-hotkeys-hook'; import { DateRange, @@ -53,6 +53,13 @@ export default function SessionSidePanel({ // Keep track of sub-drawers so we can disable closing this root drawer const [subDrawerOpen, setSubDrawerOpen] = useState(false); const isSharingSessionRef = useRef(false); + const isMountedRef = useRef(true); + + useEffect(() => { + return () => { + isMountedRef.current = false; + }; + }, []); const { size, setSize, startResize } = useResizable( getInitialDrawerWidthPercent(), @@ -76,6 +83,9 @@ export default function SessionSidePanel({ } finally { isSharingSessionRef.current = false; } + if (!isMountedRef.current) { + return; + } notifications.show({ color: copied ? 'green' : 'red', message: copied ? 'Copied link to clipboard' : CLIPBOARD_ERROR_MESSAGE, diff --git a/packages/app/src/__tests__/SessionSidePanel.test.tsx b/packages/app/src/__tests__/SessionSidePanel.test.tsx index f3f8aae3f1..d85b349418 100644 --- a/packages/app/src/__tests__/SessionSidePanel.test.tsx +++ b/packages/app/src/__tests__/SessionSidePanel.test.tsx @@ -175,4 +175,26 @@ describe('SessionSidePanel', () => { expect(notificationsShowSpy).toHaveBeenCalledTimes(2); }); }); + + it('does not show a share notification after the panel unmounts', async () => { + let finishCopy: (copied: boolean) => void = (_copied: boolean): void => { + throw new Error('copy promise was not created'); + }; + copyTextToClipboardMock.mockImplementation( + () => + new Promise(resolve => { + finishCopy = resolve; + }), + ); + const { unmount } = renderPanel(); + + fireEvent.click(screen.getByRole('button', { name: /share session/i })); + + expect(copyTextToClipboardMock).toHaveBeenCalledTimes(1); + + unmount(); + finishCopy(true); + await new Promise(resolve => setTimeout(resolve, 0)); + expect(notificationsShowSpy).not.toHaveBeenCalled(); + }); }); From be021e53a2012475bac2c802da5563d23b5e9591 Mon Sep 17 00:00:00 2001 From: AjTheSpidey Date: Sat, 23 May 2026 03:23:46 +0800 Subject: [PATCH 6/7] fix: show share copy progress --- packages/app/src/SessionSidePanel.tsx | 10 +- .../src/__tests__/SessionSidePanel.test.tsx | 133 ++++++++++++------ 2 files changed, 101 insertions(+), 42 deletions(-) diff --git a/packages/app/src/SessionSidePanel.tsx b/packages/app/src/SessionSidePanel.tsx index 84e38c4f63..0b6b898fcb 100644 --- a/packages/app/src/SessionSidePanel.tsx +++ b/packages/app/src/SessionSidePanel.tsx @@ -52,10 +52,13 @@ export default function SessionSidePanel({ }) { // Keep track of sub-drawers so we can disable closing this root drawer const [subDrawerOpen, setSubDrawerOpen] = useState(false); + const [isSharingSession, setIsSharingSession] = useState(false); const isSharingSessionRef = useRef(false); const isMountedRef = useRef(true); useEffect(() => { + isMountedRef.current = true; + return () => { isMountedRef.current = false; }; @@ -75,13 +78,15 @@ export default function SessionSidePanel({ } isSharingSessionRef.current = true; + setIsSharingSession(true); let copied = false; try { copied = await copyTextToClipboard(window.location.href); - } catch { - copied = false; } finally { isSharingSessionRef.current = false; + if (isMountedRef.current) { + setIsSharingSession(false); + } } if (!isMountedRef.current) { return; @@ -164,6 +169,7 @@ export default function SessionSidePanel({ size="sm" leftSection={} style={{ fontSize: '12px' }} + loading={isSharingSession} onClick={handleShareSession} > Share Session diff --git a/packages/app/src/__tests__/SessionSidePanel.test.tsx b/packages/app/src/__tests__/SessionSidePanel.test.tsx index d85b349418..4264a2221d 100644 --- a/packages/app/src/__tests__/SessionSidePanel.test.tsx +++ b/packages/app/src/__tests__/SessionSidePanel.test.tsx @@ -1,6 +1,13 @@ +import { StrictMode } from 'react'; +import { + SourceKind, + TSessionSource, + TTraceSource, +} from '@hyperdx/common-utils/dist/types'; import { notifications } from '@mantine/notifications'; import { fireEvent, screen, waitFor } from '@testing-library/react'; +import { Session } from '../sessions'; import SessionSidePanel from '../SessionSidePanel'; import { CLIPBOARD_ERROR_MESSAGE, @@ -29,32 +36,75 @@ jest.mock('../utils/clipboard', () => ({ copyTextToClipboard: jest.fn(), })); -const copyTextToClipboardMock = copyTextToClipboard as jest.Mock; +const copyTextToClipboardMock = jest.mocked(copyTextToClipboard); const notificationsShowSpy = jest .spyOn(notifications, 'show') .mockImplementation(jest.fn()); -function renderPanel() { - return renderWithMantine( +const traceSource = { + id: 'trace-source', + name: 'Trace Source', + kind: SourceKind.Trace, + connection: 'clickhouse', + from: { + databaseName: 'default', + tableName: 'traces', + }, + timestampValueExpression: 'Timestamp', + defaultTableSelectExpression: '*', + durationExpression: 'Duration', + durationPrecision: 9, + traceIdExpression: 'TraceId', + spanIdExpression: 'SpanId', + parentSpanIdExpression: 'ParentSpanId', + spanNameExpression: 'SpanName', + spanKindExpression: 'SpanKind', +} satisfies TTraceSource; + +const sessionSource = { + id: 'session-source', + name: 'Session Source', + kind: SourceKind.Session, + connection: 'clickhouse', + from: { + databaseName: 'default', + tableName: 'sessions', + }, + timestampValueExpression: 'Timestamp', + traceSourceId: traceSource.id, +} satisfies TSessionSource; + +const session = { + userEmail: 'user@example.com', + maxTimestamp: '2026-05-21T10:00:00Z', + minTimestamp: '2026-05-21T09:00:00Z', + errorCount: '0', + interactionCount: '0', + recordingCount: '0', + serviceName: 'web', + sessionCount: '12', + sessionId: 'session-1', + teamId: 'team-1', + teamName: 'Team', + userName: 'User', +} satisfies Session; + +function renderPanel({ strict = false }: { strict?: boolean } = {}) { + const panel = ( , + /> ); + + return renderWithMantine(strict ? {panel} : panel); } describe('SessionSidePanel', () => { @@ -91,50 +141,37 @@ describe('SessionSidePanel', () => { message: 'Copied link to clipboard', }); }); - }); - it('shows an error notification when copying the session URL fails', async () => { - copyTextToClipboardMock.mockResolvedValue(false); - renderPanel(); + window.history.pushState( + {}, + '', + '/sessions?sessionSource=source-1&from=1&to=2&sid=session-1&sfrom=30&sto=40', + ); fireEvent.click(screen.getByRole('button', { name: /share session/i })); await waitFor(() => { - expect(copyTextToClipboardMock).toHaveBeenCalledWith( - 'http://localhost/sessions?sessionSource=source-1&from=1&to=2', + expect(copyTextToClipboardMock).toHaveBeenLastCalledWith( + 'http://localhost/sessions?sessionSource=source-1&from=1&to=2&sid=session-1&sfrom=30&sto=40', ); - expect(notificationsShowSpy).toHaveBeenCalledWith({ - color: 'red', - message: CLIPBOARD_ERROR_MESSAGE, - }); }); }); - it('shows an error notification when the clipboard helper rejects', async () => { - copyTextToClipboardMock - .mockRejectedValueOnce(new Error('copy failed')) - .mockResolvedValueOnce(true); + it('shows an error notification when copying the session URL fails', async () => { + copyTextToClipboardMock.mockResolvedValue(false); renderPanel(); - const shareButton = screen.getByRole('button', { name: /share session/i }); - fireEvent.click(shareButton); + fireEvent.click(screen.getByRole('button', { name: /share session/i })); await waitFor(() => { + expect(copyTextToClipboardMock).toHaveBeenCalledWith( + 'http://localhost/sessions?sessionSource=source-1&from=1&to=2', + ); expect(notificationsShowSpy).toHaveBeenCalledWith({ color: 'red', message: CLIPBOARD_ERROR_MESSAGE, }); }); - - fireEvent.click(shareButton); - - await waitFor(() => { - expect(copyTextToClipboardMock).toHaveBeenCalledTimes(2); - expect(notificationsShowSpy).toHaveBeenCalledWith({ - color: 'green', - message: 'Copied link to clipboard', - }); - }); }); it('ignores duplicate share clicks while copying is still pending', async () => { @@ -154,10 +191,12 @@ describe('SessionSidePanel', () => { fireEvent.click(shareButton); expect(copyTextToClipboardMock).toHaveBeenCalledTimes(1); + expect(shareButton).toHaveAttribute('data-loading', 'true'); finishCopy(true); await waitFor(() => { + expect(shareButton).not.toHaveAttribute('data-loading'); expect(notificationsShowSpy).toHaveBeenCalledTimes(1); expect(notificationsShowSpy).toHaveBeenCalledWith({ color: 'green', @@ -197,4 +236,18 @@ describe('SessionSidePanel', () => { await new Promise(resolve => setTimeout(resolve, 0)); expect(notificationsShowSpy).not.toHaveBeenCalled(); }); + + it('shows share notifications when rendered in StrictMode', async () => { + renderPanel({ strict: true }); + + fireEvent.click(screen.getByRole('button', { name: /share session/i })); + + await waitFor(() => { + expect(copyTextToClipboardMock).toHaveBeenCalledTimes(1); + expect(notificationsShowSpy).toHaveBeenCalledWith({ + color: 'green', + message: 'Copied link to clipboard', + }); + }); + }); }); From 68d9d8ccab5e5ff18f807da7eca090cad56ef629 Mon Sep 17 00:00:00 2001 From: AjTheSpidey Date: Sat, 23 May 2026 15:40:30 +0800 Subject: [PATCH 7/7] fix: handle rejected session share copies --- packages/app/src/SessionSidePanel.tsx | 3 +++ .../src/__tests__/SessionSidePanel.test.tsx | 19 +++++++++++++++++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/packages/app/src/SessionSidePanel.tsx b/packages/app/src/SessionSidePanel.tsx index 0b6b898fcb..ee1627b991 100644 --- a/packages/app/src/SessionSidePanel.tsx +++ b/packages/app/src/SessionSidePanel.tsx @@ -57,6 +57,7 @@ export default function SessionSidePanel({ const isMountedRef = useRef(true); useEffect(() => { + // React 18 StrictMode runs cleanup before re-mounting this effect. isMountedRef.current = true; return () => { @@ -82,6 +83,8 @@ export default function SessionSidePanel({ let copied = false; try { copied = await copyTextToClipboard(window.location.href); + } catch { + copied = false; } finally { isSharingSessionRef.current = false; if (isMountedRef.current) { diff --git a/packages/app/src/__tests__/SessionSidePanel.test.tsx b/packages/app/src/__tests__/SessionSidePanel.test.tsx index 4264a2221d..3b63438a07 100644 --- a/packages/app/src/__tests__/SessionSidePanel.test.tsx +++ b/packages/app/src/__tests__/SessionSidePanel.test.tsx @@ -174,6 +174,23 @@ describe('SessionSidePanel', () => { }); }); + it('shows an error notification when copying the session URL rejects', async () => { + copyTextToClipboardMock.mockRejectedValue(new Error('clipboard blocked')); + renderPanel(); + + fireEvent.click(screen.getByRole('button', { name: /share session/i })); + + await waitFor(() => { + expect(copyTextToClipboardMock).toHaveBeenCalledWith( + 'http://localhost/sessions?sessionSource=source-1&from=1&to=2', + ); + expect(notificationsShowSpy).toHaveBeenCalledWith({ + color: 'red', + message: CLIPBOARD_ERROR_MESSAGE, + }); + }); + }); + it('ignores duplicate share clicks while copying is still pending', async () => { let finishCopy: (copied: boolean) => void = (_copied: boolean): void => { throw new Error('copy promise was not created'); @@ -191,12 +208,10 @@ describe('SessionSidePanel', () => { fireEvent.click(shareButton); expect(copyTextToClipboardMock).toHaveBeenCalledTimes(1); - expect(shareButton).toHaveAttribute('data-loading', 'true'); finishCopy(true); await waitFor(() => { - expect(shareButton).not.toHaveAttribute('data-loading'); expect(notificationsShowSpy).toHaveBeenCalledTimes(1); expect(notificationsShowSpy).toHaveBeenCalledWith({ color: 'green',