From be6fde179c7be68031e6787839abf10717fd0d8d Mon Sep 17 00:00:00 2001 From: Caplets Test Date: Sun, 28 Jun 2026 09:06:13 -0400 Subject: [PATCH] fix(catalog): preserve virtual result rows --- apps/catalog/src/scripts/virtual-results.ts | 53 +++++++++++++++++++-- apps/catalog/test/virtual-results.test.ts | 12 +++++ 2 files changed, 60 insertions(+), 5 deletions(-) diff --git a/apps/catalog/src/scripts/virtual-results.ts b/apps/catalog/src/scripts/virtual-results.ts index 12acdf8..0ead52c 100644 --- a/apps/catalog/src/scripts/virtual-results.ts +++ b/apps/catalog/src/scripts/virtual-results.ts @@ -56,6 +56,7 @@ export function initVirtualCatalogSearch( const rows = parseRows(index.textContent ?? "[]"); let visibleRows = [...rows]; let lastFocusedControl: HTMLElement | null = null; + const renderedRows = new Map(); const virtualizer = new Virtualizer({ count: visibleRows.length, getScrollElement: () => window, @@ -128,7 +129,36 @@ export function initVirtualCatalogSearch( function renderVirtualRows(): void { const items = virtualizer.getVirtualItems(); resultSpacerEl.style.height = `${Math.max(virtualizer.getTotalSize(), visibleRows.length ? estimateRowHeight() : 1)}px`; - resultListEl.replaceChildren(...items.map((item) => renderRow(item, visibleRows[item.index]))); + const nextKeys = new Set(); + let cursor: ChildNode | null = resultListEl.firstChild; + + for (const item of items) { + const row = visibleRows[item.index]; + const key = virtualRowKey(item, row); + nextKeys.add(key); + + let element = renderedRows.get(key); + if (!element) { + element = renderRow(item, row); + renderedRows.set(key, element); + } else { + updateRowPosition(element, item, row); + } + + if (element !== cursor) { + resultListEl.insertBefore(element, cursor); + } + cursor = element.nextSibling; + } + + for (const element of Array.from( + resultListEl.querySelectorAll("[data-result-row]"), + )) { + const key = element.dataset.virtualKey; + if (key && nextKeys.has(key)) continue; + element.remove(); + if (key) renderedRows.delete(key); + } } function navigateFromRowClick(event: MouseEvent): void { @@ -249,10 +279,7 @@ function renderRow(item: VirtualItem, row: CatalogSearchRow | undefined): HTMLEl element.className = "catalog-result-row"; element.role = "row"; element.dataset.resultRow = ""; - element.dataset.detailHref = row?.detailHref ?? ""; - element.dataset.index = String(item.index); - element.setAttribute("aria-rowindex", String(item.index + 2)); - element.style.transform = `translateY(${item.start}px)`; + updateRowPosition(element, item, row); if (!row) return element; element.innerHTML = `
@@ -278,6 +305,22 @@ function renderRow(item: VirtualItem, row: CatalogSearchRow | undefined): HTMLEl return element; } +function updateRowPosition( + element: HTMLElement, + item: VirtualItem, + row: CatalogSearchRow | undefined, +): void { + element.dataset.virtualKey = virtualRowKey(item, row); + element.dataset.detailHref = row?.detailHref ?? ""; + element.dataset.index = String(item.index); + element.setAttribute("aria-rowindex", String(item.index + 2)); + element.style.transform = `translateY(${item.start}px)`; +} + +function virtualRowKey(item: VirtualItem, row: CatalogSearchRow | undefined): string { + return row?.id ?? String(item.key); +} + function renderCapletIcon(row: CatalogSearchRow): string { if (!row.icon) { return ``; diff --git a/apps/catalog/test/virtual-results.test.ts b/apps/catalog/test/virtual-results.test.ts index a81734d..ae56b45 100644 --- a/apps/catalog/test/virtual-results.test.ts +++ b/apps/catalog/test/virtual-results.test.ts @@ -84,6 +84,18 @@ describe("virtual catalog results", () => { expect(icon.getAttribute("loading")).toBe("lazy"); }); + it("reuses row nodes that remain visible while scrolling", async () => { + mountSearchShell(manyCatalogSearchRows(200)); + + const { initVirtualCatalogSearch } = await import("../src/scripts/virtual-results"); + initVirtualCatalogSearch(); + const firstRow = resultRows()[0]; + + window.scrollTo({ top: 72 }); + + expect(resultRows()).toContain(firstRow); + }); + it("hydrates filters from the url and resets to the first row", async () => { mountSearchShell(manyCatalogSearchRows(80), "http://localhost:3000/?q=caplet-70");