Skip to content

Commit 8965cdd

Browse files
fix(app): fix session replay sub-event modal stacking and tab conflict (#2068)
## Summary Clicking a log/error event in the session replay event list either reopened the session replay instead of showing event details, or rendered the detail panel behind the replay drawer. Fixed this by ensuring that isSubPanel is correctly set and using the ZIndexProvider to correctly stack the contexts. ## Steps to Reproduce From the Sessions page: 1. Go to /sessions, select a session source, open a session card 2. In the session replay drawer, wait for the event list to load 3. Click any event row (e.g. a console.error) 4. Bug A: The detail panel opens behind the session replay drawer (overlay darkens but panel is inaccessible), or ESC/close doesn't work correctly From the Search page (URL conflict): 1. Go to /search, open any trace row to open the detail side panel 2. Click the Session Replay tab — this sets sidePanelTab=replay in the URL 3. In the session event list, click any event row 4. Bug B: The inner detail panel opens to the Session Replay tab again instead of event details (e.g. Overview/Trace)
1 parent fbfcb84 commit 8965cdd

8 files changed

Lines changed: 169 additions & 13 deletions

File tree

.changeset/tender-monkeys-drop.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@hyperdx/app": patch
3+
---
4+
5+
Fix bug when accessing session replay panel from search page

packages/app/src/SessionEventList.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ const EventRow = React.forwardRef(
6262
return (
6363
<div
6464
data-index={dataIndex}
65+
data-testid={`session-event-row-${dataIndex}`}
6566
ref={ref}
6667
className={cx(styles.eventRow, {
6768
[styles.eventRowError]: event.isError,

packages/app/src/SessionSidePanel.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,10 @@ export default function SessionSidePanel({
9191
className="border-start"
9292
>
9393
<ZIndexContext.Provider value={zIndex}>
94-
<div className="d-flex flex-column h-100">
94+
<div
95+
className="d-flex flex-column h-100"
96+
data-testid="session-side-panel"
97+
>
9598
<div>
9699
<div className="p-3 d-flex align-items-center justify-content-between border-bottom border-dark">
97100
<div style={{ width: '50%', maxWidth: 500 }}>

packages/app/src/SessionSubpanel.tsx

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import {
3535

3636
import DBRowSidePanel from '@/components/DBRowSidePanel';
3737
import { RowWhereResult, WithClause } from '@/hooks/useRowWhere';
38+
import { useZIndex, ZIndexContext } from '@/zIndex';
3839

3940
import SearchWhereInput from './components/SearchInput/SearchWhereInput';
4041
import useFieldExpressionGenerator from './hooks/useFieldExpressionGenerator';
@@ -267,6 +268,8 @@ export default function SessionSubpanel({
267268
whereLanguage?: SearchConditionLanguage;
268269
onLanguageChange?: (lang: 'sql' | 'lucene') => void;
269270
}) {
271+
const contextZIndex = useZIndex();
272+
270273
const [rowId, setRowId] = useState<string | undefined>(undefined);
271274
const [aliasWith, setAliasWith] = useState<WithClause[]>([]);
272275

@@ -466,15 +469,18 @@ export default function SessionSubpanel({
466469
<div className={styles.wrapper}>
467470
{rowId != null && traceSource && (
468471
<Portal>
469-
<DBRowSidePanel
470-
source={traceSource}
471-
rowId={rowId}
472-
aliasWith={aliasWith}
473-
onClose={() => {
474-
setDrawerOpen(false);
475-
setRowId(undefined);
476-
}}
477-
/>
472+
<ZIndexContext.Provider value={contextZIndex}>
473+
<DBRowSidePanel
474+
source={traceSource}
475+
rowId={rowId}
476+
aliasWith={aliasWith}
477+
isNestedPanel
478+
onClose={() => {
479+
setDrawerOpen(false);
480+
setRowId(undefined);
481+
}}
482+
/>
483+
</ZIndexContext.Provider>
478484
</Portal>
479485
)}
480486
<div className={cx(styles.eventList, { 'd-none': playerFullWidth })}>

packages/app/src/components/DBRowSidePanel.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -506,9 +506,11 @@ const DBRowSidePanel = ({
506506
</div>
507507
)}
508508
>
509-
<div className="overflow-hidden flex-grow-1">
509+
<div
510+
className="overflow-hidden flex-grow-1"
511+
data-testid="side-panel-tab-replay"
512+
>
510513
<DBSessionPanel
511-
data-testid="side-panel-tab-replay"
512514
dateRange={fourHourRange}
513515
focusDate={focusDate}
514516
setSubDrawerOpen={setSubDrawerOpen}
@@ -590,7 +592,6 @@ export default function DBRowSidePanelErrorBoundary({
590592
<Drawer
591593
opened={rowId != null}
592594
withCloseButton={false}
593-
withOverlay={!isNestedPanel}
594595
onClose={() => {
595596
if (!subDrawerOpen) {
596597
_onClose();

packages/app/tests/e2e/features/sessions.spec.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,4 +39,81 @@ test.describe('Client Sessions Functionality', { tag: ['@sessions'] }, () => {
3939
await sessionsPage.openFirstSession();
4040
});
4141
});
42+
43+
test(
44+
'clicking a session event opens the event detail panel with tabs, not another session replay',
45+
{ tag: ['@full-stack'] },
46+
async ({ page }) => {
47+
await test.step('Navigate and open a session (with sidePanelTab=replay pre-set in URL to simulate search-page flow)', async () => {
48+
// Pre-set sidePanelTab=replay in the URL to simulate navigating from a search page
49+
// row detail panel that had the Session Replay tab open. Without isNestedPanel=true,
50+
// the inner DBRowSidePanel would inherit this URL param and open to the Replay tab again.
51+
await page.goto('/search');
52+
await sessionsPage.goto();
53+
await sessionsPage.selectDataSource();
54+
await expect(sessionsPage.getFirstSessionCard()).toBeVisible();
55+
// Inject sidePanelTab=replay into the URL before opening the session
56+
const currentUrl = page.url();
57+
await page.goto(
58+
currentUrl.includes('?')
59+
? `${currentUrl}&sidePanelTab=replay`
60+
: `${currentUrl}?sidePanelTab=replay`,
61+
);
62+
await expect(sessionsPage.getFirstSessionCard()).toBeVisible();
63+
await sessionsPage.openFirstSession();
64+
});
65+
66+
await test.step('Wait for session replay drawer and event rows to load', async () => {
67+
await expect(sessionsPage.sessionSidePanel).toBeVisible();
68+
// Wait for the session event list to populate (routeChange/console.error events are seeded)
69+
await expect(sessionsPage.getSessionEventRows().first()).toBeVisible({
70+
timeout: 15000,
71+
});
72+
});
73+
74+
await test.step('Click a session event row', async () => {
75+
await sessionsPage.clickFirstSessionEvent();
76+
});
77+
78+
await test.step('Event detail panel opens alongside the session replay — not replacing it', async () => {
79+
// The row-side-panel must be visible (event detail drawer opened on top of session replay)
80+
await expect(sessionsPage.rowSidePanel).toBeVisible();
81+
82+
// The original session replay panel must still be open (not replaced/closed)
83+
await expect(sessionsPage.sessionSidePanel).toBeVisible();
84+
85+
// Only one session-side-panel must exist (not a second replay opened inside the detail panel)
86+
await expect(page.getByTestId('session-side-panel')).toHaveCount(1);
87+
88+
// The row-side-panel must show the event detail TabBar (Overview, Trace, etc.)
89+
// This guards against the regression where the inner panel re-opened session replay
90+
// instead of showing event details (which has no TabBar, just the replay player)
91+
await expect(
92+
sessionsPage.rowSidePanel.getByTestId('side-panel-tabs'),
93+
).toBeVisible();
94+
95+
// The inner panel must NOT be showing the Session Replay tab content.
96+
// Without isNestedPanel=true (broken), the inner DBRowSidePanel reads sidePanelTab=replay
97+
// from the URL (injected above) and renders the Session Replay tab content (side-panel-tab-replay).
98+
// With isNestedPanel=true (fixed), the inner panel uses local state and ignores the URL,
99+
// opening to its default tab (Trace/Overview) instead.
100+
await expect(
101+
sessionsPage.rowSidePanel.getByTestId('side-panel-tab-replay'),
102+
).toHaveCount(0);
103+
});
104+
105+
await test.step('Clicking the overlay closes the event detail panel but keeps the session replay open', async () => {
106+
// Without the fix, withOverlay={!isNestedPanel} removed the overlay on nested panels,
107+
// so there was nothing to click to close the panel (it had to be ESC only).
108+
// With the fix (withOverlay always true), clicking the Mantine overlay dismisses the inner panel.
109+
await sessionsPage.clickTopmostDrawerOverlay();
110+
111+
// The event detail panel must close
112+
await expect(sessionsPage.rowSidePanel).toBeHidden();
113+
114+
// The session replay drawer must still be open
115+
await expect(sessionsPage.sessionSidePanel).toBeVisible();
116+
});
117+
},
118+
);
42119
});

packages/app/tests/e2e/page-objects/SessionsPage.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,46 @@ export class SessionsPage {
6868
await this.getFirstSessionCard().click();
6969
}
7070

71+
/**
72+
* Get the session side panel (the replay drawer)
73+
*/
74+
get sessionSidePanel() {
75+
return this.page.getByTestId('session-side-panel');
76+
}
77+
78+
/**
79+
* Get all session event rows inside the replay drawer
80+
*/
81+
getSessionEventRows() {
82+
return this.page.locator('[data-testid^="session-event-row-"]');
83+
}
84+
85+
/**
86+
* Click the first session event row to open its detail panel
87+
*/
88+
async clickFirstSessionEvent() {
89+
await this.getSessionEventRows().first().click();
90+
}
91+
92+
/**
93+
* Get the row side panel (event detail drawer opened from within session replay)
94+
*/
95+
get rowSidePanel() {
96+
return this.page.getByTestId('row-side-panel');
97+
}
98+
99+
/**
100+
* Click the Mantine overlay of the topmost open drawer to close it.
101+
* Mantine renders one overlay per open Drawer. The last one belongs to
102+
* the innermost (topmost) drawer.
103+
*/
104+
async clickTopmostDrawerOverlay() {
105+
// Mantine overlays are siblings of the drawer content inside the portal root.
106+
// Use the last one since the inner panel's overlay is rendered on top.
107+
const overlay = this.page.locator('.mantine-Drawer-overlay').last();
108+
await overlay.click({ position: { x: 10, y: 10 } });
109+
}
110+
71111
// Getters for assertions
72112

73113
get form() {

packages/app/tests/e2e/seed-clickhouse.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,29 @@ function generateSessionTraces(
321321
`('${timestampNs}', '${traceId}', '${spanId}', '', '', '${spanName}', 'SPAN_KIND_INTERNAL', 'browser', {'rum.sessionId':'${sessionId}','service.name':'browser'}, '', '', {'component':'${component}','page.url':'https://example.com/dashboard','teamId':'${teamId}','teamName':'${teamName}','userEmail':'${userEmail}','userName':'${userName}'}, 0, '${statusCode}', '', [], [], [], [], [], [], [])`,
322322
);
323323
}
324+
325+
// Add visible events for the session event list:
326+
// - A routeChange (navigation) event — shown in both Highlighted and All Events tabs
327+
// - A console.error event — shown in both tabs
328+
const sessionUserIndex = i % 5;
329+
const sessionUserEmail = `test${sessionUserIndex}@example.com`;
330+
const sessionUserName = `Test User ${sessionUserIndex}`;
331+
const sessionTeamId = 'test-team-id';
332+
const sessionTeamName = 'Test Team';
333+
334+
const navTimestampNs = (baseTime - 1000) * 1000000;
335+
const navTraceId = `session-nav-${i}`;
336+
const navSpanId = `session-nav-span-${i}`;
337+
rows.push(
338+
`('${navTimestampNs}', '${navTraceId}', '${navSpanId}', '', '', 'routeChange', 'SPAN_KIND_INTERNAL', 'browser', {'rum.sessionId':'${sessionId}','service.name':'browser'}, '', '', {'component':'navigation','location.href':'https://example.com/dashboard','teamId':'${sessionTeamId}','teamName':'${sessionTeamName}','userEmail':'${sessionUserEmail}','userName':'${sessionUserName}'}, 0, 'STATUS_CODE_OK', '', [], [], [], [], [], [], [])`,
339+
);
340+
341+
const errTimestampNs = (baseTime - 2000) * 1000000;
342+
const errTraceId = `session-err-${i}`;
343+
const errSpanId = `session-err-span-${i}`;
344+
rows.push(
345+
`('${errTimestampNs}', '${errTraceId}', '${errSpanId}', '', '', 'console.error', 'SPAN_KIND_INTERNAL', 'browser', {'rum.sessionId':'${sessionId}','service.name':'browser'}, '', '', {'component':'error','message':'E2E test error ${i}','teamId':'${sessionTeamId}','teamName':'${sessionTeamName}','userEmail':'${sessionUserEmail}','userName':'${sessionUserName}'}, 0, 'STATUS_CODE_ERROR', '', [], [], [], [], [], [], [])`,
346+
);
324347
}
325348

326349
return rows.join(',\n');

0 commit comments

Comments
 (0)