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)
This commit is contained in:
Brandon Pereira 2026-04-08 10:17:22 -06:00 committed by GitHub
parent 74536b3af5
commit 52986a943c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 169 additions and 13 deletions

View file

@ -0,0 +1,5 @@
---
"@hyperdx/app": patch
---
Fix bug when accessing session replay panel from search page

View file

@ -62,6 +62,7 @@ const EventRow = React.forwardRef(
return (
<div
data-index={dataIndex}
data-testid={`session-event-row-${dataIndex}`}
ref={ref}
className={cx(styles.eventRow, {
[styles.eventRowError]: event.isError,

View file

@ -91,7 +91,10 @@ export default function SessionSidePanel({
className="border-start"
>
<ZIndexContext.Provider value={zIndex}>
<div className="d-flex flex-column h-100">
<div
className="d-flex flex-column h-100"
data-testid="session-side-panel"
>
<div>
<div className="p-3 d-flex align-items-center justify-content-between border-bottom border-dark">
<div style={{ width: '50%', maxWidth: 500 }}>

View file

@ -35,6 +35,7 @@ import {
import DBRowSidePanel from '@/components/DBRowSidePanel';
import { RowWhereResult, WithClause } from '@/hooks/useRowWhere';
import { useZIndex, ZIndexContext } from '@/zIndex';
import SearchWhereInput from './components/SearchInput/SearchWhereInput';
import useFieldExpressionGenerator from './hooks/useFieldExpressionGenerator';
@ -267,6 +268,8 @@ export default function SessionSubpanel({
whereLanguage?: SearchConditionLanguage;
onLanguageChange?: (lang: 'sql' | 'lucene') => void;
}) {
const contextZIndex = useZIndex();
const [rowId, setRowId] = useState<string | undefined>(undefined);
const [aliasWith, setAliasWith] = useState<WithClause[]>([]);
@ -466,15 +469,18 @@ export default function SessionSubpanel({
<div className={styles.wrapper}>
{rowId != null && traceSource && (
<Portal>
<DBRowSidePanel
source={traceSource}
rowId={rowId}
aliasWith={aliasWith}
onClose={() => {
setDrawerOpen(false);
setRowId(undefined);
}}
/>
<ZIndexContext.Provider value={contextZIndex}>
<DBRowSidePanel
source={traceSource}
rowId={rowId}
aliasWith={aliasWith}
isNestedPanel
onClose={() => {
setDrawerOpen(false);
setRowId(undefined);
}}
/>
</ZIndexContext.Provider>
</Portal>
)}
<div className={cx(styles.eventList, { 'd-none': playerFullWidth })}>

View file

@ -506,9 +506,11 @@ const DBRowSidePanel = ({
</div>
)}
>
<div className="overflow-hidden flex-grow-1">
<div
className="overflow-hidden flex-grow-1"
data-testid="side-panel-tab-replay"
>
<DBSessionPanel
data-testid="side-panel-tab-replay"
dateRange={fourHourRange}
focusDate={focusDate}
setSubDrawerOpen={setSubDrawerOpen}
@ -590,7 +592,6 @@ export default function DBRowSidePanelErrorBoundary({
<Drawer
opened={rowId != null}
withCloseButton={false}
withOverlay={!isNestedPanel}
onClose={() => {
if (!subDrawerOpen) {
_onClose();

View file

@ -39,4 +39,81 @@ test.describe('Client Sessions Functionality', { tag: ['@sessions'] }, () => {
await sessionsPage.openFirstSession();
});
});
test(
'clicking a session event opens the event detail panel with tabs, not another session replay',
{ tag: ['@full-stack'] },
async ({ page }) => {
await test.step('Navigate and open a session (with sidePanelTab=replay pre-set in URL to simulate search-page flow)', async () => {
// Pre-set sidePanelTab=replay in the URL to simulate navigating from a search page
// row detail panel that had the Session Replay tab open. Without isNestedPanel=true,
// the inner DBRowSidePanel would inherit this URL param and open to the Replay tab again.
await page.goto('/search');
await sessionsPage.goto();
await sessionsPage.selectDataSource();
await expect(sessionsPage.getFirstSessionCard()).toBeVisible();
// Inject sidePanelTab=replay into the URL before opening the session
const currentUrl = page.url();
await page.goto(
currentUrl.includes('?')
? `${currentUrl}&sidePanelTab=replay`
: `${currentUrl}?sidePanelTab=replay`,
);
await expect(sessionsPage.getFirstSessionCard()).toBeVisible();
await sessionsPage.openFirstSession();
});
await test.step('Wait for session replay drawer and event rows to load', async () => {
await expect(sessionsPage.sessionSidePanel).toBeVisible();
// Wait for the session event list to populate (routeChange/console.error events are seeded)
await expect(sessionsPage.getSessionEventRows().first()).toBeVisible({
timeout: 15000,
});
});
await test.step('Click a session event row', async () => {
await sessionsPage.clickFirstSessionEvent();
});
await test.step('Event detail panel opens alongside the session replay — not replacing it', async () => {
// The row-side-panel must be visible (event detail drawer opened on top of session replay)
await expect(sessionsPage.rowSidePanel).toBeVisible();
// The original session replay panel must still be open (not replaced/closed)
await expect(sessionsPage.sessionSidePanel).toBeVisible();
// Only one session-side-panel must exist (not a second replay opened inside the detail panel)
await expect(page.getByTestId('session-side-panel')).toHaveCount(1);
// The row-side-panel must show the event detail TabBar (Overview, Trace, etc.)
// This guards against the regression where the inner panel re-opened session replay
// instead of showing event details (which has no TabBar, just the replay player)
await expect(
sessionsPage.rowSidePanel.getByTestId('side-panel-tabs'),
).toBeVisible();
// The inner panel must NOT be showing the Session Replay tab content.
// Without isNestedPanel=true (broken), the inner DBRowSidePanel reads sidePanelTab=replay
// from the URL (injected above) and renders the Session Replay tab content (side-panel-tab-replay).
// With isNestedPanel=true (fixed), the inner panel uses local state and ignores the URL,
// opening to its default tab (Trace/Overview) instead.
await expect(
sessionsPage.rowSidePanel.getByTestId('side-panel-tab-replay'),
).toHaveCount(0);
});
await test.step('Clicking the overlay closes the event detail panel but keeps the session replay open', async () => {
// Without the fix, withOverlay={!isNestedPanel} removed the overlay on nested panels,
// so there was nothing to click to close the panel (it had to be ESC only).
// With the fix (withOverlay always true), clicking the Mantine overlay dismisses the inner panel.
await sessionsPage.clickTopmostDrawerOverlay();
// The event detail panel must close
await expect(sessionsPage.rowSidePanel).toBeHidden();
// The session replay drawer must still be open
await expect(sessionsPage.sessionSidePanel).toBeVisible();
});
},
);
});

View file

@ -68,6 +68,46 @@ export class SessionsPage {
await this.getFirstSessionCard().click();
}
/**
* Get the session side panel (the replay drawer)
*/
get sessionSidePanel() {
return this.page.getByTestId('session-side-panel');
}
/**
* Get all session event rows inside the replay drawer
*/
getSessionEventRows() {
return this.page.locator('[data-testid^="session-event-row-"]');
}
/**
* Click the first session event row to open its detail panel
*/
async clickFirstSessionEvent() {
await this.getSessionEventRows().first().click();
}
/**
* Get the row side panel (event detail drawer opened from within session replay)
*/
get rowSidePanel() {
return this.page.getByTestId('row-side-panel');
}
/**
* Click the Mantine overlay of the topmost open drawer to close it.
* Mantine renders one overlay per open Drawer. The last one belongs to
* the innermost (topmost) drawer.
*/
async clickTopmostDrawerOverlay() {
// Mantine overlays are siblings of the drawer content inside the portal root.
// Use the last one since the inner panel's overlay is rendered on top.
const overlay = this.page.locator('.mantine-Drawer-overlay').last();
await overlay.click({ position: { x: 10, y: 10 } });
}
// Getters for assertions
get form() {

View file

@ -321,6 +321,29 @@ function generateSessionTraces(
`('${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}', '', [], [], [], [], [], [], [])`,
);
}
// Add visible events for the session event list:
// - A routeChange (navigation) event — shown in both Highlighted and All Events tabs
// - A console.error event — shown in both tabs
const sessionUserIndex = i % 5;
const sessionUserEmail = `test${sessionUserIndex}@example.com`;
const sessionUserName = `Test User ${sessionUserIndex}`;
const sessionTeamId = 'test-team-id';
const sessionTeamName = 'Test Team';
const navTimestampNs = (baseTime - 1000) * 1000000;
const navTraceId = `session-nav-${i}`;
const navSpanId = `session-nav-span-${i}`;
rows.push(
`('${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', '', [], [], [], [], [], [], [])`,
);
const errTimestampNs = (baseTime - 2000) * 1000000;
const errTraceId = `session-err-${i}`;
const errSpanId = `session-err-span-${i}`;
rows.push(
`('${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', '', [], [], [], [], [], [], [])`,
);
}
return rows.join(',\n');