Skip to content

Fixed virtual list windowing for large admin lists#26952

Open
jonatansberg wants to merge 7 commits intomainfrom
ber-3470-fix-massive-member-counts
Open

Fixed virtual list windowing for large admin lists#26952
jonatansberg wants to merge 7 commits intomainfrom
ber-3470-fix-massive-member-counts

Conversation

@jonatansberg
Copy link
Copy Markdown
Member

@jonatansberg jonatansberg commented Mar 25, 2026

ref https://linear.app/ghost/issue/BER-3470/member-list-fails-to-render-in-chrome-for-massive-member-counts

This change adds a shared virtual list window for the large members, comments, and tags tables so they stop expanding their rendered range indefinitely. The lists now unlock additional rows in 1,000-item increments through an explicit Fetch more action, members use the same outer layout wrapper as comments and tags, and comments keep an explicit reset key so opening a thread sidebar does not collapse the unlocked window.

Screenshot 2026-03-25 at 16 01 18

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Mar 25, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: bbb2ecfd-6f89-4b12-ba7d-27f2b70e35c5

📥 Commits

Reviewing files that changed from the base of the PR and between f32b8be and 3280459.

📒 Files selected for processing (1)
  • apps/posts/src/components/virtual-table/use-scroll-restoration.tsx
✅ Files skipped from review due to trivial changes (1)
  • apps/posts/src/components/virtual-table/use-scroll-restoration.tsx

Walkthrough

Adds a virtualized list window module and useVirtualListWindow hook for incremental pagination and history-backed persistence. Integrates the hook into members, comments, and tags lists and adds conditional “Fetch more” controls. Increases member-list API limit from 50 to 100. Updates scroll restoration to use history state with improved retry/persist logic. Adds unit tests for the virtual window and E2E tests validating virtual-window behavior and scroll restoration.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 4.76% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Fixed virtual list windowing for large admin lists' directly and accurately summarizes the main change: implementing virtual list windowing to fix rendering issues for large datasets.
Description check ✅ Passed The description is clearly related to the changeset, explaining the addition of shared virtual list window functionality across members, comments, and tags tables with explicit 'Fetch more' actions.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ 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 ber-3470-fix-massive-member-counts

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 and usage tips.

@jonatansberg jonatansberg force-pushed the ber-3470-fix-massive-member-counts branch from bab11b1 to 67f84d9 Compare March 25, 2026 13:34
@jonatansberg jonatansberg marked this pull request as ready for review March 25, 2026 15:01
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
apps/posts/src/views/members/member-query-params.ts (1)

59-68: Prefer a shared constant for member page size across modules.

'100' is now hardcoded here and in apps/admin-x-framework/src/api/members.ts. A shared exported constant would reduce drift risk between list query builders and infinite-query defaults.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/posts/src/views/members/member-query-params.ts` around lines 59 - 68,
The hardcoded page size '100' in buildMemberListSearchParams should be replaced
with a shared exported constant to avoid drift; add a new exported constant
(e.g. MEMBERS_PAGE_SIZE) in a common/shared module and import it here, then
replace the literal '100' in buildMemberListSearchParams with
String(MEMBERS_PAGE_SIZE); also update the other usage in apps/admin-x-framework
(members list/infinite-query defaults) to import and use the same
MEMBERS_PAGE_SIZE constant so both query builders and infinite-query defaults
stay in sync.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/posts/src/components/virtual-table/virtual-list-window.ts`:
- Around line 21-55: Guard against non-positive windowSize by normalizing it to
a positive integer wherever it can be passed in: in getNextUnlockedItemCount and
in useVirtualListWindow. Ensure the local windowSize used for state and for
fetchMore is replaced with a validated value (e.g., Math.max(1, windowSize) or
defaulting to VIRTUAL_LIST_WINDOW_SIZE) so calls to
getNextUnlockedItemCount(unlockedItemCount, windowSize) and the initial
useState(windowSize) cannot receive 0 or negative values; update the
normalization in the useVirtualListWindow parameter handling and before invoking
setUnlockedItemCount in fetchMore.

---

Nitpick comments:
In `@apps/posts/src/views/members/member-query-params.ts`:
- Around line 59-68: The hardcoded page size '100' in
buildMemberListSearchParams should be replaced with a shared exported constant
to avoid drift; add a new exported constant (e.g. MEMBERS_PAGE_SIZE) in a
common/shared module and import it here, then replace the literal '100' in
buildMemberListSearchParams with String(MEMBERS_PAGE_SIZE); also update the
other usage in apps/admin-x-framework (members list/infinite-query defaults) to
import and use the same MEMBERS_PAGE_SIZE constant so both query builders and
infinite-query defaults stay in sync.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 36b206ef-3685-411c-910d-2d2de0196aed

