Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit ac60add03bb335b8380e5ec6648d6188f40c38f8
parent 6d4a06d76f4382ad3d56d372b284a84cb94f98fe
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Fri, 22 May 2026 11:04:10 -0400

virtualize big lists

Diffstat:
Mcommon/components/TranscriptModal.tsx | 14++++++++------
Mcommon/components/TranscriptSearch.tsx | 427+++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------
Acommon/components/VirtualRow.tsx | 35+++++++++++++++++++++++++++++++++++
Mcommon/components/exportAdvancedStorage.ts | 7++-----
Mexport/CHANGELOG.md | 3+++
Aexport/e2e/fixtures/many-results.ts | 105+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mexport/e2e/query-tree.spec.ts | 7++++---
Aexport/e2e/search-results-virtualization.spec.ts | 172+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
8 files changed, 614 insertions(+), 156 deletions(-)

diff --git a/common/components/TranscriptModal.tsx b/common/components/TranscriptModal.tsx @@ -9,6 +9,7 @@ import { usePlayerTime, } from "./PlayerProvider"; import { AgeRestrictedBadge, LivestreamBadge } from "./badges"; +import { VirtualRow } from "./VirtualRow"; import { formatTimestamp } from "../lib/vtt"; import { formatDate } from "../lib/format"; @@ -331,11 +332,12 @@ const CueRow = memo(function CueRow({ offset: number; }) { return ( - <li - ref={measureRef} - data-index={virtualIndex} - className={`absolute left-0 right-0 ${isActive ? "bg-blue-950/50" : ""}`} - style={{ transform: `translateY(${offset}px)` }} + <VirtualRow + as="li" + measureRef={measureRef} + index={virtualIndex} + offset={offset} + className={isActive ? "bg-blue-950/50" : ""} > <button type="button" @@ -352,7 +354,7 @@ const CueRow = memo(function CueRow({ <span>{cue.body}</span> </span> </button> - </li> + </VirtualRow> ); }); diff --git a/common/components/TranscriptSearch.tsx b/common/components/TranscriptSearch.tsx @@ -1,7 +1,9 @@ "use client"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useWindowVirtualizer } from "@tanstack/react-virtual"; import { usePlayer } from "./PlayerProvider"; +import { VirtualRow } from "./VirtualRow"; import { useSummaries } from "./summariesCache"; import { useChannelSubsManifests, useSubsManifest } from "./subsCache"; import type { LayerHit } from "./searchPipeline"; @@ -71,7 +73,10 @@ type ResultGroup = { const DEFAULT_MAX_HITS = 500; const DEFAULT_FETCH_CONCURRENCY = 6; const DEFAULT_FLUSH_INTERVAL_MS = 120; -const DEFAULT_GROUPS_PAGE_SIZE = 50; +// 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; // Legacy URL keys still parsed for hydration (old share-links), but stripped // on the first commit so the modern `qt=` is canonical going forward. @@ -278,10 +283,6 @@ export default function TranscriptSearch() { // Advanced options — configurable from the UI, pre-filled with defaults. const [hitBatchValue, setHitBatchValue] = useState(DEFAULT_MAX_HITS); const [hitBatchUnlimited, setHitBatchUnlimited] = useState(false); - const [groupsBatchValue, setGroupsBatchValue] = useState( - DEFAULT_GROUPS_PAGE_SIZE, - ); - const [groupsBatchUnlimited, setGroupsBatchUnlimited] = useState(false); const [fetchConcurrency, setFetchConcurrency] = useState( DEFAULT_FETCH_CONCURRENCY, ); @@ -289,9 +290,6 @@ export default function TranscriptSearch() { DEFAULT_FLUSH_INTERVAL_MS, ); const hitBatchSize = hitBatchUnlimited ? Infinity : hitBatchValue; - const groupsBatchSize = groupsBatchUnlimited - ? Infinity - : groupsBatchValue; const [advancedHydrated, setAdvancedHydrated] = useState(false); useEffect(() => { @@ -300,8 +298,6 @@ export default function TranscriptSearch() { if (snap) { setHitBatchValue(snap.hitBatchValue); setHitBatchUnlimited(snap.hitBatchUnlimited); - setGroupsBatchValue(snap.groupsBatchValue); - setGroupsBatchUnlimited(snap.groupsBatchUnlimited); setFetchConcurrency(snap.fetchConcurrency); setFlushIntervalMs(snap.flushIntervalMs); } @@ -313,8 +309,6 @@ export default function TranscriptSearch() { saveAdvanced({ hitBatchValue, hitBatchUnlimited, - groupsBatchValue, - groupsBatchUnlimited, fetchConcurrency, flushIntervalMs, }); @@ -322,23 +316,17 @@ export default function TranscriptSearch() { advancedHydrated, hitBatchValue, hitBatchUnlimited, - groupsBatchValue, - groupsBatchUnlimited, fetchConcurrency, flushIntervalMs, ]); const [hitLimit, setHitLimit] = useState(DEFAULT_MAX_HITS); - const [groupsShown, setGroupsShown] = useState<number>( - DEFAULT_GROUPS_PAGE_SIZE, - ); const pipelineRef = useRef<TreeController | null>(null); const { openTranscript } = usePlayer(); - // Reset the live caps on each new committed query. + // Reset the live hit cap on each new committed query. useEffect(() => { setHitLimit(hitBatchSize); - setGroupsShown(groupsBatchSize); // eslint-disable-next-line react-hooks/exhaustive-deps }, [committedHash]); @@ -1380,15 +1368,6 @@ export default function TranscriptSearch() { onValueChange={setHitBatchValue} onUnlimitedChange={setHitBatchUnlimited} /> - <LimitField - label="Videos per page" - hint="How many matching videos to show at a time." - value={groupsBatchValue} - unlimited={groupsBatchUnlimited} - defaultValue={DEFAULT_GROUPS_PAGE_SIZE} - onValueChange={setGroupsBatchValue} - onUnlimitedChange={setGroupsBatchUnlimited} - /> <NumberField label="Fetch concurrency" hint="Parallel transcript downloads while searching. Applies on next search." @@ -1412,9 +1391,7 @@ export default function TranscriptSearch() { type="button" onClick={() => { setHitBatchUnlimited(true); - setGroupsBatchUnlimited(true); setHitLimit(Infinity); - setGroupsShown(Infinity); }} className="px-3 py-1.5 rounded-md border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 hover:bg-zinc-50 dark:hover:bg-zinc-800 text-xs" > @@ -1425,8 +1402,6 @@ export default function TranscriptSearch() { onClick={() => { setHitBatchValue(DEFAULT_MAX_HITS); setHitBatchUnlimited(false); - setGroupsBatchValue(DEFAULT_GROUPS_PAGE_SIZE); - setGroupsBatchUnlimited(false); setFetchConcurrency(DEFAULT_FETCH_CONCURRENCY); setFlushIntervalMs(DEFAULT_FLUSH_INTERVAL_MS); clearAdvanced(); @@ -1473,33 +1448,23 @@ export default function TranscriptSearch() { <p className="text-sm text-zinc-500">No matching videos.</p> )} {resultGroups.length > 0 && ( - <div className="flex flex-col gap-3"> - {resultGroups.slice(0, groupsShown).map((g) => ( - <ResultCard - key={g.slug} - group={g} - leavesById={leavesById} - orderedLeafIds={orderedLeafIds} - activeVideo={activeVideo} - activeTime={activeTime} - openWithMode={openWithMode} - /> - ))} - </div> + <VirtualResultList + resultGroups={resultGroups} + leavesById={leavesById} + orderedLeafIds={orderedLeafIds} + activeVideo={activeVideo} + activeTime={activeTime} + openWithMode={openWithMode} + /> )} - {(resultGroups.length > groupsShown || capped) && ( + {capped && ( <div className="mt-3 flex justify-center"> <button type="button" - onClick={() => { - setGroupsShown((n) => n + groupsBatchSize); - if (capped) setHitLimit((l) => l + hitBatchSize); - }} + onClick={() => setHitLimit((l) => l + hitBatchSize)} className="px-4 py-2 rounded-md border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 hover:bg-zinc-50 dark:hover:bg-zinc-800 text-sm" > - {resultGroups.length > groupsShown - ? `Show more videos (${resultGroups.length - groupsShown} loaded${capped ? ", more available" : ""})` - : "Show more videos"} + Load more results </button> </div> )} @@ -1511,116 +1476,294 @@ export default function TranscriptSearch() { // ─── Result rendering ──────────────────────────────────────────────────────── -function ResultCard({ - group, +type LeafInfo = { + query: string; + useRegex: boolean; + scope: "transcripts" | "chat" | "metadata"; +}; + +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; + }; + +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, + }); + }); + } + // Flag the last row of this card so the renderer can close its visual + // grouping (rounded-b, border-b, margin to the next card). + const last = out[out.length - 1]; + if (last && last.cardIndex === cardIndex && out.length - 1 > cardStart) { + (last as { isLastInCard: boolean }).isLastInCard = true; + } else if (last && out.length - 1 === cardStart) { + // Card with no contributing hits — header is also the last row. + (last as { isLastInCard: boolean }).isLastInCard = true; + } + }); + 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}`; + } +} + +function VirtualResultList({ + resultGroups, leavesById, orderedLeafIds, activeVideo, activeTime, openWithMode, }: { - group: ResultGroup; - leavesById: ReadonlyMap< - string, - { query: string; useRegex: boolean; scope: "transcripts" | "chat" | "metadata" } - >; + resultGroups: ReadonlyArray<ResultGroup>; + leavesById: ReadonlyMap<string, LeafInfo>; orderedLeafIds: ReadonlyArray<string>; activeVideo: string | null; activeTime: number | null; openWithMode: (slug: string, hit?: LayerHit) => void; }) { - // 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 flatRows = useMemo( + () => buildResultRows(resultGroups, orderedLeafIds), + [resultGroups, orderedLeafIds], + ); + + const listRef = useRef<HTMLDivElement | null>(null); + const [scrollMargin, setScrollMargin] = useState(0); + + useEffect(() => { + if (typeof window === "undefined") return; + const update = () => { + const el = listRef.current; + if (!el) return; + setScrollMargin(el.getBoundingClientRect().top + window.scrollY); + }; + update(); + const ro = new ResizeObserver(update); + if (listRef.current) ro.observe(listRef.current); + ro.observe(document.body); + window.addEventListener("resize", update); + return () => { + ro.disconnect(); + window.removeEventListener("resize", update); + }; + }, []); + + const rowVirtualizer = useWindowVirtualizer({ + count: flatRows.length, + estimateSize: () => RESULT_ROW_ESTIMATE_PX, + overscan: 12, + scrollMargin, + }); + + const virtualItems = rowVirtualizer.getVirtualItems(); + const totalSize = rowVirtualizer.getTotalSize(); return ( <div - className="border border-zinc-200 dark:border-zinc-800 rounded-lg overflow-hidden bg-white dark:bg-zinc-900" - data-result-slug={group.slug} + ref={listRef} + className="relative" + style={{ height: totalSize }} > + {virtualItems.map((vi) => { + const row = flatRows[vi.index]; + 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} + /> + </VirtualRow> + ); + })} + </div> + ); +} + +// Tailwind classes that recreate the original card's rounded border + the +// gap between cards. Side borders + a top border on every non-first row +// give the divide-y effect without a separate wrapper element. +function rowFrameClass(row: ResultRow): string { + const base = + "border-l border-r border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900"; + 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 mb-3" : ""; + return `${base} ${top} ${bottom}`; +} + +function ResultRowView({ + row, + leavesById, + activeVideo, + activeTime, + openWithMode, +}: { + row: ResultRow; + leavesById: ReadonlyMap<string, LeafInfo>; + 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> + ); + } + + 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}> <button type="button" - 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" + 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" + : "" + }`} > - <span className="font-medium truncate flex-1 min-w-0"> - {group.title} + <span className="text-xs font-mono text-zinc-500 shrink-0 w-16"> + {h.scope === "metadata" ? "—" : formatSeconds(h.start)} </span> - {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"}`} + {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> - <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/common/components/VirtualRow.tsx b/common/components/VirtualRow.tsx @@ -0,0 +1,35 @@ +import type { ReactNode } from "react"; + +type VirtualRowElement = "div" | "li"; + +export function VirtualRow({ + as = "div", + measureRef, + index, + offset, + className, + children, +}: { + as?: VirtualRowElement; + measureRef: (el: Element | null) => void; + index: number; + offset: number; + className?: string; + children: ReactNode; +}) { + const ref = measureRef as (el: HTMLElement | null) => void; + const style = { transform: `translateY(${offset}px)` }; + const cls = `absolute left-0 right-0 ${className ?? ""}`; + if (as === "li") { + return ( + <li ref={ref} data-index={index} style={style} className={cls}> + {children} + </li> + ); + } + return ( + <div ref={ref} data-index={index} style={style} className={cls}> + {children} + </div> + ); +} diff --git a/common/components/exportAdvancedStorage.ts b/common/components/exportAdvancedStorage.ts @@ -9,8 +9,6 @@ const VERSION = 1; export type AdvancedSnapshot = { hitBatchValue: number; hitBatchUnlimited: boolean; - groupsBatchValue: number; - groupsBatchUnlimited: boolean; fetchConcurrency: number; flushIntervalMs: number; }; @@ -33,16 +31,15 @@ function clampInt( return n; } +// Tolerates v1 records that still carry the now-removed `groupsBatch*` +// fields — they're silently ignored. Only `hitBatchUnlimited` is required. function parseSnapshot(raw: unknown): AdvancedSnapshot | null { if (!raw || typeof raw !== "object") return null; const r = raw as Record<string, unknown>; if (typeof r.hitBatchUnlimited !== "boolean") return null; - if (typeof r.groupsBatchUnlimited !== "boolean") return null; return { hitBatchValue: clampInt(r.hitBatchValue, 1, 1_000_000, 500), hitBatchUnlimited: r.hitBatchUnlimited, - groupsBatchValue: clampInt(r.groupsBatchValue, 1, 1_000_000, 50), - groupsBatchUnlimited: r.groupsBatchUnlimited, fetchConcurrency: clampInt(r.fetchConcurrency, 1, 32, 6), flushIntervalMs: clampInt(r.flushIntervalMs, 16, 2000, 120), }; diff --git a/export/CHANGELOG.md b/export/CHANGELOG.md @@ -2,6 +2,9 @@ ## [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). + ### 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. - "Unwrap" button on every non-root group in the query builder. Flattens the group's children into the parent at the group's position, giving users a fast way back out of a sub-group they no longer want. If the group has NOT set, each promoted child inherits a flipped `negate` so the meaning of the subtree doesn't silently flip. diff --git a/export/e2e/fixtures/many-results.ts b/export/e2e/fixtures/many-results.ts @@ -0,0 +1,105 @@ +// Standalone fixture for the search-results-virtualization spec. Lives in +// its own channel so it can co-exist with the standard `data.ts` fixture +// without changing the route surface other specs depend on. + +export const CHANNEL = "Bulk Channel"; +export const CHANNEL_SLUG = "bulk-channel"; + +export const VIDEO_COUNT = 120; +// One video carries a large number of hits so we can verify the inner-list +// (hits within a single card) is also virtualized — it lives at the +// front of the result list so its rows are guaranteed to be on-screen. +// 350 + 119 = 469 hits total, well under the DEFAULT_MAX_HITS=500 cap so +// the pipeline doesn't mark the result `capped`. +export const DENSE_VIDEO_INDEX = 0; +export const DENSE_HIT_COUNT = 350; + +type Cue = { start: number; end: number; text: string }; + +function videoId(i: number): string { + return `bulk-vid-${String(i).padStart(4, "0")}`; +} + +export function slug(i: number): string { + return `${CHANNEL_SLUG}/${videoId(i)}`; +} + +function makeSummary(i: number) { + return { + slug: slug(i), + id: videoId(i), + channelSlug: CHANNEL_SLUG, + title: `Bulk video #${i}`, + uploadDate: "20260101", + date: "2026-01-01", + duration: "5:00", + channel: CHANNEL, + isLivestream: false, + ageRestricted: false, + isDeleted: false, + platform: "rumble" as const, + webpageUrl: `https://example.com/${videoId(i)}`, + }; +} + +function cuesFor(i: number): Cue[] { + if (i === DENSE_VIDEO_INDEX) { + const out: Cue[] = []; + for (let k = 0; k < DENSE_HIT_COUNT; k++) { + const t = 1 + k * 2; + out.push({ start: t, end: t + 2, text: `alpha hit ${k} in dense video` }); + } + return out; + } + // Other videos: a single matching line so each shows up as one hit. + return [{ start: 5, end: 9, text: `alpha line for video ${i}` }]; +} + +export function summaries() { + const out = []; + for (let i = 0; i < VIDEO_COUNT; i++) out.push(makeSummary(i)); + return out; +} + +export function summariesManifest() { + return { + version: 2, + totalCount: VIDEO_COUNT, + pageSize: VIDEO_COUNT, + pageCount: 1, + generatedAt: new Date().toISOString(), + channels: [{ name: CHANNEL, count: VIDEO_COUNT }], + }; +} + +export function transcriptPage() { + return summaries().map((s, i) => ({ + ...s, + duration: 300, + description: "", + cues: cuesFor(i), + })); +} + +export function channelTranscriptsManifest() { + const slugToPage: Record<string, number> = {}; + for (let i = 0; i < VIDEO_COUNT; i++) slugToPage[videoId(i)] = 0; + return { + version: 1, + channelSlug: CHANNEL_SLUG, + pageCount: 1, + maxPageBytes: 8388608, + generatedAt: new Date().toISOString(), + slugToPage, + }; +} + +export function subsManifest() { + return { + version: 3, + channels: [], + totalCount: 0, + liveChatTotalCount: 0, + generatedAt: new Date().toISOString(), + }; +} diff --git a/export/e2e/query-tree.spec.ts b/export/e2e/query-tree.spec.ts @@ -40,9 +40,10 @@ const CHAT_SMALL_SLUG = `${CHANNEL_SLUG}/${VIDEO_CHAT_SMALL}`; const CHAT_LARGE_SLUG = `${CHANNEL_SLUG}/${VIDEO_CHAT_LARGE}`; async function expectResultSlugs(page: Page, slugs: string[]) { - // Wait until the search has settled by waiting for the summary line to - // report at least the expected count. - const cards = page.locator("[data-result-slug]"); + // Wait until the search has settled. We count unique cards by selecting + // card-header rows only — leaf-header and hit rows also carry + // `data-result-slug` (for compound selectors) but would over-count here. + const cards = page.locator("[data-card-header]"); await expect(async () => { const got = await cards.evaluateAll((els) => els.map((e) => e.getAttribute("data-result-slug") ?? ""), diff --git a/export/e2e/search-results-virtualization.spec.ts b/export/e2e/search-results-virtualization.spec.ts @@ -0,0 +1,172 @@ +import { expect, test, type Page, type Route } from "@playwright/test"; +import { + DENSE_HIT_COUNT, + DENSE_VIDEO_INDEX, + VIDEO_COUNT, + channelTranscriptsManifest, + slug, + subsManifest, + summaries, + summariesManifest, + transcriptPage, +} from "./fixtures/many-results"; + +async function fulfillJson(route: Route, body: unknown) { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(body), + }); +} + +async function installBulkRoutes(page: Page) { + await page.route("**/summaries/manifest.json", async (route) => { + await fulfillJson(route, summariesManifest()); + }); + await page.route("**/summaries/page-*.json", async (route) => { + await fulfillJson(route, summaries()); + }); + await page.route( + /\/transcripts\/[^/]+\/manifest\.json$/, + async (route) => { + await fulfillJson(route, channelTranscriptsManifest()); + }, + ); + await page.route(/\/transcripts\/[^/]+\/page-\d+\.json$/, async (route) => { + await fulfillJson(route, transcriptPage()); + }); + await page.route("**/subs/manifest.json", async (route) => { + await fulfillJson(route, subsManifest()); + }); +} + +type QtTree = { + k: "g"; + o: "AND"; + c: Array<{ + k: "l"; + q: string; + s: "transcripts" | "chat" | "metadata"; + }>; +}; + +function qt(root: QtTree): string { + return encodeURIComponent(JSON.stringify(root)); +} + +const DENSE_SLUG = slug(DENSE_VIDEO_INDEX); +const LAST_SLUG = slug(VIDEO_COUNT - 1); + +async function gotoBulkSearch(page: Page) { + const tree: QtTree = { + k: "g", + o: "AND", + c: [{ k: "l", q: "alpha", s: "transcripts" }], + }; + await page.goto(`/?qt=${qt(tree)}`); +} + +test.describe("search results virtualization", () => { + test.beforeEach(async ({ page }) => { + await installBulkRoutes(page); + }); + + test("renders only a virtualized window of cards for a large result set", async ({ + page, + }) => { + await gotoBulkSearch(page); + // Wait for the results summary to show all 120 videos. + await expect(page.locator('[data-testid="results-summary"]')).toContainText( + `${VIDEO_COUNT} videos`, + ); + + // First card is on-screen; last card is not yet rendered. + await expect( + page.locator(`[data-card-header][data-result-slug="${DENSE_SLUG}"]`), + ).toBeVisible(); + await expect( + page.locator(`[data-card-header][data-result-slug="${LAST_SLUG}"]`), + ).toHaveCount(0); + + // Virtualization should keep the rendered card count far below the total + // (120 cards plus their leaf headers + hits would otherwise be in the + // thousands of nodes; we just need any non-trivial reduction). + const renderedCardCount = await page + .locator("[data-card-header]") + .count(); + expect(renderedCardCount).toBeGreaterThan(0); + expect(renderedCardCount).toBeLessThan(VIDEO_COUNT); + }); + + test("scrolling reveals later cards and recycles earlier ones", async ({ + page, + }) => { + await gotoBulkSearch(page); + await expect(page.locator('[data-testid="results-summary"]')).toContainText( + `${VIDEO_COUNT} videos`, + ); + + // Initially the dense (first) video is rendered. + await expect( + page.locator(`[data-card-header][data-result-slug="${DENSE_SLUG}"]`), + ).toBeVisible(); + + // Scroll to the bottom; the last slug must appear, and the dense first + // slug should drop out of the DOM (rows recycled). + await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)); + await expect( + page.locator(`[data-card-header][data-result-slug="${LAST_SLUG}"]`), + ).toBeVisible(); + await expect( + page.locator(`[data-card-header][data-result-slug="${DENSE_SLUG}"]`), + ).toHaveCount(0); + }); + + test("dense per-card hit list is also virtualized", 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. + const denseHitButtons = page.locator( + `[data-result-slug="${DENSE_SLUG}"]:not([data-card-header]) button`, + ); + const renderedHitCount = await denseHitButtons.count(); + expect(renderedHitCount).toBeGreaterThan(0); + expect(renderedHitCount).toBeLessThan(DENSE_HIT_COUNT); + }); + + test("the legacy Show more videos button is gone", async ({ page }) => { + await gotoBulkSearch(page); + await expect(page.locator('[data-testid="results-summary"]')).toContainText( + `${VIDEO_COUNT} videos`, + ); + await expect( + page.getByRole("button", { name: /Show more videos/ }), + ).toHaveCount(0); + }); + + test("clicking a card header opens the modal for that slug", async ({ + page, + }) => { + await gotoBulkSearch(page); + await expect(page.locator('[data-testid="results-summary"]')).toContainText( + `${VIDEO_COUNT} videos`, + ); + + // The dense card is the first one rendered; clicking its header (not a + // hit row) opens the modal for it. + await page + .locator(`[data-card-header][data-result-slug="${DENSE_SLUG}"] button`) + .click(); + await page + .getByRole("button", { name: "Mark clip start at current time" }) + .waitFor(); + const params = new URLSearchParams( + await page.evaluate(() => window.location.search), + ); + expect(params.get("v")).toBe(DENSE_SLUG); + }); +});