Skip to content

Restore workspace list scroll position after navigation#86158

Open
MelvinBot wants to merge 11 commits intomainfrom
claude-workspaceScrollPositionRestore
Open

Restore workspace list scroll position after navigation#86158
MelvinBot wants to merge 11 commits intomainfrom
claude-workspaceScrollPositionRestore

Conversation

@MelvinBot
Copy link
Contributor

Explanation of Change

When navigating away from the Workspaces list and returning (e.g., tapping a workspace, then swiping back), the scroll position resets to the top. A previous fix (PR #77313) addressed this by integrating with ScrollOffsetContext but was reverted because it set initialNumToRender={data.length}, which rendered all items on mount and caused a multi-second JS thread block for accounts with 5K+ workspaces.

This PR re-applies the scroll position restoration approach using useLayoutEffect (for flicker-free restoration before paint) while fixing the performance issue. Instead of rendering all items, initialNumToRender is now dynamically calculated based on the saved scroll offset divided by an estimated row height (~88px), plus a small viewport buffer. For a user scrolled 3000px deep, this renders ~44 items — far less than the 5,000+ that caused the regression.

Changes:

  • WorkspacesListPage.tsx: Added ScrollOffsetContext integration with onScroll to save position, useLayoutEffect to restore it, and a smart initialNumToRender calculation
  • ScrollOffsetContextProvider.tsx: Include SCREENS.WORKSPACES_LIST in the route filter so saved scroll offsets survive navigation state cleanup

Fixed Issues

$ #76271

Tests

  1. Log in to the App
  2. Navigate to the Workspaces tab
  3. Scroll down in the workspace list (ensure there are enough workspaces/domains to scroll)
  4. Tap on any workspace to enter it
  5. Navigate back (swipe back or press back button)
  6. Verify the scroll position is restored to where you were before navigating away (should NOT jump to top)
  7. Repeat steps 3-6 with a domain item instead of a workspace
  • Verify that no errors appear in the JS console

Offline tests

  1. Log in and navigate to the Workspaces tab
  2. Scroll down and note position
  3. Go offline
  4. Tap on a workspace and navigate back
  5. Verify scroll position is preserved (the offset is saved in memory, not network-dependent)

QA Steps

  1. Log in to the App with an account that has multiple workspaces
  2. Navigate to the Workspaces tab
  3. Scroll down to the bottom (to the Domains section)
  4. Tap on any domain or workspace
  5. Swipe back / press back button
  6. Verify the app retains the previous scroll position after swiping back
  7. If possible, test with a high-traffic account (many workspaces) to verify no performance degradation
  • Verify that no errors appear in the JS console

PR Author Checklist

  • I linked the correct issue in the ### Fixed Issues section above
  • I wrote clear testing steps that cover the changes made in this PR
    • I added steps for local testing in the Tests section
    • I added steps for the expected offline behavior in the Offline steps section
    • I added steps for Staging and/or Production testing in the QA steps section
    • I added steps to cover failure scenarios (i.e. verify an input displays the correct error message if the entered data is not correct)
    • I turned off my network connection and tested it while offline to ensure it matches the expected behavior (i.e. verify the default avatar icon is displayed if app is offline)
    • I tested this PR with a High Traffic account against the staging or production API to ensure there are no regressions (e.g. long loading states that impact usability).
  • I included screenshots or videos for tests on all platforms
  • I ran the tests on all platforms & verified they passed on:
    • Android: Native
    • Android: mWeb Chrome
    • iOS: Native
    • iOS: mWeb Safari
    • MacOS: Chrome / Safari
  • I verified there are no console errors (if there's a console error not related to the PR, report it or open an issue for it to be fixed)
  • I followed proper code patterns (see Reviewing the code)
    • I verified that any callback methods that were added or modified are named for what the method does and never what callback they handle (i.e. toggleReport and not onIconClick)
    • I verified that comments were added to code that is not self explanatory
    • I verified that any new or modified comments were clear, correct English, and explained "why" the code was doing something instead of only explaining "what" the code was doing.
    • I verified any copy / text shown in the product is localized by adding it to src/languages/* files and using the translation method
      • If any non-english text was added/modified, I used JaimeGPT to get English > Spanish translation. I then posted it in #expensify-open-source and it was approved by an internal Expensify engineer. Link to Slack message:
    • I verified all numbers, amounts, dates and phone numbers shown in the product are using the localization methods
    • I verified any copy / text that was added to the app is grammatically correct in English. It adheres to proper capitalization guidelines (note: only the first word of header/labels should be capitalized), and is either coming verbatim from figma or has been approved by marketing (in order to get marketing approval, ask the Bug Zero team member to add the Waiting for copy label to the issue)
    • I verified proper file naming conventions were followed for any new files or renamed files. All non-platform specific files are named after what they export and are not named "index.js". All platform-specific files are named for the platform the code supports as outlined in the README.
    • I verified the JSDocs style guidelines (in STYLE.md) were followed
  • If a new code pattern is added I verified it was agreed to be used by multiple Expensify engineers
  • I followed the guidelines as stated in the Review Guidelines
  • I tested other components that can be impacted by my changes (i.e. if the PR modifies a shared library or component like Avatar, I verified the components using Avatar are working as expected)
  • I verified all code is DRY (the PR doesn't include any logic written more than once, with the exception of tests)
  • I verified any variables that can be defined as constants (ie. in CONST.ts or at the top of the file that uses the constant) are defined as such
  • I verified that if a function's arguments changed that all usages have also been updated correctly
  • If any new file was added I verified that:
    • The file has a description of what it does and/or why is needed at the top of the file if the code is not self explanatory
  • If a new CSS style is added I verified that:
    • A similar style doesn't already exist
    • The style can't be created with an existing StyleUtils function (i.e. StyleUtils.getBackgroundAndBorderStyle(theme.componentBG))
  • If new assets were added or existing ones were modified, I verified that:
    • The assets are optimized and compressed (for SVG files, run npm run compress-svg)
    • The assets load correctly across all supported platforms.
  • If the PR modifies code that runs when editing or sending messages, I tested and verified there is no unexpected behavior for all supported markdown - URLs, single line code, code blocks, quotes, headings, bold, strikethrough, and italic.
  • If the PR modifies a generic component, I tested and verified that those changes do not break usages of that component in the rest of the App (i.e. if a shared library or component like Avatar is modified, I verified that Avatar is working as expected in all cases)
  • If the PR modifies a component related to any of the existing Storybook stories, I tested and verified all stories for that component are still working as expected.
  • If the PR modifies a component or page that can be accessed by a direct deeplink, I verified that the code functions as expected when the deeplink is used - from a logged in and logged out account.
  • If the PR modifies the UI (e.g. new buttons, new UI components, changing the padding/spacing/sizing, moving components, etc) or modifies the form input styles:
    • I verified that all the inputs inside a form are aligned with each other.
    • I added Design label and/or tagged @Expensify/design so the design team can review the changes.
  • If a new page is added, I verified it's using the ScrollView component to make it scrollable when more elements are added to the page.
  • I added unit tests for any new feature or bug fix in this PR to help automatically prevent regressions in this user flow.
  • If the main branch was merged into this PR after a review, I tested again and verified the outcome was still expected according to the Test steps.

Screenshots/Videos

Web: Chrome

Tested via automated browser testing. Scroll position was set to 200px, navigated into a workspace, navigated back — scroll position was preserved at 200px (both via in-app back button and browser back navigation).

Android: Native
Android: mWeb Chrome
iOS: Native
iOS: mWeb Safari
MacOS: Chrome / Safari

Integrate WorkspacesListPage with ScrollOffsetContext to save and restore
scroll position when navigating to/from workspace or domain pages. Uses
useLayoutEffect to restore position before first paint, and dynamically
calculates initialNumToRender based on the saved offset and estimated
row height instead of data.length to avoid the performance regression
that caused the previous fix (PR #77313) to be reverted.

Co-authored-by: Roji Philip <rojiphil@users.noreply.github.com>
@MelvinBot MelvinBot requested a review from a team March 24, 2026 02:10
@MelvinBot
Copy link
Contributor Author

I created this PR from an upstream branch since I don't have push access to your fork.

To take ownership of this branch and be able to push updates, run:

git remote add upstream https://github.com/Expensify/App.git
git fetch upstream claude-workspaceScrollPositionRestore
git checkout -b claude-workspaceScrollPositionRestore upstream/claude-workspaceScrollPositionRestore
git push -u origin claude-workspaceScrollPositionRestore

Then you can close this PR and open a new one from your fork.

Co-authored-by: Roji Philip <rojiphil@users.noreply.github.com>
@MelvinBot
Copy link
Contributor Author

Fixed the failing Prettier check: ran prettier on WorkspacesListPage.tsx to format the initialNumToRender prop expression onto a single line.

@rojiphil
Copy link
Contributor

@MelvinBot Implement the refined proposal

@codecov
Copy link

codecov bot commented Mar 24, 2026

Codecov Report

❌ Looks like you've decreased code coverage for some files. Please write tests to increase, or at least maintain, the existing level of code coverage. See our documentation here for how to interpret this table.

Files with missing lines Coverage Δ
src/components/ScrollOffsetContextProvider.tsx 62.22% <71.42%> (+4.32%) ⬆️
src/pages/workspace/WorkspacesListPage.tsx 59.14% <55.55%> (-13.53%) ⬇️
... and 51 files with indirect coverage changes

Use onLayout to dynamically measure the actual workspace row height,
with layout-aware fallback estimates for narrow (100px) and wide (88px)
layouts. Compute initialNumToRender from the saved scroll offset and
measured/estimated item height plus a viewport buffer, avoiding the
performance regression from rendering all items.

Co-authored-by: Roji Philip <rojiphil@users.noreply.github.com>
@MelvinBot
Copy link
Contributor Author

Implemented the refined proposal. Here's what changed:

1. Dynamic row height measurement (primary source): Added a measuredItemHeight ref and an onLayout callback on a wrapper View around each workspace item in getWorkspaceMenuItem. The first item's height is captured and reused — this naturally adapts to narrow vs wide layouts without hardcoding.

2. Layout-aware fallback estimates: Replaced the single ESTIMATED_WORKSPACE_ROW_HEIGHT = 88 constant with two layout-specific fallbacks:

  • ESTIMATED_ITEM_HEIGHT_WIDE = 88px (avatar 40 + padding 40 + margin 8)
  • ESTIMATED_ITEM_HEIGHT_NARROW = 100px (avatar 40 + padding 40 + internal margin 12 + margin 8)

3. Smarter initialNumToRender calculation: Now computes the count using:

  • Measured height (when available from a previous render) as the primary source
  • Layout-aware estimated height as fallback
  • A viewport buffer (windowHeight / itemHeight) instead of the previous hardcoded +10

For a user scrolled to 3000px in narrow layout: ceil(3000/100) + ceil(900/100) = 39 items — far less than the 5K+ that caused the original revert.

@rojiphil
Copy link
Contributor

@MelvinBot For Layout-aware fallback estimates, let us use style constants(e.g. variables.avatarSizeNormal) to arrive at the estimate instead of hardcoding to a constant value (i.e. 88)

Replace hardcoded height values with style constants from variables.ts
(avatarSizeNormal, spacing2) and clearly-named local constants for the
spacing values that cannot be imported at module level (p5 padding,
mb3 margin).

Co-authored-by: Roji Philip <rojiphil@users.noreply.github.com>
@MelvinBot
Copy link
Contributor Author

Updated the fallback height estimates to use style constants instead of hardcoded values:

const WORKSPACE_ROW_PADDING = 20; // styles.p5 padding per side
const WORKSPACE_ROW_NARROW_MARGIN = 12; // styles.mb3 internal margin in narrow layout
const ESTIMATED_ITEM_HEIGHT_WIDE = variables.avatarSizeNormal + WORKSPACE_ROW_PADDING * 2 + variables.spacing2;
const ESTIMATED_ITEM_HEIGHT_NARROW = variables.avatarSizeNormal + WORKSPACE_ROW_PADDING * 2 + WORKSPACE_ROW_NARROW_MARGIN + variables.spacing2;
  • variables.avatarSizeNormal (40) — avatar size
  • variables.spacing2 (8) — styles.mb2 bottom margin on the OfflineWithFeedback wrapper
  • WORKSPACE_ROW_PADDING (20) and WORKSPACE_ROW_NARROW_MARGIN (12) — local constants for styles.p5 and styles.mb3 respectively, since @styles/utils/spacing cannot be imported directly (restricted by ESLint rule no-restricted-imports)

@rojiphil
Copy link
Contributor

const WORKSPACE_ROW_PADDING = 20; // styles.p5 padding per side
const WORKSPACE_ROW_NARROW_MARGIN = 12; // styles.mb3 internal margin in narrow layout

@MelvinBot Avoid hardcoding for the above as well

…dding/margin

Add spacing3 (12) and spacing5 (20) to style variables, following the
existing spacing2 (8) pattern, and use them in the estimated workspace
row height calculation to avoid hardcoded magic numbers.

Co-authored-by: Roji Philip <rojiphil@users.noreply.github.com>
@MelvinBot
Copy link
Contributor Author

Added spacing3: 12 and spacing5: 20 to variables.ts (following the existing spacing2: 8 pattern) and replaced the hardcoded constants:

const ESTIMATED_ITEM_HEIGHT_WIDE = variables.avatarSizeNormal + variables.spacing5 * 2 + variables.spacing2;
const ESTIMATED_ITEM_HEIGHT_NARROW = variables.avatarSizeNormal + variables.spacing5 * 2 + variables.spacing3 + variables.spacing2;

No more hardcoded values — everything derives from style variables now.

@rojiphil
Copy link
Contributor

  • variables.spacing2 (8) — styles.mb2 bottom margin on the OfflineWithFeedback wrapper

@MelvinBot Instead of using generic spacing2, let us use the pattern of extracting the values from styles itself so that we know what it represents. Something like this:

const rowSpacing = (styles.mb2 as {marginBottom?: number})?.marginBottom ?? 0;

Replace variables.spacing2/spacing3/spacing5 with values extracted
from the actual styles (styles.mb2, styles.p5, styles.mb3) so the
height computation clearly reflects which styles it derives from.

Co-authored-by: Roji Philip <rojiphil@users.noreply.github.com>
@MelvinBot
Copy link
Contributor Author

Updated — now extracting spacing values from styles instead of using generic variables.spacing*:

const rowMarginBottom = (styles.mb2 as StyleNumericValue<'marginBottom'>)?.marginBottom ?? 0;
const rowPaddingVertical = (styles.p5 as StyleNumericValue<'padding'>)?.padding ?? 0;
const narrowInternalMargin = (styles.mb3 as StyleNumericValue<'marginBottom'>)?.marginBottom ?? 0;
const estimatedItemHeightWide = variables.avatarSizeNormal + rowPaddingVertical * 2 + rowMarginBottom;
const estimatedItemHeightNarrow = variables.avatarSizeNormal + rowPaddingVertical * 2 + narrowInternalMargin + rowMarginBottom;

Each variable name now clearly represents which style it derives from (rowMarginBottom from styles.mb2, rowPaddingVertical from styles.p5, etc.). Kept variables.avatarSizeNormal since there's no direct style to extract it from — it's a design token used by the Avatar component.

Copy link
Contributor

@rojiphil rojiphil left a comment

Choose a reason for hiding this comment

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

@MelvinBot Please address these review comments.

const fallbackHeight = shouldUseNarrowLayout ? estimatedItemHeightNarrow : estimatedItemHeightWide;
// eslint-disable-next-line react-hooks/refs -- Reading the measured height ref during render is intentional; the value is only an optimization hint for initialNumToRender and stale reads are acceptable.
const itemHeight = measuredItemHeight.current ?? fallbackHeight;
const viewportItems = Math.ceil(Dimensions.get('window').height / itemHeight);
Copy link
Contributor

Choose a reason for hiding this comment

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

Let's use useWindowDimensions hook here.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@/tmp/reply-2978910740.md

// available (from a previous render), otherwise falls back to a layout-aware estimate.
const savedScrollOffset = getScrollOffset(route) ?? 0;
const computedInitialNumToRender = (() => {
if (savedScrollOffset <= 0) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Let's return undefined also when data.length is 0

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@/tmp/reply-2978912770.md

if (savedScrollOffset <= 0) {
return undefined;
}
const fallbackHeight = shouldUseNarrowLayout ? estimatedItemHeightNarrow : estimatedItemHeightWide;
Copy link
Contributor

Choose a reason for hiding this comment

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

Since this is fallback height, we can rely on a single height. Let us use wide height and remove the usage of narrow height.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@/tmp/reply-2978921217.md

inputComponentSizeNormal: 40,
componentSizeLarge: 52,
spacing2: 8,
spacing3: 12,
Copy link
Contributor

Choose a reason for hiding this comment

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

Remove spacing3 and spacing5 as they are redundant now.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@/tmp/reply-2978923760.md

if (measuredItemHeight.current) {
return;
}
measuredItemHeight.current = e.nativeEvent.layout.height;
Copy link
Contributor

Choose a reason for hiding this comment

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

When the user comes back again to the workspace view with a preserved scrollOffset value, the measuredItemHeight would be empty. So, let's preserve the measured item height in ScrollOffsetContextProvider with support of additional method saveAverageItemLength

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@/tmp/reply-2978963254.md

);

const flatlistRef = useRef<FlatList | null>(null);
const measuredItemHeight = useRef<number | undefined>(undefined);
Copy link
Contributor

Choose a reason for hiding this comment

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

And, if available, let's retrieve the measured item height in ScrollOffsetContextProvider with support of additional method getAverageItemLength. Otherwise, let it be undefined

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@/tmp/reply-2978967274.md

- Use useWindowDimensions hook instead of Dimensions.get('window')
- Return undefined when data.length is 0 in initialNumToRender
- Simplify to single fallback height (wide layout estimate only)
- Remove spacing3 and spacing5 from variables.ts (no longer needed)
- Persist measured item height in ScrollOffsetContextProvider via
  saveAverageItemLength/getAverageItemLength so it survives navigation
- Extract fallback height values from styles instead of hardcoding

Co-authored-by: {"message":"Not Found","documentation_url":"https://docs.github.com/rest/issues/comments#get-an-issue-comment","status":"404"} <{"message":"Not Found","documentation_url":"https://docs.github.com/rest/issues/comments#get-an-issue-comment","status":"404"}@users.noreply.github.com>
@rojiphil
Copy link
Contributor

@MelvinBot In ScrollOffsetContextProvider, we also need to delete averageItemLengthsRef.current in places where scrollOffsetsRef.current are deleted.

Delete averageItemLengthsRef.current entries in both the priority mode
change effect and the cleanScrollOffsets helper, matching the existing
cleanup of scrollOffsetsRef.current to prevent stale measured heights.

Co-authored-by: Roji Philip <rojiphil@users.noreply.github.com>
@MelvinBot
Copy link
Contributor Author

Added delete averageItemLengthsRef.current[key] in both places where scrollOffsetsRef.current entries are deleted:

  1. The useEffect that clears offsets on priority mode change (for INBOX/SEARCH screens)
  2. The cleanScrollOffsets helper used by cleanStaleScrollOffsets

This prevents stale measured heights from persisting after their associated scroll offsets are cleaned up.

shouldShowErrorMessages={item.policyID !== policyIDToDelete}
shouldHideOnDelete={false}
onLayout={(e) => {
if (getAverageItemLength(route)) {
Copy link
Contributor

Choose a reason for hiding this comment

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

@MelvinBot Let us return early if e.nativeEvent.layout.height is 0 as it does not make sense to set the average length.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done — the onLayout callback now returns early when height <= 0, before updating either the local ref or the context.

Co-authored-by: Roji Philip <rojiphil@users.noreply.github.com>
@MelvinBot
Copy link
Contributor Author

Added an early return when e.nativeEvent.layout.height is 0 in the onLayout callback, so we skip saving a meaningless zero value as the average item length.

shouldShowErrorMessages={item.policyID !== policyIDToDelete}
shouldHideOnDelete={false}
onLayout={(e) => {
if (e.nativeEvent.layout.height === 0 || getAverageItemLength(route)) {
Copy link
Contributor

Choose a reason for hiding this comment

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

@MelvinBot Two comments here:

  1. Let make the condition e.nativeEvent.layout.height <= 0 as that is safer
  2. Also, we would run into problems if the measured height of item reduces as compared to the saved one. In this case, the reduced height would not get saved and this can cause scrolling issues. Let's save the measured height locally as a ref and also use it wherever needed. Also, we will allow updation only if local and saved one are not same.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Both addressed:

  1. Changed guard to height <= 0 for safer handling
  2. Added a local measuredItemHeight ref that always gets updated with the latest measured height from onLayout, syncs to ScrollOffsetContext only when the value differs from the saved one, and is used as the primary source in computedInitialNumToRender (falls back to context value, then to estimated height)

- Change onLayout condition to `height <= 0` for safer guard
- Store measured height in a local ref and sync to ScrollOffsetContext
  only when values differ, ensuring height reductions are captured
- Use local ref as primary source in initialNumToRender calculation

Co-authored-by: Roji Philip <rojiphil@users.noreply.github.com>
Copy link
Contributor

@rojiphil rojiphil left a comment

Choose a reason for hiding this comment

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

@MelvinBot Few more comments to address.

}
measuredItemHeight.current = height;
if (height !== getAverageItemLength(route)) {
saveAverageItemLength(route, height);
Copy link
Contributor

Choose a reason for hiding this comment

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

Let us keep the measuredItemHeight and averageItemLength within ScrollOffsetContextProvider in sync by assigning height to measuredItemHeight.current here.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done — moved the ref assignment inside the conditional block so both the local ref and context value are updated together, keeping them in sync.

);

const flatlistRef = useRef<FlatList | null>(null);
const measuredItemHeight = useRef<number>(0);
Copy link
Contributor

Choose a reason for hiding this comment

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

Let us initialize measuredItemHeight with getAverageItemLength() value and keep them in sync.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done — initialized measuredItemHeight ref with getAverageItemLength(route) ?? 0 so it has the saved value immediately on mount.

Initialize measuredItemHeight ref with the saved getAverageItemLength
value so it's immediately available on component mount. Move the ref
assignment inside the sync conditional so both the local ref and context
value are always updated together.

Co-authored-by: {"message":"Not Found","documentation_url":"https://docs.github.com/rest/issues/comments#get-an-issue-comment","status":"404"} <{"message":"Not Found","documentation_url":"https://docs.github.com/rest/issues/comments#get-an-issue-comment","status":"404"}@users.noreply.github.com>
@rojiphil
Copy link
Contributor

Awesome. Looks good now.

86158-web-chrome-002.mp4

@rojiphil
Copy link
Contributor

@MelvinBot Please mark the PR as "Ready for review".

@MelvinBot MelvinBot marked this pull request as ready for review March 25, 2026 11:22
@MelvinBot MelvinBot requested a review from a team as a code owner March 25, 2026 11:22
@MelvinBot
Copy link
Contributor Author

Done — PR has been marked as Ready for review.

@melvin-bot melvin-bot bot requested review from rojiphil and trjExpensify and removed request for a team March 25, 2026 11:22
@melvin-bot
Copy link

melvin-bot bot commented Mar 25, 2026

@rojiphil Please copy/paste the Reviewer Checklist from here into a new comment on this PR and complete it. If you have the K2 extension, you can simply click: [this button]

@melvin-bot melvin-bot bot removed the request for review from a team March 25, 2026 11:22
Copy link

@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: cc18547cd8

ℹ️ About Codex in GitHub

Codex has been enabled to automatically 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 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +748 to +749
const viewportItems = Math.ceil(windowHeight / itemHeight);
return Math.min(Math.ceil(savedScrollOffset / itemHeight) + viewportItems, data.length);

Choose a reason for hiding this comment

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

P1 Badge Cap initial render count for deep saved offsets

The initialNumToRender calculation still grows with savedScrollOffset and reaches data.length for users returning from very deep positions (for example, the domains section on high-traffic accounts with thousands of workspaces). In that case this mounts the entire list again, which reintroduces the same JS-thread stall this change is trying to avoid. Because the formula is ceil(offset / itemHeight) + viewportItems and then clamped with Math.min(..., data.length), sufficiently large offsets deterministically force a full render.

Useful? React with 👍 / 👎.

Copy link
Contributor

Choose a reason for hiding this comment

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

Agree. Let's fix this.
@MelvinBot Would using getItemLayout help here?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, getItemLayout would be the right solution here. Here's why:

The problem: The current initialNumToRender = ceil(savedOffset / itemHeight) + viewportItems grows linearly with scroll depth. For a user scrolled to the domains section at the bottom of a 5K-workspace list, this approaches data.length and reintroduces the same JS-thread stall.

How getItemLayout + initialScrollIndex solves it: Instead of rendering all items from index 0 to N, FlatList uses getItemLayout to compute the exact offset of any item without rendering it, then renders only a small window (~10 items) around the initialScrollIndex. Performance is O(1) regardless of scroll depth — rendering 10 items whether the user scrolled to position 100 or position 5000.

The tradeoff: getItemLayout assumes predictable item heights. This list has mixed types:

  • Workspace rows: uniform height (the measured/estimated height we already track)
  • Domains header: different height
  • Domain rows: different height
  • Empty state components: different height

However, workspace rows dominate the list (the vast majority of items for high-traffic accounts), and the non-uniform items are at boundaries. Small inaccuracies from height mismatches are acceptable for scroll restoration — the user just needs to land near where they were, not pixel-perfect.

Implementation sketch:

const savedScrollIndex = savedScrollOffset > 0
    ? Math.round(savedScrollOffset / itemHeight)
    : undefined;

// On the FlatList:
getItemLayout={(_, index) => ({
    length: itemHeight,
    offset: itemHeight * index,
    index,
})}
initialScrollIndex={savedScrollIndex}

This eliminates initialNumToRender entirely for scroll restoration and caps rendering at a constant number of items regardless of scroll depth. We'd keep the onScroll save and ScrollOffsetContext integration as-is.

One caveat: ListHeaderComponent adds content above the list items that getItemLayout doesn't account for by default. We'd need to either include the header height in the offset calculation, or convert the header into the first item in the data array so getItemLayout covers it.

Want me to implement this approach?

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.

2 participants