📥 Commits

Reviewing files that changed from the base of the PR and between 0bc372c and 455e8c3.

📒 Files selected for processing (11)
  • apps/admin-x-framework/src/api/members.ts
  • apps/posts/src/components/virtual-table/virtual-list-window.test.ts
  • apps/posts/src/components/virtual-table/virtual-list-window.ts
  • apps/posts/src/views/Tags/components/tags-list.tsx
  • apps/posts/src/views/comments/comments.tsx
  • apps/posts/src/views/comments/components/comments-list.tsx
  • apps/posts/src/views/members/components/members-layout.tsx
  • apps/posts/src/views/members/components/members-list-item.tsx
  • apps/posts/src/views/members/components/members-list.tsx
  • apps/posts/src/views/members/member-query-params.test.ts
  • apps/posts/src/views/members/member-query-params.ts

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 455e8c3363

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

) {
const {search} = useLocation();
const effectiveResetKey = resetKey ?? search;
const [unlockedItemCount, setUnlockedItemCount] = useState(windowSize);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve unlocked row window after navigation

useVirtualListWindow always reinitializes unlockedItemCount to windowSize on mount, so any rows unlocked via “Fetch more” are lost when the list unmounts/remounts (for example, opening a member and then using Back). In that flow, useScrollRestoration can no longer restore positions past the first 1,000 rows because the restored scroll offset is clamped to the smaller window, forcing users to re-unlock data and losing their place in large lists.

Useful? React with 👍 / 👎.

@rob-ghost rob-ghost force-pushed the ber-3470-fix-massive-member-counts branch from 15c8870 to 455e8c3 Compare March 25, 2026 15:51
@rob-ghost rob-ghost added deploy-to-staging Optionally deploy PR to staging and removed deploy-to-staging Optionally deploy PR to staging labels Mar 25, 2026
@Ghost-Slimer Ghost-Slimer temporarily deployed to staging-pr-26952 March 25, 2026 22:27 Destroyed
@rob-ghost rob-ghost added deploy-to-staging Optionally deploy PR to staging and removed deploy-to-staging Optionally deploy PR to staging labels Mar 25, 2026
@Ghost-Slimer Ghost-Slimer temporarily deployed to staging-pr-26952 March 25, 2026 22:35 Destroyed
@jonatansberg jonatansberg requested a review from 9larsons as a code owner March 26, 2026 10:03
@sonarqubecloud
Copy link
Copy Markdown

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 198aa01a09

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

if (entryScopedScrollPositionKey) {
scrollPositions.set(entryScopedScrollPositionKey, position);
}
setStoredScrollPosition(getCurrentHistoryState(), key, position);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Persist scroll position against source history entry

In persistScrollPosition, the history state is read at write time (getCurrentHistoryState()), but this function is also called from the effect cleanup during navigation. At that point React Router has already moved to the destination entry, so the previous page's scroll position gets written into the new entry instead of the one being left. This means quick scroll-then-navigate flows (e.g. open a member right after scrolling) can restore to an older position when returning, because the source entry never receives the final flushed value.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/posts/src/components/virtual-table/use-scroll-restoration.tsx (1)

230-244: ⚠️ Potential issue | 🟠 Major

Track and clear all retry timeouts, not just the initial one.

The cleanup function only cancels the initial 150ms timeout. If attemptRestore schedules a retry on line 232 before cleanup runs, that retry persists after navigation or unmount. When it fires, it executes with stale savedPosition and location context, restoring incorrect scroll positions.

Store retry timeout IDs in a ref (e.g., Set<number>) and clear them all during cleanup to prevent post-navigation scroll writes.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/posts/src/components/virtual-table/use-scroll-restoration.tsx` around
lines 230 - 244, The cleanup only clears the initial timeoutId so retries
scheduled by attemptRestore can fire after unmount; modify
use-scroll-restoration to track all retry timers (e.g., keep a ref holding a
Set<number> of timeout IDs) and, whenever you call setTimeout in attemptRestore
or for the initial schedule, add the returned id to that Set, and on cleanup
iterate the Set calling clearTimeout for each id and clear the Set; ensure
symbols mentioned (attemptRestore, timeoutId, savedPosition, maxAttempts,
setTimeout, clearTimeout, scrollContainer) are updated accordingly so no retry
timeout can run with stale savedPosition or location context.
🧹 Nitpick comments (2)
e2e/helpers/pages/admin/members/members-page.ts (1)

109-110: Expose the new locators as public readonly.

These additions don't follow the repo's page-object locator convention. Making them explicit public readonly keeps the helper aligned with the rest of the shared page-object API.

As per coding guidelines, "Page Objects should be located in helpers/pages/ and expose locators as public readonly".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@e2e/helpers/pages/admin/members/members-page.ts` around lines 109 - 110, The
new locators fetchMoreButton and membersList are declared without the repo's
page-object visibility convention; update their declarations to be public
readonly so they match the rest of the page-object API (e.g., change the
declarations for fetchMoreButton and membersList in the MembersPage helper to
use "public readonly" visibility).
e2e/tests/admin/members/virtual-window.test.ts (1)

94-114: Use a semantic locator for the main region instead of document.querySelector('main').

The test reaches into the page with raw CSS selectors twice here. That makes the scroll assertions more brittle than they need to be and breaks the repo's E2E locator rule. A page.getByRole('main') locator can be reused for both the scrollTop read and the restoration poll.

♻️ One way to rewrite it
+        const mainRegion = page.getByRole('main');
         const targetRow = {
             index: Number(await targetRowLocator.getAttribute('data-index')),
-            scrollTop: await page.evaluate(() => document.querySelector('main')?.scrollTop || 0)
+            scrollTop: await mainRegion.evaluate(element => (element as HTMLElement).scrollTop)
         };
@@
-        await page.waitForFunction(({scrollTop}) => {
-            const main = document.querySelector('main');
-
-            if (!main) {
-                return false;
-            }
-
-            return Math.abs(main.scrollTop - scrollTop) < 500;
-        }, {scrollTop: targetRow.scrollTop});
+        await expect.poll(async () => {
+            const scrollTop = await mainRegion.evaluate(element => (element as HTMLElement).scrollTop);
+            return Math.abs(scrollTop - targetRow.scrollTop);
+        }).toBeLessThan(500);
As per coding guidelines, "Prefer semantic locators over test IDs, and never use CSS selectors, XPath, nth-child, or class names".
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@e2e/tests/admin/members/virtual-window.test.ts` around lines 94 - 114,
Replace raw document.querySelector('main') usage with a semantic Playwright
locator: create const mainLocator = page.getByRole('main') and use
mainLocator.evaluate(...) instead of page.evaluate(...) to read the initial
scrollTop (replace the page.evaluate call that produces targetRow.scrollTop),
then change the restoration poll so it compares against mainLocator.evaluate(el
=> el.scrollTop) (either by calling mainLocator.evaluate inside the wait loop or
by passing an evaluateHandle) instead of querying document.querySelector('main')
inside page.waitForFunction; keep existing symbols targetRow, targetRowLocator,
targetRow.scrollTop, page.waitForFunction and memberDetailsPage.nameInput when
locating where to replace the selectors.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/posts/src/components/virtual-table/use-scroll-restoration.tsx`:
- Around line 145-150: Restore currently reads history.state before the
in-memory Map, which can return a stale position after quick scroll-and-click;
update the restore logic (the effect that computes the restored position for
entryScopedScrollPositionKey) to check
scrollPositions.get(entryScopedScrollPositionKey) first and use that if present,
and only then fall back to reading getCurrentHistoryState() /
setStoredScrollPosition (or its stored value) — keep persistScrollPosition,
scrollPositions, entryScopedScrollPositionKey and getCurrentHistoryState usages
as-is but change the precedence so the in-memory Map wins within the same
navigation cycle.

---

Outside diff comments:
In `@apps/posts/src/components/virtual-table/use-scroll-restoration.tsx`:
- Around line 230-244: The cleanup only clears the initial timeoutId so retries
scheduled by attemptRestore can fire after unmount; modify
use-scroll-restoration to track all retry timers (e.g., keep a ref holding a
Set<number> of timeout IDs) and, whenever you call setTimeout in attemptRestore
or for the initial schedule, add the returned id to that Set, and on cleanup
iterate the Set calling clearTimeout for each id and clear the Set; ensure
symbols mentioned (attemptRestore, timeoutId, savedPosition, maxAttempts,
setTimeout, clearTimeout, scrollContainer) are updated accordingly so no retry
timeout can run with stale savedPosition or location context.

---

Nitpick comments:
In `@e2e/helpers/pages/admin/members/members-page.ts`:
- Around line 109-110: The new locators fetchMoreButton and membersList are
declared without the repo's page-object visibility convention; update their
declarations to be public readonly so they match the rest of the page-object API
(e.g., change the declarations for fetchMoreButton and membersList in the
MembersPage helper to use "public readonly" visibility).

In `@e2e/tests/admin/members/virtual-window.test.ts`:
- Around line 94-114: Replace raw document.querySelector('main') usage with a
semantic Playwright locator: create const mainLocator = page.getByRole('main')
and use mainLocator.evaluate(...) instead of page.evaluate(...) to read the
initial scrollTop (replace the page.evaluate call that produces
targetRow.scrollTop), then change the restoration poll so it compares against
mainLocator.evaluate(el => el.scrollTop) (either by calling mainLocator.evaluate
inside the wait loop or by passing an evaluateHandle) instead of querying
document.querySelector('main') inside page.waitForFunction; keep existing
symbols targetRow, targetRowLocator, targetRow.scrollTop, page.waitForFunction
and memberDetailsPage.nameInput when locating where to replace the selectors.
🪄 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: 07e70b0e-cab3-4dbb-9213-14aa7b1985b7

📥 Commits

Reviewing files that changed from the base of the PR and between 455e8c3 and 198aa01.

📒 Files selected for processing (5)
  • apps/posts/src/components/virtual-table/use-scroll-restoration.tsx
  • apps/posts/src/components/virtual-table/virtual-list-window.test.ts
  • apps/posts/src/components/virtual-table/virtual-list-window.ts
  • e2e/helpers/pages/admin/members/members-page.ts
  • e2e/tests/admin/members/virtual-window.test.ts
✅ Files skipped from review due to trivial changes (1)
  • apps/posts/src/components/virtual-table/virtual-list-window.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/posts/src/components/virtual-table/virtual-list-window.ts

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

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 the current code and only fix it if needed.

Inline comments:
In `@apps/posts/src/components/virtual-table/use-scroll-restoration.tsx`:
- Around line 164-180: flushScrollPosition currently writes the final position
only to the in-memory map when persistToHistory is false, leaving history.state
stale; update flushScrollPosition so that after setting scrollPositions (and
updating lastPersistedAtRef/lastPersistedPositionRef) it also calls
persistScrollPosition(position) to ensure the final pre-navigation scroll
position is persisted into history.state (persistScrollPosition already guards
on sourceHistoryEntryKey so this call is safe); apply the same change to the
other similar block at the indicated location (lines 212-214) referencing
latestScrollPositionRef, entryScopedScrollPositionKey, scrollPositions,
lastPersistedAtRef and lastPersistedPositionRef.
🪄 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: 7ef22f4a-bb23-44bc-8255-a37eaa278ea8

📥 Commits

Reviewing files that changed from the base of the PR and between 198aa01 and bafdc6b.

📒 Files selected for processing (5)
  • apps/posts/src/components/virtual-table/use-scroll-restoration.tsx
  • apps/posts/src/components/virtual-table/virtual-list-window.test.ts
  • apps/posts/src/components/virtual-table/virtual-list-window.ts
  • e2e/helpers/pages/admin/members/members-page.ts
  • e2e/tests/admin/members/virtual-window.test.ts
✅ Files skipped from review due to trivial changes (2)
  • apps/posts/src/components/virtual-table/virtual-list-window.ts
  • e2e/tests/admin/members/virtual-window.test.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • e2e/helpers/pages/admin/members/members-page.ts
  • apps/posts/src/components/virtual-table/virtual-list-window.test.ts

Comment on lines +164 to +180
const flushScrollPosition = ({persistToHistory = true}: {persistToHistory?: boolean} = {}) => {
clearPendingPersist();

if (!persistToHistory) {
const position = latestScrollPositionRef.current;

if (entryScopedScrollPositionKey) {
scrollPositions.set(entryScopedScrollPositionKey, position);
}

lastPersistedAtRef.current = Date.now();
lastPersistedPositionRef.current = position;
return;
}

persistScrollPosition(latestScrollPositionRef.current);
};
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.

⚠️ Potential issue | 🟠 Major

Persist the final pre-navigation scroll position into history.state.

Cleanup currently flushes only to the in-memory map. A small scroll followed by immediate SPA navigation can therefore leave the source history entry stale, so after a refresh on the destination page, Back restores an older scrollTop. persistScrollPosition() already guards on sourceHistoryEntryKey, so the normal flush is still safe here.

🔧 Minimal fix
         return () => {
-            flushScrollPosition({persistToHistory: false});
+            flushScrollPosition();
             scrollContainer.removeEventListener('scroll', handleScroll);
             window.removeEventListener('pagehide', flushScrollPosition);
         };

Also applies to: 212-214

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/posts/src/components/virtual-table/use-scroll-restoration.tsx` around
lines 164 - 180, flushScrollPosition currently writes the final position only to
the in-memory map when persistToHistory is false, leaving history.state stale;
update flushScrollPosition so that after setting scrollPositions (and updating
lastPersistedAtRef/lastPersistedPositionRef) it also calls
persistScrollPosition(position) to ensure the final pre-navigation scroll
position is persisted into history.state (persistScrollPosition already guards
on sourceHistoryEntryKey so this call is safe); apply the same change to the
other similar block at the indicated location (lines 212-214) referencing
latestScrollPositionRef, entryScopedScrollPositionKey, scrollPositions,
lastPersistedAtRef and lastPersistedPositionRef.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: bafdc6be46

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".


return (
<div ref={parentRef} className="-mx-8 -mb-8 h-[calc(100%+32px)] max-w-[calc(100vw-300px)] overflow-auto">
<div ref={parentRef} className="overflow-hidden">
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Re-enable horizontal scroll for wide member tables

The new wrapper now clips overflow (overflow-hidden), which removes the horizontal scrolling behavior the members table previously relied on. In views with active dynamic columns (activeColumns), the configured column widths can exceed the available viewport width on common screen sizes, so the rightmost columns become inaccessible instead of scrollable. This is a user-facing regression for filtered member views where those extra columns are the primary context.

Useful? React with 👍 / 👎.

ref https://linear.app/ghost/issue/BER-3470/member-list-fails-to-render-in-chrome-for-massive-member-counts

Large members, comments, and tags lists could misbehave as the virtualized window kept expanding with layout-specific reset behavior. This adds a bounded shared window with an explicit fetch-more path and keeps comments on an explicit reset key until its filters are refactored.
ref https://linear.app/ghost/issue/BER-3470/member-list-fails-to-render-in-chrome-for-massive-member-counts

The larger virtual list window works better when each members fetch returns more rows, which reduces paging churn and makes the manual fetch-more flow less noisy for large datasets.
ref https://linear.app/ghost/issue/BER-3470/member-list-fails-to-render-in-chrome-for-massive-member-counts

Members needed the same constrained layout shell as the other admin lists, and the table widths needed to be sized from the rendered cells so the load-more windowing did not break row layout or overflow behavior.
ref https://linear.app/ghost/issue/BER-3470/member-list-fails-to-render-in-chrome-for-massive-member-counts
Preserve unlocked windows and scroll position on back navigation without stale restores or chatty history writes
ref https://linear.app/ghost/issue/BER-3470/member-list-fails-to-render-in-chrome-for-massive-member-counts
Address follow-up review issues around history-entry scroll restoration and virtual window safety
ref https://linear.app/ghost/issue/BER-3470/member-list-fails-to-render-in-chrome-for-massive-member-counts
Reapplied the virtual windowing changes on top of the current upstream members table structure
@jonatansberg jonatansberg force-pushed the ber-3470-fix-massive-member-counts branch from bafdc6b to f32b8be Compare March 26, 2026 18:09
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

♻️ Duplicate comments (1)
apps/posts/src/components/virtual-table/use-scroll-restoration.tsx (1)

212-214: ⚠️ Potential issue | 🟠 Major

Persist the final unmount scroll position to history.state as well.

Cleanup currently uses persistToHistory: false, so quick scroll + SPA navigation can leave the source history entry stale for back-after-refresh restoration. Please persist on cleanup too.

Suggested fix
         return () => {
-            flushScrollPosition({persistToHistory: false});
+            flushScrollPosition();
             scrollContainer.removeEventListener('scroll', handleScroll);
             window.removeEventListener('pagehide', flushScrollPosition);
         };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/posts/src/components/virtual-table/use-scroll-restoration.tsx` around
lines 212 - 214, The cleanup currently calls
flushScrollPosition({persistToHistory: false}) which leaves history.state stale;
update the unmount/cleanup callback to call
flushScrollPosition({persistToHistory: true}) so the final scroll position is
persisted into history.state before removing the scroll listener (the code
around the return cleanup that also calls
scrollContainer.removeEventListener('scroll', handleScroll) should be updated
accordingly).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/posts/src/components/virtual-table/virtual-list-window.ts`:
- Around line 7-9: The normalization currently allows NaN to pass through;
update getSafeVirtualListWindowSize to first validate the input with
Number.isFinite(windowSize) (or check !Number.isNaN and typeof 'number') and if
invalid use the default VIRTUAL_LIST_WINDOW_SIZE before applying Math.floor and
Math.max(1, ...); likewise, in the restore/parse path where persisted
window/count is read (the code that parses persisted values into
visibleItemCount), ensure you parse integers (e.g., parseInt or Number) and then
validate with Number.isFinite and >0, falling back to the default when invalid
so NaN or non-finite values cannot poison visibleItemCount.

In `@e2e/helpers/pages/admin/members/members-page.ts`:
- Around line 194-200: The loop in scrollUntilMaxRenderedIndexAtLeast currently
continues when maxRenderedIndex === targetIndex, causing an extra iteration;
change the loop condition from "maxRenderedIndex <= targetIndex" to
"maxRenderedIndex < targetIndex" so it only iterates while below the target,
keeping the existing calls to scrollScrollParentBy(...) and
this.page.waitForFunction(...) intact and using the same maxRenderedIndex and
targetIndex variables.

---

Duplicate comments:
In `@apps/posts/src/components/virtual-table/use-scroll-restoration.tsx`:
- Around line 212-214: The cleanup currently calls
flushScrollPosition({persistToHistory: false}) which leaves history.state stale;
update the unmount/cleanup callback to call
flushScrollPosition({persistToHistory: true}) so the final scroll position is
persisted into history.state before removing the scroll listener (the code
around the return cleanup that also calls
scrollContainer.removeEventListener('scroll', handleScroll) should be updated
accordingly).
🪄 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: f2c49236-aeb1-4c40-96ab-ee0ed4204441

📥 Commits

Reviewing files that changed from the base of the PR and between bafdc6b and f32b8be.

📒 Files selected for processing (12)
  • apps/admin-x-framework/src/api/members.ts
  • apps/posts/src/components/virtual-table/use-scroll-restoration.tsx
  • apps/posts/src/components/virtual-table/virtual-list-window.test.ts
  • apps/posts/src/components/virtual-table/virtual-list-window.ts
  • apps/posts/src/views/Tags/components/tags-list.tsx
  • apps/posts/src/views/comments/comments.tsx
  • apps/posts/src/views/comments/components/comments-list.tsx
  • apps/posts/src/views/members/components/members-list.tsx
  • apps/posts/src/views/members/member-query-params.test.ts
  • apps/posts/src/views/members/member-query-params.ts
  • e2e/helpers/pages/admin/members/members-page.ts
  • e2e/tests/admin/members/virtual-window.test.ts
✅ Files skipped from review due to trivial changes (4)
  • apps/posts/src/views/comments/comments.tsx
  • apps/admin-x-framework/src/api/members.ts
  • apps/posts/src/views/members/member-query-params.test.ts
  • apps/posts/src/views/members/member-query-params.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • apps/posts/src/components/virtual-table/virtual-list-window.test.ts
  • e2e/tests/admin/members/virtual-window.test.ts

Comment on lines +7 to +9
export function getSafeVirtualListWindowSize(windowSize: number = VIRTUAL_LIST_WINDOW_SIZE) {
return Math.max(1, Math.floor(windowSize));
}
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.

⚠️ Potential issue | 🟡 Minor

Harden window-size/count normalization against NaN and invalid persisted values.

NaN passes typeof value === 'number' checks and currently flows through both normalization and restore paths, which can poison visibleItemCount.

Suggested fix
 export function getSafeVirtualListWindowSize(windowSize: number = VIRTUAL_LIST_WINDOW_SIZE) {
-    return Math.max(1, Math.floor(windowSize));
+    const normalized = Number.isFinite(windowSize) ? Math.floor(windowSize) : VIRTUAL_LIST_WINDOW_SIZE;
+    return Math.max(1, normalized);
 }
@@
-    if (typeof storedUnlockedItemCount !== 'number') {
+    if (typeof storedUnlockedItemCount !== 'number' || !Number.isFinite(storedUnlockedItemCount)) {
         return defaultUnlockedItemCount;
     }
 
-    return storedUnlockedItemCount;
+    return Math.max(1, Math.floor(storedUnlockedItemCount));
 }

Also applies to: 50-55

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/posts/src/components/virtual-table/virtual-list-window.ts` around lines
7 - 9, The normalization currently allows NaN to pass through; update
getSafeVirtualListWindowSize to first validate the input with
Number.isFinite(windowSize) (or check !Number.isNaN and typeof 'number') and if
invalid use the default VIRTUAL_LIST_WINDOW_SIZE before applying Math.floor and
Math.max(1, ...); likewise, in the restore/parse path where persisted
window/count is read (the code that parses persisted values into
visibleItemCount), ensure you parse integers (e.g., parseInt or Number) and then
validate with Number.isFinite and >0, falling back to the default when invalid
so NaN or non-finite values cannot poison visibleItemCount.

Comment on lines +194 to +200
for (let i = 0; i < 30 && maxRenderedIndex <= targetIndex; i += 1) {
await this.scrollScrollParentBy(4000);
await this.page.waitForFunction((previousMaxIndex) => {
const rows = Array.from(document.querySelectorAll('[data-testid="members-list-item"]'));

return rows.some(row => Number(row.getAttribute('data-index') || '-1') > previousMaxIndex);
}, maxRenderedIndex);
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.

⚠️ Potential issue | 🟡 Minor

Stop iterating once target index is reached.

scrollUntilMaxRenderedIndexAtLeast continues when maxRenderedIndex === targetIndex, which can cause an unnecessary extra wait and flaky timeout. The loop should continue only while below target.

Suggested fix
-        for (let i = 0; i < 30 && maxRenderedIndex <= targetIndex; i += 1) {
+        for (let i = 0; i < 30 && maxRenderedIndex < targetIndex; i += 1) {
📝 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
for (let i = 0; i < 30 && maxRenderedIndex <= targetIndex; i += 1) {
await this.scrollScrollParentBy(4000);
await this.page.waitForFunction((previousMaxIndex) => {
const rows = Array.from(document.querySelectorAll('[data-testid="members-list-item"]'));
return rows.some(row => Number(row.getAttribute('data-index') || '-1') > previousMaxIndex);
}, maxRenderedIndex);
for (let i = 0; i < 30 && maxRenderedIndex < targetIndex; i += 1) {
await this.scrollScrollParentBy(4000);
await this.page.waitForFunction((previousMaxIndex) => {
const rows = Array.from(document.querySelectorAll('[data-testid="members-list-item"]'));
return rows.some(row => Number(row.getAttribute('data-index') || '-1') > previousMaxIndex);
}, maxRenderedIndex);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@e2e/helpers/pages/admin/members/members-page.ts` around lines 194 - 200, The
loop in scrollUntilMaxRenderedIndexAtLeast currently continues when
maxRenderedIndex === targetIndex, causing an extra iteration; change the loop
condition from "maxRenderedIndex <= targetIndex" to "maxRenderedIndex <
targetIndex" so it only iterates while below the target, keeping the existing
calls to scrollScrollParentBy(...) and this.page.waitForFunction(...) intact and
using the same maxRenderedIndex and targetIndex variables.

ref https://linear.app/ghost/issue/BER-3470/member-list-fails-to-render-in-chrome-for-massive-member-counts
Wrapped the pagehide flush callback in an event-compatible handler so the posts build passes under CI TypeScript checks.
@sonarqubecloud
Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
3.8% Duplication on New Code (required ≤ 3%)

See analysis details on SonarQube Cloud

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3280459cdd

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +106 to +108
}, [historyKey, safeWindowSize]);

useEffect(() => {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Persist unlocked window across entry-key changes

useVirtualListWindow only writes ghostVirtualListWindow when historyKey or unlockedItemCount changes, but not when the browser history entry changes with the same reset key. In comments, resetKey is intentionally derived from nql (not location.search), so opening ?thread=... creates a new history entry without changing historyKey; this effect never runs for that entry, and a later remount from that entry falls back to the default 1,000-row window instead of the unlocked size. This is a navigation regression for users who open a thread, leave the page, and return via Back/Forward.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

deploy-to-staging Optionally deploy PR to staging

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants