Skip to content

Moved the virtual-list infinite-scroll primitive into admin-x-framework - #29187

Merged
9larsons merged 1 commit into
mainfrom
slars/framework-infinite-virtual-scroll
Jul 9, 2026
Merged

Moved the virtual-list infinite-scroll primitive into admin-x-framework#29187
9larsons merged 1 commit into
mainfrom
slars/framework-infinite-virtual-scroll

Conversation

@9larsons

@9larsons 9larsons commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Moves the posts app's "virtual table" infinite-scroll behaviour into @tryghost/admin-x-framework as 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 a shade component: shade is presentational and must not own data-fetching or routing coupling. Both apps/posts and apps/admin already depend on admin-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?})
  • The react-query subset it consumes is a documented generic interface (InfiniteQueryResultLike).

Posts-specific assumptions removed while generalizing:

  • The hardcoded 1000-row window is now the windowSize option (default 1000).
  • The scroll-element resolver is injectable (getScrollElement), defaulting to shade's getScrollParent rather than being hardwired.
  • LoadMoreButton copy and wrapper class are props (defaults preserve the original strings).

LoadMoreButton lands in framework rather than shade: it's the paired affordance for the window's loadMore, 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's Button there is clean.

Behaviour

None changed. Defaults reproduce the old hardcoded values, and the three posts consumers (Tags, comments, members list views) change by import line only — no logic touched. @tanstack/react-virtual moved from posts to framework (pinned 3.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 four useInfiniteVirtualScroll tests (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 pass
  • pnpm --filter @tryghost/posts build | lint | test — exit 0; 478 tests pass
  • Empty apps/posts/src/components/virtual-table/ removed; no virtual-table references remain.

Follow-up

apps/posts/src/components/layout/main-layout.tsx (a 12-line div ≈ shade Container size='page') is left untouched here; a separate change swaps it for Container (and removes the duplicate copy the analytics move left behind).

@cursor

cursor Bot commented Jul 8, 2026

Copy link
Copy Markdown

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.

@coderabbitai

coderabbitai Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

This change adds a new shared virtual-list module in admin-x-framework, exposing useInfiniteVirtualScroll, useVirtualListWindow, useScrollRestoration, and LoadMoreButton through package and barrel exports. useScrollRestoration gains getScrollElement, and useVirtualListWindow gains windowSize plus exported option/result types. Unit tests are added for the new hooks and updated behavior. In apps/posts, the shared module is imported from @tryghost/admin-x-framework/virtual-list, and the local @tanstack/react-virtual devDependency is removed.

Possibly related PRs

  • TryGhost/Ghost#22050: Adds react-router and related routing exports used by the updated virtual-list hooks.
    Suggested reviewers: EvanHahn, kevinansfield
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly matches the main change: moving the virtual-list infinite-scroll primitive into admin-x-framework.
Description check ✅ Passed The description is directly related to the changeset and accurately summarizes the framework move and posts rewiring.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch slars/framework-infinite-virtual-scroll

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@nx-cloud

nx-cloud Bot commented Jul 8, 2026

Copy link
Copy Markdown

🤖 Nx Cloud AI Fix

Ensure the fix-ci command is configured to always run in your CI pipeline to get automatic fixes in future runs. For more information, please see https://nx.dev/ci/features/self-healing-ci


View your CI Pipeline Execution ↗ for commit c30357b

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

📥 Commits

Reviewing files that changed from the base of the PR and between 65fc69d and 3eb2ea1.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (15)
  • apps/admin-x-framework/package.json
  • apps/admin-x-framework/src/index.ts
  • apps/admin-x-framework/src/virtual-list/index.ts
  • apps/admin-x-framework/src/virtual-list/load-more-button.tsx
  • apps/admin-x-framework/src/virtual-list/use-infinite-virtual-scroll.tsx
  • apps/admin-x-framework/src/virtual-list/use-scroll-restoration.tsx
  • apps/admin-x-framework/src/virtual-list/use-virtual-list-window.ts
  • apps/admin-x-framework/test/unit/virtual-list/use-infinite-virtual-scroll.test.ts
  • apps/admin-x-framework/test/unit/virtual-list/use-virtual-list-window.test.ts
  • apps/posts/package.json
  • apps/posts/src/components/virtual-table/load-more-button.tsx
  • apps/posts/src/components/virtual-table/use-infinite-virtual-scroll.tsx
  • apps/posts/src/views/Tags/components/tags-list.tsx
  • apps/posts/src/views/comments/components/comments-list.tsx
  • apps/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

Comment on lines +106 to +115
// 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]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 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.

Suggested change
// 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

codecov Bot commented Jul 8, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 74.04%. Comparing base (c49d919) to head (c30357b).
⚠️ Report is 1 commits behind head on main.

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     
Flag Coverage Δ
admin-tests 55.19% <ø> (ø)
e2e-tests 76.15% <ø> (+<0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

- 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
@9larsons
9larsons force-pushed the slars/framework-infinite-virtual-scroll branch from 3eb2ea1 to c30357b Compare July 9, 2026 14:13
@cursor

cursor Bot commented Jul 9, 2026

Copy link
Copy Markdown

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 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 win

Consider adding a test for the isFetchingNextPage true→false transition.

The current tests cover the initial-render cases well, but none exercise the re-render path where isFetchingNextPage transitions from true to false with hasNextPage still true. This is the most common real-world trigger for fetchNextPage and 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

📥 Commits

Reviewing files that changed from the base of the PR and between 3eb2ea1 and c30357b.

📒 Files selected for processing (15)
  • apps/admin-x-framework/package.json
  • apps/admin-x-framework/src/index.ts
  • apps/admin-x-framework/src/virtual-list/index.ts
  • apps/admin-x-framework/src/virtual-list/load-more-button.tsx
  • apps/admin-x-framework/src/virtual-list/use-infinite-virtual-scroll.tsx
  • apps/admin-x-framework/src/virtual-list/use-scroll-restoration.tsx
  • apps/admin-x-framework/src/virtual-list/use-virtual-list-window.ts
  • apps/admin-x-framework/test/unit/virtual-list/use-infinite-virtual-scroll.test.ts
  • apps/admin-x-framework/test/unit/virtual-list/use-virtual-list-window.test.ts
  • apps/posts/package.json
  • apps/posts/src/components/virtual-table/load-more-button.tsx
  • apps/posts/src/components/virtual-table/use-infinite-virtual-scroll.tsx
  • apps/posts/src/views/Tags/components/tags-list.tsx
  • apps/posts/src/views/comments/components/comments-list.tsx
  • apps/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

@9larsons
9larsons merged commit 6e35beb into main Jul 9, 2026
51 checks passed
@9larsons
9larsons deleted the slars/framework-infinite-virtual-scroll branch July 9, 2026 14:35
9larsons added a commit that referenced this pull request Jul 9, 2026
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).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant