commit dd562e8bcfdbe9dcb9a1dd47b33c4874c309c28c
parent 3425e755790b87640a5e7091dbf09a686957e967
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Fri, 22 May 2026 13:59:28 -0400
fix virtual list gaps by wrapping cards as elements
Diffstat:
3 files changed, 148 insertions(+), 231 deletions(-)
diff --git a/common/components/TranscriptSearch.tsx b/common/components/TranscriptSearch.tsx
@@ -73,10 +73,10 @@ type ResultGroup = {
const DEFAULT_MAX_HITS = 500;
const DEFAULT_FETCH_CONCURRENCY = 6;
const DEFAULT_FLUSH_INTERVAL_MS = 120;
-// Average rendered height of a result row (card-header / leaf-header / hit).
-// Used as the virtualizer's initial estimate; the real heights are measured
-// per row by `rowVirtualizer.measureElement` after mount.
-const RESULT_ROW_ESTIMATE_PX = 48;
+// Rough first-paint guess for one full result card (header + a couple of
+// leaf sections + a handful of hits). After mount, ResizeObserver measures
+// the card's actual height and the virtualizer re-positions later cards.
+const CARD_ROW_ESTIMATE_PX = 120;
// Legacy URL keys still parsed for hydration (old share-links), but stripped
// on the first commit so the modern `qt=` is canonical going forward.
@@ -1482,115 +1482,33 @@ type LeafInfo = {
scope: "transcripts" | "chat" | "metadata";
};
+// Each card is one virtualized element. Intra-card layout (header, leaf
+// sections, hit rows) is plain CSS flow inside the card — the virtualizer
+// only ever measures a card's total height, so there is no inter-row
+// translateY math to misalign.
type ResultRow =
- | {
- kind: "card-header";
- cardIndex: number;
- group: ResultGroup;
- isFirstInCard: true;
- isLastInCard: boolean;
- }
- | {
- kind: "leaf-header";
- cardIndex: number;
- group: ResultGroup;
- leafId: string;
- hitCount: number;
- isFirstInCard: false;
- isLastInCard: boolean;
- }
- | {
- kind: "hit";
- cardIndex: number;
- group: ResultGroup;
- leafId: string;
- hit: LayerHit;
- hitIndex: number;
- isFirstInCard: false;
- isLastInCard: boolean;
- }
- // A dedicated 12px transparent row that sits between cards. Doing the
- // gap as its own virtualized row (rather than padding-bottom on the
- // previous card's last row) means the row that "owns" the gap has its
- // height pinned to a constant — the virtualizer never has to discover
- // it through ResizeObserver, so the gap stays put even before the
- // dynamic-measurement pass completes.
+ | { kind: "card"; cardIndex: number; group: ResultGroup }
| { kind: "gap"; cardIndex: number };
const GAP_ROW_PX = 12;
function buildResultRows(
resultGroups: ReadonlyArray<ResultGroup>,
- orderedLeafIds: ReadonlyArray<string>,
): ResultRow[] {
const out: ResultRow[] = [];
resultGroups.forEach((g, cardIndex) => {
- const buckets = new Map<string, LayerHit[]>();
- for (const id of orderedLeafIds) buckets.set(id, []);
- for (const h of g.hits) {
- const arr = buckets.get(h.leafId);
- if (arr) arr.push(h);
- }
- const cardStart = out.length;
- out.push({
- kind: "card-header",
- cardIndex,
- group: g,
- isFirstInCard: true,
- isLastInCard: false,
- });
- for (const [leafId, hits] of buckets) {
- if (hits.length === 0) continue;
- out.push({
- kind: "leaf-header",
- cardIndex,
- group: g,
- leafId,
- hitCount: hits.length,
- isFirstInCard: false,
- isLastInCard: false,
- });
- hits.forEach((hit, hitIndex) => {
- out.push({
- kind: "hit",
- cardIndex,
- group: g,
- leafId,
- hit,
- hitIndex,
- isFirstInCard: false,
- isLastInCard: false,
- });
- });
- }
- // Mark the last content row of this card so the renderer can apply
- // the rounded-b / border-b. Tombstone cards (header only) get the
- // header marked.
- const last = out[out.length - 1];
- if (last && last.kind !== "gap" && last.cardIndex === cardIndex) {
- (last as { isLastInCard: boolean }).isLastInCard = true;
- }
- // Inter-card gap, skipped after the final card so there's no
- // trailing empty space below the result list.
+ out.push({ kind: "card", cardIndex, group: g });
if (cardIndex < resultGroups.length - 1) {
out.push({ kind: "gap", cardIndex });
}
- void cardStart;
});
return out;
}
function rowKey(row: ResultRow): string {
- switch (row.kind) {
- case "card-header":
- return `c${row.cardIndex}:h`;
- case "leaf-header":
- return `c${row.cardIndex}:l:${row.leafId}`;
- case "hit":
- return `c${row.cardIndex}:h:${row.leafId}:${row.hitIndex}`;
- case "gap":
- return `c${row.cardIndex}:gap`;
- }
+ return row.kind === "card"
+ ? `c${row.cardIndex}`
+ : `c${row.cardIndex}:gap`;
}
function VirtualResultList({
@@ -1609,8 +1527,8 @@ function VirtualResultList({
openWithMode: (slug: string, hit?: LayerHit) => void;
}) {
const flatRows = useMemo(
- () => buildResultRows(resultGroups, orderedLeafIds),
- [resultGroups, orderedLeafIds],
+ () => buildResultRows(resultGroups),
+ [resultGroups],
);
const listRef = useRef<HTMLDivElement | null>(null);
@@ -1636,12 +1554,16 @@ function VirtualResultList({
const rowVirtualizer = useWindowVirtualizer({
count: flatRows.length,
- // The estimate only matters until ResizeObserver measures each row.
- // Picking a value close to the most common row height (transcript
- // hits + leaf-headers) keeps any pre-measurement layout shift small.
+ // Card heights vary a lot (header-only tombstone vs. a card with
+ // hundreds of hits), so estimateSize is only a rough first-paint
+ // guess; ResizeObserver measures each card's actual height on mount
+ // and the virtualizer re-positions later cards. The unit here is a
+ // whole card, not a row inside one — there is no intra-card row
+ // positioning to get wrong.
estimateSize: (index) =>
- flatRows[index]?.kind === "gap" ? GAP_ROW_PX : RESULT_ROW_ESTIMATE_PX,
- overscan: 12,
+ flatRows[index]?.kind === "gap" ? GAP_ROW_PX : CARD_ROW_ESTIMATE_PX,
+ // Cards are large; ~4 above/below the viewport is plenty of buffer.
+ overscan: 4,
scrollMargin,
});
@@ -1656,39 +1578,25 @@ function VirtualResultList({
>
{virtualItems.map((vi) => {
const row = flatRows[vi.index];
- if (row.kind === "gap") {
- // Fixed-height transparent row. Putting the frame classes on
- // the same div the virtualizer measures (rather than wrapping
- // an inner frame div) avoids a stale-measurement class of
- // bugs where the gap "appeared" between rows until a scroll
- // forced re-measurement.
- return (
- <VirtualRow
- key={rowKey(row)}
- measureRef={rowVirtualizer.measureElement}
- index={vi.index}
- offset={vi.start - scrollMargin}
- className=""
- >
- <div style={{ height: GAP_ROW_PX }} aria-hidden="true" />
- </VirtualRow>
- );
- }
return (
<VirtualRow
key={rowKey(row)}
measureRef={rowVirtualizer.measureElement}
index={vi.index}
offset={vi.start - scrollMargin}
- className={rowFrameClass(row)}
>
- <ResultRowView
- row={row}
- leavesById={leavesById}
- activeVideo={activeVideo}
- activeTime={activeTime}
- openWithMode={openWithMode}
- />
+ {row.kind === "gap" ? (
+ <div style={{ height: GAP_ROW_PX }} aria-hidden="true" />
+ ) : (
+ <ResultCard
+ group={row.group}
+ leavesById={leavesById}
+ orderedLeafIds={orderedLeafIds}
+ activeVideo={activeVideo}
+ activeTime={activeTime}
+ openWithMode={openWithMode}
+ />
+ )}
</VirtualRow>
);
})}
@@ -1696,116 +1604,114 @@ function VirtualResultList({
);
}
-// Tailwind classes for the row's outer (virtualized + measured) div. Side
-// borders on every row plus a top border on every non-first row of a card
-// give the divide-y effect; the first and last rows of a card carry the
-// rounded corners. The frame lives on the measured div directly so the
-// virtualizer's ResizeObserver sees the row's true height without an
-// intermediate wrapper.
-function rowFrameClass(
- row: Exclude<ResultRow, { kind: "gap" }>,
-): string {
- const base =
- "border-l border-r border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 overflow-hidden";
- const top = row.isFirstInCard
- ? "rounded-t-lg border-t"
- : "border-t border-zinc-200/60 dark:border-zinc-800/60";
- const bottom = row.isLastInCard ? "rounded-b-lg border-b" : "";
- return `${base} ${top} ${bottom}`;
-}
-
-function ResultRowView({
- row,
+function ResultCard({
+ group,
leavesById,
+ orderedLeafIds,
activeVideo,
activeTime,
openWithMode,
}: {
- row: Exclude<ResultRow, { kind: "gap" }>;
+ group: ResultGroup;
leavesById: ReadonlyMap<string, LeafInfo>;
+ orderedLeafIds: ReadonlyArray<string>;
activeVideo: string | null;
activeTime: number | null;
openWithMode: (slug: string, hit?: LayerHit) => void;
}) {
- if (row.kind === "card-header") {
- const g = row.group;
- return (
- <div data-result-slug={g.slug} data-card-header="">
- <button
- type="button"
- onClick={() => openWithMode(g.slug)}
- className="w-full text-left px-4 py-2 bg-zinc-50 dark:bg-zinc-900/60 hover:bg-zinc-100 dark:hover:bg-zinc-800 flex items-baseline gap-2"
- >
- <span className="font-medium truncate flex-1 min-w-0">
- {g.title}
- </span>
- {g.isLivestream && <LivestreamBadge />}
- {g.ageRestricted && <AgeRestrictedBadge />}
- <span className="text-xs text-zinc-500 shrink-0">
- {g.channel && `${g.channel} · `}
- {g.date}
- {` · ${g.hits.length} hit${g.hits.length === 1 ? "" : "s"}`}
- </span>
- </button>
- </div>
- );
- }
-
- if (row.kind === "leaf-header") {
- const leafInfo = leavesById.get(row.leafId);
- if (!leafInfo) return null;
- // data-leaf-section is on a child so the existing test selector
- // `[data-result-slug="X"] [data-leaf-section]` (descendant combinator)
- // still matches across the flat-row structure.
- return (
- <div data-result-slug={row.group.slug}>
- <div
- className="flex items-center gap-2 px-4 py-1.5 bg-zinc-50/60 dark:bg-zinc-900/40"
- data-leaf-section={row.leafId}
- >
- <LayerSwatch leafId={row.leafId} size="xs" />
- <span className="text-[10px] uppercase tracking-wide text-zinc-500">
- {leafInfo.scope === "metadata"
- ? "Title / channel"
- : leafInfo.scope === "chat"
- ? "Live chat"
- : "Transcripts"}
- </span>
- <span className="font-mono text-xs text-zinc-600 dark:text-zinc-400 truncate">
- {leafInfo.query}
- </span>
- <span className="ml-auto text-[10px] text-zinc-500">
- {row.hitCount} hit{row.hitCount === 1 ? "" : "s"}
- </span>
- </div>
- </div>
- );
- }
+ // Bucket hits per contributing leaf so the user sees one section per
+ // layer rather than an interleaved mishmash.
+ const buckets = useMemo(() => {
+ const m = new Map<string, LayerHit[]>();
+ for (const id of orderedLeafIds) m.set(id, []);
+ for (const h of group.hits) {
+ const arr = m.get(h.leafId);
+ if (arr) arr.push(h);
+ }
+ return m;
+ }, [group.hits, orderedLeafIds]);
- const leafInfo = leavesById.get(row.leafId);
- if (!leafInfo) return null;
- const h = row.hit;
- const isActive =
- activeVideo === row.group.slug && activeTime === h.start;
return (
- <div data-result-slug={row.group.slug}>
+ <div
+ data-result-slug={group.slug}
+ data-card-header=""
+ className="border border-zinc-200 dark:border-zinc-800 rounded-lg overflow-hidden bg-white dark:bg-zinc-900"
+ >
<button
type="button"
- onClick={() => openWithMode(row.group.slug, h)}
- className={`w-full text-left flex gap-3 px-4 py-2 hover:bg-zinc-50 dark:hover:bg-zinc-800 ${
- isActive
- ? "bg-amber-100/70 dark:bg-amber-500/10 ring-1 ring-inset ring-amber-400/60"
- : ""
- }`}
+ onClick={() => openWithMode(group.slug)}
+ className="w-full text-left px-4 py-2 bg-zinc-50 dark:bg-zinc-900/60 border-b border-zinc-200 dark:border-zinc-800 hover:bg-zinc-100 dark:hover:bg-zinc-800 flex items-baseline gap-2"
>
- <span className="text-xs font-mono text-zinc-500 shrink-0 w-16">
- {h.scope === "metadata" ? "—" : formatSeconds(h.start)}
+ <span className="font-medium truncate flex-1 min-w-0">
+ {group.title}
</span>
- {h.track && h.track !== "live_chat" && <TrackBadge track={h.track} />}
- <span className="flex-1 min-w-0 text-sm">
- {highlight(h.text, leafInfo.query, leafInfo.useRegex)}
+ {group.isLivestream && <LivestreamBadge />}
+ {group.ageRestricted && <AgeRestrictedBadge />}
+ <span className="text-xs text-zinc-500 shrink-0">
+ {group.channel && `${group.channel} · `}
+ {group.date}
+ {` · ${group.hits.length} hit${group.hits.length === 1 ? "" : "s"}`}
</span>
</button>
+ <ul className="flex flex-col divide-y divide-zinc-200 dark:divide-zinc-800">
+ {Array.from(buckets.entries()).map(([leafId, hits]) => {
+ if (hits.length === 0) return null;
+ const leafInfo = leavesById.get(leafId);
+ if (!leafInfo) return null;
+ return (
+ <li key={leafId}>
+ <div
+ className="flex items-center gap-2 px-4 py-1.5 bg-zinc-50/60 dark:bg-zinc-900/40 border-b border-zinc-200 dark:border-zinc-800"
+ data-leaf-section={leafId}
+ >
+ <LayerSwatch leafId={leafId} size="xs" />
+ <span className="text-[10px] uppercase tracking-wide text-zinc-500">
+ {leafInfo.scope === "metadata"
+ ? "Title / channel"
+ : leafInfo.scope === "chat"
+ ? "Live chat"
+ : "Transcripts"}
+ </span>
+ <span className="font-mono text-xs text-zinc-600 dark:text-zinc-400 truncate">
+ {leafInfo.query}
+ </span>
+ <span className="ml-auto text-[10px] text-zinc-500">
+ {hits.length} hit{hits.length === 1 ? "" : "s"}
+ </span>
+ </div>
+ <ul className="flex flex-col divide-y divide-zinc-200/60 dark:divide-zinc-800/60">
+ {hits.map((h, i) => {
+ const isActive =
+ activeVideo === group.slug && activeTime === h.start;
+ return (
+ <li key={i}>
+ <button
+ type="button"
+ onClick={() => openWithMode(group.slug, h)}
+ className={`w-full text-left flex gap-3 px-4 py-2 hover:bg-zinc-50 dark:hover:bg-zinc-800 ${
+ isActive
+ ? "bg-amber-100/70 dark:bg-amber-500/10 ring-1 ring-inset ring-amber-400/60"
+ : ""
+ }`}
+ >
+ <span className="text-xs font-mono text-zinc-500 shrink-0 w-16">
+ {h.scope === "metadata" ? "—" : formatSeconds(h.start)}
+ </span>
+ {h.track && h.track !== "live_chat" && (
+ <TrackBadge track={h.track} />
+ )}
+ <span className="flex-1 min-w-0 text-sm">
+ {highlight(h.text, leafInfo.query, leafInfo.useRegex)}
+ </span>
+ </button>
+ </li>
+ );
+ })}
+ </ul>
+ </li>
+ );
+ })}
+ </ul>
</div>
);
}
diff --git a/export/CHANGELOG.md b/export/CHANGELOG.md
@@ -3,7 +3,7 @@
## [Unreleased]
### Changed
-- Search results now render through a single virtualised list (`@tanstack/react-virtual`) covering every video card, leaf-section header, and individual hit row. Scrolling stays smooth across searches with hundreds of matching videos or a single video with hundreds of hits — only the rows currently in view are mounted. The manual "Show more videos" pagination button and the "Videos per page" advanced option are gone; all matching videos render in one continuous list. When the search pipeline still caps at its per-batch hit limit, a "Load more results" button surfaces below the list (the existing `hitLimit` flow, just relabeled now that pagination of cards is no longer needed).
+- Search results now render through a virtualised list (`@tanstack/react-virtual`), one element per matching video. Cards mount in full when they enter the viewport (header + leaf sections + hits laid out in normal CSS flow inside the card) and unmount once scrolled away — earlier iterations virtualised every row inside each card, which produced occasional empty bands between a card's header and its leaf sections while ResizeObserver caught up to row sizes. The manual "Show more videos" pagination button and the "Videos per page" advanced option are gone; all matching videos render in one continuous virtualised list. When the search pipeline still caps at its per-batch hit limit, a "Load more results" button surfaces below the list (the existing `hitLimit` flow, just relabelled now that pagination of cards is no longer needed).
### Added
- Composable layered search. The single search bar is now a query builder: any number of layers can be combined with AND / OR / NOT and arbitrary nesting. Each layer targets a scope (transcripts, live chat, or title/channel), and matches from every contributing layer are surfaced in the result list with a per-layer colour swatch. Per-layer results are memoised in IndexedDB so editing a deeper leaf only re-runs that layer against its already-narrowed scope. Simple one-keyword search still looks like a single input — the builder collapses to compact mode when there's only one layer. The composite query serialises into a new `qt=` URL parameter; legacy `?q=&m=&re=` links auto-migrate to a one-layer tree.
diff --git a/export/e2e/search-results-virtualization.spec.ts b/export/e2e/search-results-virtualization.spec.ts
@@ -122,20 +122,30 @@ test.describe("search results virtualization", () => {
).toHaveCount(0);
});
- test("dense per-card hit list is also virtualized", async ({ page }) => {
+ test("dense card renders all its hits when mounted, none when scrolled away", async ({
+ page,
+ }) => {
await gotoBulkSearch(page);
await expect(page.locator('[data-testid="results-summary"]')).toContainText(
`${VIDEO_COUNT} videos`,
);
- // The dense video has DENSE_HIT_COUNT hits — without virtualization they
- // would all be in the DOM at once. With it, only a small slice exists.
+ // The dense card is the first in the list; while it's in view all of
+ // its hit rows are in the DOM (intra-card layout is normal flow, not
+ // nested virtualization).
const denseHitButtons = page.locator(
- `[data-result-slug="${DENSE_SLUG}"]:not([data-card-header]) button`,
+ `[data-card-header][data-result-slug="${DENSE_SLUG}"] li button`,
);
- const renderedHitCount = await denseHitButtons.count();
- expect(renderedHitCount).toBeGreaterThan(0);
- expect(renderedHitCount).toBeLessThan(DENSE_HIT_COUNT);
+ await expect(denseHitButtons).toHaveCount(DENSE_HIT_COUNT);
+
+ // Scrolling past the dense card unmounts it (along with all of its
+ // hit buttons) — that's the per-card virtualization the new
+ // structure delivers.
+ await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
+ await expect(
+ page.locator(`[data-card-header][data-result-slug="${DENSE_SLUG}"]`),
+ ).toHaveCount(0);
+ await expect(denseHitButtons).toHaveCount(0);
});
test("the legacy Show more videos button is gone", async ({ page }) => {
@@ -156,10 +166,11 @@ test.describe("search results virtualization", () => {
`${VIDEO_COUNT} videos`,
);
- // The dense card is the first one rendered; clicking its header (not a
- // hit row) opens the modal for it.
+ // The dense card is the first one rendered; clicking its header (the
+ // direct-child button of the card-header div, not one of the nested
+ // hit-row buttons) opens the modal for it.
await page
- .locator(`[data-card-header][data-result-slug="${DENSE_SLUG}"] button`)
+ .locator(`[data-card-header][data-result-slug="${DENSE_SLUG}"] > button`)
.click();
await page
.getByRole("button", { name: "Mark clip start at current time" })