diff --git a/.changeset/olive-pugs-arrive.md b/.changeset/olive-pugs-arrive.md new file mode 100644 index 0000000000..b9ea28f2d5 --- /dev/null +++ b/.changeset/olive-pugs-arrive.md @@ -0,0 +1,11 @@ +--- +'@hyperdx/app': patch +--- + +Fix search results going blank after expanding and collapsing rows. A row and +its inline expansion are two `tr`s sharing one virtual index, and both were +measured by the virtualizer, so the expanded row took over that index's +ResizeObserver registration and left its height cached there after it +collapsed. Each expand/collapse shrank the render window a little further until +scrolling showed only a handful of rows above empty space. The row and its +expansion are now wrapped in a `tbody` that is measured as one unit. diff --git a/packages/app/src/components/DBRowTable.tsx b/packages/app/src/components/DBRowTable.tsx index f507a35e62..292ac9da58 100644 --- a/packages/app/src/components/DBRowTable.tsx +++ b/packages/app/src/components/DBRowTable.tsx @@ -1128,161 +1128,169 @@ export const RawLogTable = memo( ))} - - {paddingTop > 0 && ( + {paddingTop > 0 && ( + - )} - {items.map(virtualRow => { - const row = _rows[virtualRow.index] as TableRow; - const rowId = getRowId(row.original); - const isExpanded = expandedRows[rowId] ?? false; - const isRowSelected = - enableRowSelection && rowSelection.isSelected(rowId); - - return ( - - - {enableRowSelection && ( - - )} - - {/* Expand button cell */} - {showExpandButton && ( - - {flexRender( - row.getVisibleCells()[0].column.columnDef.cell, - row.getVisibleCells()[0].getContext(), - )} - - )} - - {/* Content columns grouped back to preserve row hover/click */} + + )} + {items.map(virtualRow => { + const row = _rows[virtualRow.index] as TableRow; + const rowId = getRowId(row.original); + const isExpanded = expandedRows[rowId] ?? false; + const isRowSelected = + enableRowSelection && rowSelection.isSelected(rowId); + + return ( + // A row and its inline expansion are two `tr`s but one + // virtual item, so the group — not either `tr` — is what the + // virtualizer measures. Measuring the `tr`s directly gave + // both the same `data-index`, and the expanded one took over + // that index's ResizeObserver registration, then left its + // height cached there after it unmounted. + + + {enableRowSelection && ( + + )} + + {/* Expand button cell */} + {showExpandButton && ( - + {flexRender( + row.getVisibleCells()[0].column.columnDef.cell, + row.getVisibleCells()[0].getContext(), + )} - - {showExpandButton && isExpanded && ( - - {renderRowDetails?.({ - id: rowId, - aliasWith: - row.original[INTERNAL_ROW_FIELDS.ALIAS_WITH], - ...row.original, - })} - )} - - ); - })} + + {/* Content columns grouped back to preserve row hover/click */} + + + + + {showExpandButton && isExpanded && ( + + {renderRowDetails?.({ + id: rowId, + aliasWith: + row.original[INTERNAL_ROW_FIELDS.ALIAS_WITH], + ...row.original, + })} + + )} + + ); + })} +
void; - virtualIndex?: number; }) => { const openSidebar = useSidebarOpener(); return ( + // Deliberately not measured by the virtualizer: the enclosing `tbody` is + // the element registered for this row's virtual index.
diff --git a/packages/app/tests/e2e/components/TableComponent.ts b/packages/app/tests/e2e/components/TableComponent.ts index c3e9e4cd13..f854e4516e 100644 --- a/packages/app/tests/e2e/components/TableComponent.ts +++ b/packages/app/tests/e2e/components/TableComponent.ts @@ -78,6 +78,53 @@ export class TableComponent { await this.firstExpandedRow.waitFor({ state: 'visible', timeout: 10_000 }); } + /** + * Collapse a row expanded by {@link expandRow}, via the same chevron. + */ + async collapseRow(index: number) { + await this.getRow(index) + .getByRole('button', { name: 'Collapse log details' }) + .click(); + } + + /** + * Scroll the virtualized container to an absolute offset. + */ + async scrollTo(top: number) { + await this.tableContainer.evaluate( + (el, offset) => el.scrollTo({ top: offset }), + top, + ); + } + + /** + * Layout readings for the virtualized scroll container. + * + * `visibleGapPx` is the empty space between the bottom of the last rendered + * row and the bottom of the viewport. While rows remain below, a healthy + * table overfills the viewport and this is negative; it turns positive when + * the virtualizer holds stale row heights and stops rendering enough rows to + * cover the screen. + */ + async getVirtualizationMetrics() { + return this.tableContainer.evaluate(el => { + const rows = el.querySelectorAll('[data-testid^="table-row-"]'); + const last = rows[rows.length - 1]; + return { + renderedRows: rows.length, + scrollHeight: el.scrollHeight, + clientHeight: el.clientHeight, + visibleGapPx: Math.round( + el.getBoundingClientRect().bottom - + (last ? last.getBoundingClientRect().bottom : 0), + ), + remainingBelowPx: Math.round( + el.scrollHeight - el.scrollTop - el.clientHeight, + ), + }; + }); + } + /** * All inline expanded rows currently rendered. Each carries * `data-testid="expanded-row-"`, where rowWhere is a SQL fragment, diff --git a/packages/app/tests/e2e/features/search/virtualized-row-heights.spec.ts b/packages/app/tests/e2e/features/search/virtualized-row-heights.spec.ts new file mode 100644 index 0000000000..4008e2240d --- /dev/null +++ b/packages/app/tests/e2e/features/search/virtualized-row-heights.spec.ts @@ -0,0 +1,62 @@ +import { SearchPage } from '../../page-objects/SearchPage'; +import { expect, test } from '../../utils/base-test'; + +test.use({ viewport: { width: 1280, height: 900 } }); + +test.describe('Virtualized row heights', { tag: '@search' }, () => { + let searchPage: SearchPage; + + test.beforeEach(async ({ page }) => { + searchPage = new SearchPage(page); + await searchPage.goto(); + await searchPage.submitEmptySearch(); + await expect(searchPage.table.firstRow).toBeVisible(); + }); + + // An expanded row used to take over its row's slot in the virtualizer's + // measurement cache and leave its height behind when it collapsed. Every + // expand/collapse shrank the render window until the viewport went blank. + test('collapsing an expanded row restores the table height', async () => { + const before = await searchPage.table.getVirtualizationMetrics(); + expect(before.renderedRows).toBeGreaterThan(20); + + for (const index of [0, 1, 2]) { + await searchPage.table.expandRow(index); + await expect(searchPage.table.firstExpandedRow).toBeVisible(); + + await searchPage.table.collapseRow(index); + await expect(searchPage.table.expandedRows).toHaveCount(0); + + await expect + .poll(async () => { + const m = await searchPage.table.getVirtualizationMetrics(); + return m.renderedRows; + }) + .toBe(before.renderedRows); + } + + const after = await searchPage.table.getVirtualizationMetrics(); + expect(after.scrollHeight).toBeLessThanOrEqual(before.scrollHeight + 20); + }); + + test('scrolling after expanding keeps the viewport filled', async () => { + for (const index of [0, 1, 2]) { + await searchPage.table.expandRow(index); + await expect(searchPage.table.firstExpandedRow).toBeVisible(); + await searchPage.table.collapseRow(index); + await expect(searchPage.table.expandedRows).toHaveCount(0); + } + + for (const top of [300, 700, 1200]) { + await searchPage.table.scrollTo(top); + + await expect + .poll(async () => { + const m = await searchPage.table.getVirtualizationMetrics(); + // Rows still below, so the rendered window must cover the viewport. + return m.remainingBelowPx > 0 ? m.visibleGapPx : 0; + }) + .toBeLessThanOrEqual(0); + } + }); +});