feat: 视频列表无限滚动 + 虚拟列表 | Infinite scroll & virtual list for the video listing - #90
Open
thazjswe42700 wants to merge 2 commits into
Open
feat: 视频列表无限滚动 + 虚拟列表 | Infinite scroll & virtual list for the video listing#90thazjswe42700 wants to merge 2 commits into
thazjswe42700 wants to merge 2 commits into
Conversation
The /list page paged through videos twenty at a time; browsing a large catalog meant repeated round trips through the pagination bar, and rendering a whole page of cards at once put every node in the DOM. Replace the pagination with cursor-style accumulation plus windowed rendering: - useInfiniteListing keeps one accumulating session per query. The cursor advances by requested count rather than rendered count, so server-side dedup and hidden rows cannot shift the offsets, and batches are merged by id because page boundaries move when videos are inserted or removed mid-scroll. - VirtualVideoGrid folds the flat list into rows and hands them to @tanstack/react-virtual's window virtualizer, so only the rows near the viewport stay mounted while the canvas keeps the scrollbar as tall as the full catalog. Row heights and column counts are measured from the real layout, so the existing responsive grid CSS still drives the design. - Loading more is derived from the rendered window instead of a separate sentinel node, which a virtualized list can recycle out from under itself. Loading, end-of-list and tail-error states live at the bottom of the list; a failed tail retries only that batch. - Back/forward restores both the loaded batches and the position. The progress is stored per history entry, and the position is captured while the list is still on screen: reading window.scrollY during unmount returns a value the browser already clamped against the next page's shorter document. Scrolling to the top is now instant on this page and in the back-to-top button: the virtualizer's measurement-driven scroll adjustments interrupt smooth animations and leave the page stranded partway. backend/cmd/seed-videos generates listable copies of an existing video for local load testing, alongside the other one-off cmd tools. Verified against a 600-video local catalog: the list loads to the end, keeps at most ~32 cards in the DOM, reports a document height matching the full catalog, and returns to the exact scroll position after visiting a video and going back.
The home page stacked two fixed-size sections, 随机推荐 above 最新视频, each capped at twelve cards with a refresh button as the only way to see anything else. Neither section could be browsed beyond that first batch. Present them as a tab bar instead, each tab an infinite-scrolling virtualized feed: - The accumulation engine now takes a feed source, so the same batching, dedup, abort and session-cache logic backs both the listing page and the home tabs. A source describes only how to fetch batch N. - 随机推荐 keeps the rotating /api/home feed. Its cursor lives on the server, capped at twelve per call and not idempotent, so the source clamps the batch size, opts out of restore-by-refetch, and ends the feed when a whole batch turns out to be videos already on screen. - 最新视频 reads /api/list?sort=latest rather than /api/home/latest: the home endpoint only ever cycles the newest 96 rows, which cannot back an infinite scroll. - The active tab is a URL parameter, so back and forward return to the tab the user was on, along with its loaded batches and position. - Refresh only appears on 随机推荐, where re-rolling the shuffle is what the button means; on the time-ordered tab it had nothing to do. Search and tag results keep their pagination — those are the results users page back into deliberately. Verified in a browser against a 600-video catalog: both tabs load continuously while the DOM holds at most ~32 cards, the latest tab scrolls well past the 96-row home rotation cap, tab state survives back/forward, and search results still render their pagination.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #80
中文
改动内容
列表页(
/list):原本每 20 条一页,浏览大库要反复点分页条,而且一整页卡片会同时挂进 DOM。现在改成游标式累积 + 窗口化渲染:useInfiniteListing按查询条件维护一个累积会话。游标按请求数量推进而不是渲染数量 —— 服务端去重和隐藏行不会让偏移量错位;批次按 id 合并,因为滚动过程中有视频增删时页边界会移动。VirtualVideoGrid把扁平列表折成行,交给@tanstack/react-virtual的 window virtualizer,只有视口附近的行保持挂载,同时画布高度撑满整个库,滚动条比例正确。行高和列数从真实布局测量,所以现有的响应式栅格 CSS 依然主导视觉。window.scrollY拿到的是浏览器已经按下一页较短文档钳制过的值。首页:原本是「随机推荐」和「最新视频」两个固定区块,各封顶 12 张卡片,除了刷新按钮没有别的浏览方式。现在改成标签栏,每个标签是一条无限滚动的虚拟化 feed:
/api/home。它的游标在服务端、每次最多 12 条且非幂等,所以 source 会钳制批次大小、放弃 refetch 式恢复,并在一整批全是已在屏视频时判定 feed 结束。/api/list?sort=latest而不是/api/home/latest—— 后者只循环最新 96 行,撑不起无限滚动。搜索和标签结果保持分页 —— 那是用户会刻意翻回去的结果集。
关于 issue 里建议的方案
issue 里提到
react-window/react-virtuoso/@tanstack/virtual三选一,这里用的是@tanstack/react-virtual(唯一新增依赖)。选它是因为它的 window virtualizer 直接虚拟化页面滚动,不需要给列表套一个固定高度的滚动容器 —— 后者会破坏现有的整页滚动手感和移动端地址栏收起行为。触发方式没有用 Intersection Observer 哨兵,原因见上:虚拟列表会把哨兵节点回收掉,导致触发时灵时不灵。
测试
新增/更新 9 个测试文件,覆盖:批次累积与按 id 合并、游标推进、中断与会话缓存、行折叠与列数测量、尾部错误只重试该批、滚动位置在 unmount 前捕获、标签状态随 URL 前进后退、随机 feed 的结束判定。
tsc --noEmit干净,521 个前端测试全部通过。另外在一个 600 条视频的库上用浏览器实测:两个标签都能连续加载,DOM 最多保持约 32 张卡片,最新标签能滚过 96 行的首页轮换上限,标签状态在前进后退后保持。English
What changed
Listing page (
/list): it paged through videos twenty at a time, so browsing a large catalog meant repeated round trips through the pagination bar, and rendering a whole page put every card in the DOM. Replaced with cursor-style accumulation plus windowed rendering:useInfiniteListingkeeps one accumulating session per query. The cursor advances by requested count rather than rendered count, so server-side dedup and hidden rows cannot shift the offsets, and batches are merged by id because page boundaries move when videos are inserted or removed mid-scroll.VirtualVideoGridfolds the flat list into rows and hands them to@tanstack/react-virtual's window virtualizer, so only the rows near the viewport stay mounted while the canvas keeps the scrollbar as tall as the full catalog. Row heights and column counts are measured from the real layout, so the existing responsive grid CSS still drives the design.window.scrollYduring unmount returns a value the browser already clamped against the next page's shorter document.Home page: it stacked two fixed-size sections, 随机推荐 above 最新视频, each capped at twelve cards with a refresh button as the only way to see anything else. They are now a tab bar, each tab an infinite-scrolling virtualized feed:
/api/homefeed. Its cursor lives on the server, capped at twelve per call and not idempotent, so the source clamps the batch size, opts out of restore-by-refetch, and ends the feed when a whole batch turns out to be videos already on screen./api/list?sort=latestrather than/api/home/latest: the home endpoint only ever cycles the newest 96 rows, which cannot back an infinite scroll.Search and tag results keep their pagination — those are the results users page back into deliberately.
On the approach suggested in the issue
The issue listed
react-window/react-virtuoso/@tanstack/virtual; this uses@tanstack/react-virtual, the only added dependency. It was chosen for its window virtualizer, which virtualizes the page scroll directly instead of requiring a fixed-height scroll container around the list — the latter would break the existing whole-page scrolling feel and the mobile address-bar collapse behaviour.Loading is not triggered by an Intersection Observer sentinel, for the reason above: a virtualized list recycles the sentinel node out from under itself, making the trigger unreliable.
Tests
Nine test files added or updated, covering batch accumulation and merge-by-id, cursor advancement, abort and session cache, row folding and column measurement, tail errors retrying only their own batch, scroll position captured before unmount, tab state surviving back/forward, and end-of-feed detection for the random feed.
tsc --noEmitis clean and all 521 frontend tests pass. Also verified in a browser against a 600-video catalog: both tabs load continuously while the DOM holds at most ~32 cards, the latest tab scrolls well past the 96-row home rotation cap, and tab state survives back/forward.🤖 Generated with Claude Code