Moved the virtual-list infinite-scroll primitive into admin-x-framework - #29187
Conversation
|
Bugbot is not enabled for this team, so this pull request was not reviewed. Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs. |
WalkthroughThis change adds a new shared Possibly related PRs
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
| Command | Status | Duration | Result |
|---|---|---|---|
nx run @tryghost/admin-x-settings:test:acceptance |
✅ Succeeded | 11m 51s | View ↗ |
nx run-many --target=build --projects=tag:publi... |
✅ Succeeded | <1s | View ↗ |
nx run ghost:test:ci:integration |
✅ Succeeded | 2m 47s | View ↗ |
nx run-many -t test:unit -p @tryghost/admin-x-f... |
✅ Succeeded | 8m 18s | View ↗ |
nx run ghost:test:integration |
✅ Succeeded | 2m 49s | View ↗ |
nx run ghost:test:legacy |
✅ Succeeded | 3m 29s | View ↗ |
nx run @tryghost/admin:build |
✅ Succeeded | 2m 37s | View ↗ |
nx run @tryghost/koenig-lexical:test:acceptance |
✅ Succeeded | 2m 47s | View ↗ |
Additional runs (9) |
✅ Succeeded | ... | View ↗ |
💡 Verify your cache is correct by running tasks in a sandbox. Read docs ↗
☁️ Nx Cloud last updated this comment at 2026-07-09 14:27:31 UTC
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@apps/admin-x-framework/src/virtual-list/use-infinite-virtual-scroll.tsx`:
- Around line 106-115: The placeholder check in useInfiniteVirtualScroll is too
broad because shouldFetchNextPage relies on truthiness of
itemsToRender.at(-1)?.item, which can incorrectly trigger fetchNextPage for
valid falsy items like 0 or "". Update the logic in useInfiniteVirtualScroll to
detect the offscreen placeholder by comparing the last virtual item’s index
against items.length instead of checking item truthiness, and keep the useEffect
trigger conditions the same.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: e63d2be9-3c89-4e06-bf91-eb6cc514717b
⛔ Files ignored due to path filters (1)
pnpm-lock.yamlis excluded by!**/pnpm-lock.yaml
📒 Files selected for processing (15)
apps/admin-x-framework/package.jsonapps/admin-x-framework/src/index.tsapps/admin-x-framework/src/virtual-list/index.tsapps/admin-x-framework/src/virtual-list/load-more-button.tsxapps/admin-x-framework/src/virtual-list/use-infinite-virtual-scroll.tsxapps/admin-x-framework/src/virtual-list/use-scroll-restoration.tsxapps/admin-x-framework/src/virtual-list/use-virtual-list-window.tsapps/admin-x-framework/test/unit/virtual-list/use-infinite-virtual-scroll.test.tsapps/admin-x-framework/test/unit/virtual-list/use-virtual-list-window.test.tsapps/posts/package.jsonapps/posts/src/components/virtual-table/load-more-button.tsxapps/posts/src/components/virtual-table/use-infinite-virtual-scroll.tsxapps/posts/src/views/Tags/components/tags-list.tsxapps/posts/src/views/comments/components/comments-list.tsxapps/posts/src/views/members/components/members-list.tsx
💤 Files with no reviewable changes (3)
- apps/posts/src/components/virtual-table/load-more-button.tsx
- apps/posts/src/components/virtual-table/use-infinite-virtual-scroll.tsx
- apps/posts/package.json
| // When the final item that is rendered (off screen) lacks data, meaning | ||
| // we render a placeholder, we should start fetching the next page | ||
| const shouldFetchNextPage = | ||
| itemsToRender.at(-1) && !itemsToRender.at(-1)?.item; | ||
|
|
||
| useEffect(() => { | ||
| if (hasNextPage && shouldFetchNextPage && !isFetchingNextPage) { | ||
| fetchNextPage(); | ||
| } | ||
| }, [hasNextPage, shouldFetchNextPage, isFetchingNextPage, fetchNextPage]); |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Use index comparison instead of truthiness for placeholder detection.
!itemsToRender.at(-1)?.item treats any falsy item value (0, "", false, null) as a missing placeholder. As a shared framework primitive, this would cause spurious fetchNextPage calls for consumers with primitive-valued items. Compare the virtual item's index against items.length instead.
🔧 Proposed fix
- // When the final item that is rendered (off screen) lacks data, meaning
- // we render a placeholder, we should start fetching the next page
- const shouldFetchNextPage =
- itemsToRender.at(-1) && !itemsToRender.at(-1)?.item;
+ // When the final rendered row is a placeholder (its index is beyond the
+ // loaded items array), we should start fetching the next page
+ const lastRendered = itemsToRender.at(-1);
+ const shouldFetchNextPage =
+ lastRendered !== undefined && lastRendered.virtualItem.index >= items.length;📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| // When the final item that is rendered (off screen) lacks data, meaning | |
| // we render a placeholder, we should start fetching the next page | |
| const shouldFetchNextPage = | |
| itemsToRender.at(-1) && !itemsToRender.at(-1)?.item; | |
| useEffect(() => { | |
| if (hasNextPage && shouldFetchNextPage && !isFetchingNextPage) { | |
| fetchNextPage(); | |
| } | |
| }, [hasNextPage, shouldFetchNextPage, isFetchingNextPage, fetchNextPage]); | |
| // When the final rendered row is a placeholder (its index is beyond the | |
| // loaded items array), we should start fetching the next page | |
| const lastRendered = itemsToRender.at(-1); | |
| const shouldFetchNextPage = | |
| lastRendered !== undefined && lastRendered.virtualItem.index >= items.length; | |
| useEffect(() => { | |
| if (hasNextPage && shouldFetchNextPage && !isFetchingNextPage) { | |
| fetchNextPage(); | |
| } | |
| }, [hasNextPage, shouldFetchNextPage, isFetchingNextPage, fetchNextPage]); |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/admin-x-framework/src/virtual-list/use-infinite-virtual-scroll.tsx`
around lines 106 - 115, The placeholder check in useInfiniteVirtualScroll is too
broad because shouldFetchNextPage relies on truthiness of
itemsToRender.at(-1)?.item, which can incorrectly trigger fetchNextPage for
valid falsy items like 0 or "". Update the logic in useInfiniteVirtualScroll to
detect the offscreen placeholder by comparing the last virtual item’s index
against items.length instead of checking item truthiness, and keep the useEffect
trigger conditions the same.
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #29187 +/- ##
=======================================
Coverage 74.04% 74.04%
=======================================
Files 1570 1570
Lines 136629 136629
Branches 16519 16519
=======================================
+ Hits 101163 101164 +1
- Misses 34430 34460 +30
+ Partials 1036 1005 -31
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
- The virtual-table scroll hooks were posts-only, but comments, members and future analytics lists all need the same infinite-scroll + windowing behaviour; framework is the shared home since it already owns the react-query and router coupling these hooks depend on - Generalized the API: windowSize is now a useVirtualListWindow option (was a hardcoded 1000), the scroll-element resolver is injectable on both scroll hooks (was hardwired to shade), and the react-query infinite-query input is documented as the InfiniteQueryResultLike interface - Exposed the primitive via a dedicated @tryghost/admin-x-framework/virtual-list subpath plus the root barrel, matching framework's existing export conventions - Kept LoadMoreButton with the hooks (not shade): it is the paired affordance for the window's canLoadMore/loadMore and its copy is specific to this pattern, not a general-purpose design-system atom - Rewired the posts tag/comment/member lists to the framework import and dropped posts' now-unused @tanstack/react-virtual dependency - Ported the list-window tests and added coverage for the infinite-scroll fetch trigger and custom window sizing
3eb2ea1 to
c30357b
Compare
|
Bugbot is not enabled for this team, so this pull request was not reviewed. Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs. |
There was a problem hiding this comment.
🧹 Nitpick comments (1)
apps/admin-x-framework/test/unit/virtual-list/use-infinite-virtual-scroll.test.ts (1)
40-108: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winConsider adding a test for the
isFetchingNextPagetrue→false transition.The current tests cover the initial-render cases well, but none exercise the re-render path where
isFetchingNextPagetransitions fromtruetofalsewithhasNextPagestill true. This is the most common real-world trigger forfetchNextPageand would protect against regressions in the effect's dependency handling.💡 Suggested additional test
it('fetches the next page after a fetch completes and more pages remain', () => { const {scrollElement, parentRef} = createParentRef(); const fetchNextPage = vi.fn(); const {rerender} = renderHook(({isFetching}) => useInfiniteVirtualScroll({ items: [], totalItems: 20, parentRef, hasNextPage: true, isFetchingNextPage: isFetching, fetchNextPage, getScrollElement: () => scrollElement }), {initialProps: {isFetching: true}}); expect(fetchNextPage).not.toHaveBeenCalled(); rerender({isFetching: false}); expect(fetchNextPage).toHaveBeenCalledTimes(1); });🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/admin-x-framework/test/unit/virtual-list/use-infinite-virtual-scroll.test.ts` around lines 40 - 108, Add a test in useInfiniteVirtualScroll that covers the rerender path where isFetchingNextPage changes from true to false while hasNextPage remains true, since the current cases only cover initial render. Use renderHook with rerender and the existing createParentRef/useInfiniteVirtualScroll setup to verify fetchNextPage is not called during the in-flight state but is called once after the transition, guarding the effect dependency behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In
`@apps/admin-x-framework/test/unit/virtual-list/use-infinite-virtual-scroll.test.ts`:
- Around line 40-108: Add a test in useInfiniteVirtualScroll that covers the
rerender path where isFetchingNextPage changes from true to false while
hasNextPage remains true, since the current cases only cover initial render. Use
renderHook with rerender and the existing
createParentRef/useInfiniteVirtualScroll setup to verify fetchNextPage is not
called during the in-flight state but is called once after the transition,
guarding the effect dependency behavior.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 5aea84d6-0f9c-49da-9fbf-143e05dfbd15
📒 Files selected for processing (15)
apps/admin-x-framework/package.jsonapps/admin-x-framework/src/index.tsapps/admin-x-framework/src/virtual-list/index.tsapps/admin-x-framework/src/virtual-list/load-more-button.tsxapps/admin-x-framework/src/virtual-list/use-infinite-virtual-scroll.tsxapps/admin-x-framework/src/virtual-list/use-scroll-restoration.tsxapps/admin-x-framework/src/virtual-list/use-virtual-list-window.tsapps/admin-x-framework/test/unit/virtual-list/use-infinite-virtual-scroll.test.tsapps/admin-x-framework/test/unit/virtual-list/use-virtual-list-window.test.tsapps/posts/package.jsonapps/posts/src/components/virtual-table/load-more-button.tsxapps/posts/src/components/virtual-table/use-infinite-virtual-scroll.tsxapps/posts/src/views/Tags/components/tags-list.tsxapps/posts/src/views/comments/components/comments-list.tsxapps/posts/src/views/members/components/members-list.tsx
💤 Files with no reviewable changes (3)
- apps/posts/package.json
- apps/posts/src/components/virtual-table/use-infinite-virtual-scroll.tsx
- apps/posts/src/components/virtual-table/load-more-button.tsx
✅ Files skipped from review due to trivial changes (3)
- apps/admin-x-framework/src/index.ts
- apps/posts/src/views/members/components/members-list.tsx
- apps/admin-x-framework/src/virtual-list/index.ts
🚧 Files skipped from review as they are similar to previous changes (8)
- apps/admin-x-framework/package.json
- apps/posts/src/views/Tags/components/tags-list.tsx
- apps/admin-x-framework/test/unit/virtual-list/use-virtual-list-window.test.ts
- apps/posts/src/views/comments/components/comments-list.tsx
- apps/admin-x-framework/src/virtual-list/use-virtual-list-window.ts
- apps/admin-x-framework/src/virtual-list/use-infinite-virtual-scroll.tsx
- apps/admin-x-framework/src/virtual-list/load-more-button.tsx
- apps/admin-x-framework/src/virtual-list/use-scroll-restoration.tsx
no ref Extracts the posts app's shared filter engine into `@tryghost/admin-x-framework`, so the domains that depend on it can dissolve into `apps/admin` independently. This is the last shared-infrastructure prerequisite for the posts co-location — the direct analog of the virtual-list primitive extraction (#29187).

Moves the posts app's "virtual table" infinite-scroll behaviour into
@tryghost/admin-x-frameworkas a reusable primitive, and rewires the posts consumers to it.Why
The infinite-scroll + virtualization + URL-driven scroll-restoration logic lived in
apps/posts/src/components/virtual-table, but it isn't posts-specific and isn't presentational — it's coupled to react-query's infinite-query shape and to the router (useLocation/useSearchParams). That makes it framework behaviour, not ashadecomponent: shade is presentational and must not own data-fetching or routing coupling. Bothapps/postsandapps/adminalready depend onadmin-x-framework, so this is the home reachable by every admin surface that wants an infinite list — the members, comments, and tags list views today, and more as Admin consolidates.What changed
New module
apps/admin-x-framework/src/virtual-list/, exported via@tryghost/admin-x-framework/virtual-list(and the root barrel):useInfiniteVirtualScroll<T>({items, totalItems, parentRef, hasNextPage?, isFetchingNextPage?, fetchNextPage, estimateSize?, overscan?, getScrollElement?})useVirtualListWindow(totalItems, {resetKey?, windowSize?})useScrollRestoration({parentRef, enabled?, isLoading?, getScrollElement?})LoadMoreButton({isLoading?, onClick, label?, loadingLabel?, className?})InfiniteQueryResultLike).Posts-specific assumptions removed while generalizing:
windowSizeoption (default 1000).getScrollElement), defaulting to shade'sgetScrollParentrather than being hardwired.LoadMoreButtoncopy and wrapper class are props (defaults preserve the original strings).LoadMoreButtonlands in framework rather than shade: it's the paired affordance for the window'sloadMore, its copy is specific to the infinite-scroll pattern, and co-locating it means a consumer gets the whole primitive from one import. Framework already depends on shade, so wrapping shade'sButtonthere is clean.Behaviour
None changed. Defaults reproduce the old hardcoded values, and the three posts consumers (
Tags,comments,memberslist views) change by import line only — no logic touched.@tanstack/react-virtualmoved from posts to framework (pinned3.14.5, mirroring the old posts pin); it's ESM, no interop change.Tests
Ported the 10 list-window tests and added: custom
windowSize, window-exceeds-total, and fouruseInfiniteVirtualScrolltests (windowed render, fetch-on-trailing-placeholder, no-fetch-without-next-page, no-fetch-while-in-flight).Verification
pnpm --filter @tryghost/admin-x-framework build | lint | test— exit 0; 451 tests passpnpm --filter @tryghost/posts build | lint | test— exit 0; 478 tests passapps/posts/src/components/virtual-table/removed; novirtual-tablereferences remain.Follow-up
apps/posts/src/components/layout/main-layout.tsx(a 12-line div ≈ shadeContainer size='page') is left untouched here; a separate change swaps it forContainer(and removes the duplicate copy the analytics move left behind).