From 3dd222a3e56525be1bc0b993e87dba1ed7707349 Mon Sep 17 00:00:00 2001 From: "Roji Philip (via MelvinBot)" Date: Tue, 24 Mar 2026 02:08:47 +0000 Subject: [PATCH 01/11] Restore workspace list scroll position after navigation 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 --- .../ScrollOffsetContextProvider.tsx | 2 +- src/pages/workspace/WorkspacesListPage.tsx | 44 ++++++++++++++++++- 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/src/components/ScrollOffsetContextProvider.tsx b/src/components/ScrollOffsetContextProvider.tsx index f051497606d48..74464bb5caaee 100644 --- a/src/components/ScrollOffsetContextProvider.tsx +++ b/src/components/ScrollOffsetContextProvider.tsx @@ -100,7 +100,7 @@ function ScrollOffsetContextProvider({children}: ScrollOffsetContextProviderProp const cleanStaleScrollOffsets: ScrollOffsetContextValue['cleanStaleScrollOffsets'] = useCallback( (state) => { - const sidebarRoutes = state.routes.filter((route) => isSidebarScreenName(route.name)); + const sidebarRoutes = state.routes.filter((route) => isSidebarScreenName(route.name) || route.name === SCREENS.WORKSPACES_LIST); const existingScreenKeys = new Set(sidebarRoutes.map(getKey)); const focusedRoute = findFocusedRoute(state); diff --git a/src/pages/workspace/WorkspacesListPage.tsx b/src/pages/workspace/WorkspacesListPage.tsx index e3ab960890321..fb41e50cb8b58 100755 --- a/src/pages/workspace/WorkspacesListPage.tsx +++ b/src/pages/workspace/WorkspacesListPage.tsx @@ -1,6 +1,6 @@ import {useIsFocused, useRoute} from '@react-navigation/native'; import {Str} from 'expensify-common'; -import React, {useEffect, useRef, useState} from 'react'; +import React, {useCallback, useContext, useEffect, useLayoutEffect, useRef, useState} from 'react'; import {FlatList, InteractionManager, View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; @@ -19,6 +19,8 @@ import {usePersonalDetails} from '@components/OnyxListItemProvider'; import type {PopoverMenuItem} from '@components/PopoverMenu'; import {PressableWithoutFeedback} from '@components/Pressable'; import ScreenWrapper from '@components/ScreenWrapper'; +import {ScrollOffsetContext} from '@components/ScrollOffsetContextProvider'; +import type {ScrollViewProps} from '@components/ScrollView'; import SearchBar from '@components/SearchBar'; import type {ListItem} from '@components/SelectionList/types'; import Text from '@components/Text'; @@ -83,6 +85,11 @@ import WorkspacesEmptyStateComponent from './WorkspacesEmptyStateComponent'; import WorkspacesListPageHeaderButton from './WorkspacesListPageHeaderButton'; import WorkspacesListRow from './WorkspacesListRow'; +// Estimated height (in px) of a single workspace row: avatar (40) + vertical padding (40) + bottom margin (8). +// Used to calculate initialNumToRender so that enough items are rendered to restore scroll position +// without rendering the entire list (which caused a performance regression with 5K+ workspaces). +const ESTIMATED_WORKSPACE_ROW_HEIGHT = 88; + type WorkspaceItem = {listItemType: 'workspace'} & ListItem & Required> & Pick & @@ -187,13 +194,38 @@ function WorkspacesListPage() { const policyToDelete = policies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyIDToDelete}`]; + const {saveScrollOffset, getScrollOffset} = useContext(ScrollOffsetContext); + const onScroll = useCallback>( + (e) => { + // If the layout measurement is 0, it means the list is not displayed but the onScroll may be triggered with offset value 0. + // We should ignore this case. + if (e.nativeEvent.layoutMeasurement.height === 0) { + return; + } + saveScrollOffset(route, e.nativeEvent.contentOffset.y); + }, + [route, saveScrollOffset], + ); + + const flatlistRef = useRef(null); + + useLayoutEffect(() => { + const scrollOffset = getScrollOffset(route); + if (!scrollOffset || !flatlistRef.current) { + return; + } + flatlistRef.current.scrollToOffset({ + offset: scrollOffset, + animated: false, + }); + }, [getScrollOffset, route]); + // We need this to update translation for deleting a workspace when it has third party card feeds or expensify card assigned. const workspaceAccountID = policyToDelete?.workspaceAccountID ?? CONST.DEFAULT_NUMBER_ID; const [cardFeeds, , defaultCardFeeds] = useCardFeeds(policyIDToDelete); const [cardsList] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${workspaceAccountID}_${CONST.EXPENSIFY_CARD.BANK}`, { selector: filterInactiveCards, }); - const flatlistRef = useRef(null); const [lastAccessedWorkspacePolicyID] = useOnyx(ONYXKEYS.LAST_ACCESSED_WORKSPACE_POLICY_ID); const hasCardFeedOrExpensifyCard = @@ -765,6 +797,14 @@ function WorkspacesListPage() { ListHeaderComponent={listHeaderComponent} keyboardShouldPersistTaps="handled" contentContainerStyle={styles.pb20} + onScroll={onScroll} + // Render enough items to cover the saved scroll offset so useLayoutEffect can restore + // the position before the first paint. We calculate the count from the offset and an + // estimated row height instead of using data.length (which caused a perf regression + // for accounts with thousands of workspaces). + initialNumToRender={ + getScrollOffset(route) ? Math.min(Math.ceil((getScrollOffset(route) ?? 0) / ESTIMATED_WORKSPACE_ROW_HEIGHT) + 10, data.length) : undefined + } /> )} From 56dc81bd3069bd3d70ae05656d55b8af174e33af Mon Sep 17 00:00:00 2001 From: "Roji Philip (via MelvinBot)" Date: Tue, 24 Mar 2026 02:12:29 +0000 Subject: [PATCH 02/11] Fix: run prettier on WorkspacesListPage.tsx Co-authored-by: Roji Philip --- src/pages/workspace/WorkspacesListPage.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/pages/workspace/WorkspacesListPage.tsx b/src/pages/workspace/WorkspacesListPage.tsx index fb41e50cb8b58..0edfec80367d0 100755 --- a/src/pages/workspace/WorkspacesListPage.tsx +++ b/src/pages/workspace/WorkspacesListPage.tsx @@ -802,9 +802,7 @@ function WorkspacesListPage() { // the position before the first paint. We calculate the count from the offset and an // estimated row height instead of using data.length (which caused a perf regression // for accounts with thousands of workspaces). - initialNumToRender={ - getScrollOffset(route) ? Math.min(Math.ceil((getScrollOffset(route) ?? 0) / ESTIMATED_WORKSPACE_ROW_HEIGHT) + 10, data.length) : undefined - } + initialNumToRender={getScrollOffset(route) ? Math.min(Math.ceil((getScrollOffset(route) ?? 0) / ESTIMATED_WORKSPACE_ROW_HEIGHT) + 10, data.length) : undefined} /> )} From c6d2e3410c0afe1058398f690502b112653ece5a Mon Sep 17 00:00:00 2001 From: "Roji Philip (via MelvinBot)" Date: Tue, 24 Mar 2026 02:24:13 +0000 Subject: [PATCH 03/11] Refine initialNumToRender to use measured workspace row height 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 --- src/pages/workspace/WorkspacesListPage.tsx | 121 ++++++++++++--------- 1 file changed, 72 insertions(+), 49 deletions(-) diff --git a/src/pages/workspace/WorkspacesListPage.tsx b/src/pages/workspace/WorkspacesListPage.tsx index 0edfec80367d0..b5c026a85254a 100755 --- a/src/pages/workspace/WorkspacesListPage.tsx +++ b/src/pages/workspace/WorkspacesListPage.tsx @@ -1,7 +1,7 @@ import {useIsFocused, useRoute} from '@react-navigation/native'; import {Str} from 'expensify-common'; import React, {useCallback, useContext, useEffect, useLayoutEffect, useRef, useState} from 'react'; -import {FlatList, InteractionManager, View} from 'react-native'; +import {Dimensions, FlatList, InteractionManager, View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import ActivityIndicator from '@components/ActivityIndicator'; @@ -85,10 +85,12 @@ import WorkspacesEmptyStateComponent from './WorkspacesEmptyStateComponent'; import WorkspacesListPageHeaderButton from './WorkspacesListPageHeaderButton'; import WorkspacesListRow from './WorkspacesListRow'; -// Estimated height (in px) of a single workspace row: avatar (40) + vertical padding (40) + bottom margin (8). -// Used to calculate initialNumToRender so that enough items are rendered to restore scroll position -// without rendering the entire list (which caused a performance regression with 5K+ workspaces). -const ESTIMATED_WORKSPACE_ROW_HEIGHT = 88; +// Fallback estimated heights (in px) for a workspace row in wide and narrow layouts. +// Wide: avatar (40) + vertical padding (40) + bottom margin (8) ≈ 88px +// Narrow: avatar (40) + vertical padding (40) + internal margin (12) + bottom margin (8) ≈ 100px +// Used to calculate initialNumToRender when no measured height is available yet. +const ESTIMATED_ITEM_HEIGHT_WIDE = 88; +const ESTIMATED_ITEM_HEIGHT_NARROW = 100; type WorkspaceItem = {listItemType: 'workspace'} & ListItem & Required> & @@ -208,6 +210,7 @@ function WorkspacesListPage() { ); const flatlistRef = useRef(null); + const measuredItemHeight = useRef(undefined); useLayoutEffect(() => { const scrollOffset = getScrollOffset(route); @@ -468,48 +471,57 @@ function WorkspacesListPage() { } return ( - { + if (measuredItemHeight.current) { + return; + } + measuredItemHeight.current = e.nativeEvent.layout.height; + }} > - - {({hovered}) => ( - - )} - - + + {({hovered}) => ( + + )} + + + ); }; @@ -715,6 +727,21 @@ function WorkspacesListPage() { shouldShowDomainsSection && !domains.length ? [{listItemType: 'domains-empty-state' as const}] : [], ].flat(); + // Compute initialNumToRender: render enough items to cover the saved scroll offset so + // useLayoutEffect can restore position before first paint. Uses measured row height when + // available (from a previous render), otherwise falls back to a layout-aware estimate. + const savedScrollOffset = getScrollOffset(route) ?? 0; + const computedInitialNumToRender = (() => { + if (savedScrollOffset <= 0) { + return undefined; + } + const fallbackHeight = shouldUseNarrowLayout ? ESTIMATED_ITEM_HEIGHT_NARROW : ESTIMATED_ITEM_HEIGHT_WIDE; + // 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); + return Math.min(Math.ceil(savedScrollOffset / itemHeight) + viewportItems, data.length); + })(); + // eslint-disable-next-line react/no-unused-prop-types const renderItem = ({item, index}: {item: WorkspaceOrDomainListItem; index: number}) => { switch (item.listItemType) { @@ -798,11 +825,7 @@ function WorkspacesListPage() { keyboardShouldPersistTaps="handled" contentContainerStyle={styles.pb20} onScroll={onScroll} - // Render enough items to cover the saved scroll offset so useLayoutEffect can restore - // the position before the first paint. We calculate the count from the offset and an - // estimated row height instead of using data.length (which caused a perf regression - // for accounts with thousands of workspaces). - initialNumToRender={getScrollOffset(route) ? Math.min(Math.ceil((getScrollOffset(route) ?? 0) / ESTIMATED_WORKSPACE_ROW_HEIGHT) + 10, data.length) : undefined} + initialNumToRender={computedInitialNumToRender} /> )} From ef2e7cb9eb1773f1afc84ccbdd9dc527cb09a53f Mon Sep 17 00:00:00 2001 From: "Roji Philip (via MelvinBot)" Date: Tue, 24 Mar 2026 02:41:42 +0000 Subject: [PATCH 04/11] Use style constants for estimated workspace row heights 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 --- src/pages/workspace/WorkspacesListPage.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/pages/workspace/WorkspacesListPage.tsx b/src/pages/workspace/WorkspacesListPage.tsx index b5c026a85254a..db32db019ea79 100755 --- a/src/pages/workspace/WorkspacesListPage.tsx +++ b/src/pages/workspace/WorkspacesListPage.tsx @@ -70,6 +70,7 @@ import shouldRenderTransferOwnerButton from '@libs/shouldRenderTransferOwnerButt import {isSubscriptionTypeOfInvoicing, shouldCalculateBillNewDot as shouldCalculateBillNewDotFn} from '@libs/SubscriptionUtils'; import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan'; import type {AvatarSource} from '@libs/UserAvatarUtils'; +import variables from '@styles/variables'; import {setNameValuePair} from '@userActions/User'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -86,11 +87,12 @@ import WorkspacesListPageHeaderButton from './WorkspacesListPageHeaderButton'; import WorkspacesListRow from './WorkspacesListRow'; // Fallback estimated heights (in px) for a workspace row in wide and narrow layouts. -// Wide: avatar (40) + vertical padding (40) + bottom margin (8) ≈ 88px -// Narrow: avatar (40) + vertical padding (40) + internal margin (12) + bottom margin (8) ≈ 100px +// Derived from style constants: avatar + vertical padding (styles.p5 top + bottom) + margins. // Used to calculate initialNumToRender when no measured height is available yet. -const ESTIMATED_ITEM_HEIGHT_WIDE = 88; -const ESTIMATED_ITEM_HEIGHT_NARROW = 100; +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; type WorkspaceItem = {listItemType: 'workspace'} & ListItem & Required> & From 8e25c7fd17d7d1577817b2af2ca947576c556a7c Mon Sep 17 00:00:00 2001 From: "Roji Philip (via MelvinBot)" Date: Tue, 24 Mar 2026 02:49:36 +0000 Subject: [PATCH 05/11] Use variables.spacing3 and variables.spacing5 instead of hardcoded padding/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 --- src/pages/workspace/WorkspacesListPage.tsx | 6 ++---- src/styles/variables.ts | 2 ++ 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/pages/workspace/WorkspacesListPage.tsx b/src/pages/workspace/WorkspacesListPage.tsx index db32db019ea79..7aa444b3cc158 100755 --- a/src/pages/workspace/WorkspacesListPage.tsx +++ b/src/pages/workspace/WorkspacesListPage.tsx @@ -89,10 +89,8 @@ import WorkspacesListRow from './WorkspacesListRow'; // Fallback estimated heights (in px) for a workspace row in wide and narrow layouts. // Derived from style constants: avatar + vertical padding (styles.p5 top + bottom) + margins. // Used to calculate initialNumToRender when no measured height is available yet. -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; +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; type WorkspaceItem = {listItemType: 'workspace'} & ListItem & Required> & diff --git a/src/styles/variables.ts b/src/styles/variables.ts index 0510b832e12ed..1e49bf284a49c 100644 --- a/src/styles/variables.ts +++ b/src/styles/variables.ts @@ -22,6 +22,8 @@ export default { inputComponentSizeNormal: 40, componentSizeLarge: 52, spacing2: 8, + spacing3: 12, + spacing5: 20, componentBorderRadius: 8, componentBorderRadiusSmall: 4, componentBorderRadiusMedium: 6, From 98dab6bc286622a9ac0f24bf43698f38acf53c32 Mon Sep 17 00:00:00 2001 From: "Roji Philip (via MelvinBot)" Date: Tue, 24 Mar 2026 03:14:19 +0000 Subject: [PATCH 06/11] Extract estimated heights from styles instead of generic variables 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 --- src/pages/workspace/WorkspacesListPage.tsx | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/pages/workspace/WorkspacesListPage.tsx b/src/pages/workspace/WorkspacesListPage.tsx index 7aa444b3cc158..b7390a772767e 100755 --- a/src/pages/workspace/WorkspacesListPage.tsx +++ b/src/pages/workspace/WorkspacesListPage.tsx @@ -86,11 +86,7 @@ import WorkspacesEmptyStateComponent from './WorkspacesEmptyStateComponent'; import WorkspacesListPageHeaderButton from './WorkspacesListPageHeaderButton'; import WorkspacesListRow from './WorkspacesListRow'; -// Fallback estimated heights (in px) for a workspace row in wide and narrow layouts. -// Derived from style constants: avatar + vertical padding (styles.p5 top + bottom) + margins. -// Used to calculate initialNumToRender when no measured height is available yet. -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; +type StyleNumericValue = Partial>; type WorkspaceItem = {listItemType: 'workspace'} & ListItem & Required> & @@ -141,6 +137,16 @@ function WorkspacesListPage() { const icons = useMemoizedLazyExpensifyIcons(['Building', 'Exit', 'Copy', 'Star', 'Trashcan', 'Transfer', 'FallbackWorkspaceAvatar']); const theme = useTheme(); const styles = useThemeStyles(); + + // Fallback estimated heights (in px) for a workspace row in wide and narrow layouts. + // Derived from actual style values: avatar + vertical padding (styles.p5 top + bottom) + margins. + // Used to calculate initialNumToRender when no measured height is available yet. + 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; + const expensifyIcons = useMemoizedLazyExpensifyIcons(['Building', 'Exit', 'Copy', 'Star', 'Trashcan', 'Transfer', 'Plus', 'FallbackWorkspaceAvatar']); const {translate, localeCompare} = useLocalize(); useDocumentTitle(translate('common.workspaces')); @@ -735,7 +741,7 @@ function WorkspacesListPage() { if (savedScrollOffset <= 0) { return undefined; } - const fallbackHeight = shouldUseNarrowLayout ? ESTIMATED_ITEM_HEIGHT_NARROW : ESTIMATED_ITEM_HEIGHT_WIDE; + 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); From 2cd45aa41af429b2a902770fd888e3f43bb948d3 Mon Sep 17 00:00:00 2001 From: "{\"message\":\"Not Found\",\"documentation_url\":\"https://docs.github.com/rest/issues/comments#get-an-issue-comment\",\"status\":\"404\"} (via MelvinBot)" Date: Tue, 24 Mar 2026 04:55:32 +0000 Subject: [PATCH 07/11] Address review comments on scroll position restoration - 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> --- .../ScrollOffsetContextProvider.tsx | 24 ++++++++++++- src/pages/workspace/WorkspacesListPage.tsx | 34 ++++++++----------- src/styles/variables.ts | 2 -- 3 files changed, 37 insertions(+), 23 deletions(-) diff --git a/src/components/ScrollOffsetContextProvider.tsx b/src/components/ScrollOffsetContextProvider.tsx index 74464bb5caaee..bf5155c3b0493 100644 --- a/src/components/ScrollOffsetContextProvider.tsx +++ b/src/components/ScrollOffsetContextProvider.tsx @@ -22,6 +22,12 @@ type ScrollOffsetContextValue = { /** Get scroll index value for given screen */ getScrollIndex: (route: PlatformStackRouteProp) => number | undefined; + /** Save the measured average item length for a given screen's list */ + saveAverageItemLength: (route: PlatformStackRouteProp, length: number) => void; + + /** Get the saved average item length for a given screen's list */ + getAverageItemLength: (route: PlatformStackRouteProp) => number | undefined; + /** Clean scroll offsets of screen that aren't anymore in the state */ cleanStaleScrollOffsets: (state: State) => void; }; @@ -36,6 +42,8 @@ const defaultValue: ScrollOffsetContextValue = { getScrollOffset: () => undefined, saveScrollIndex: () => {}, getScrollIndex: () => undefined, + saveAverageItemLength: () => {}, + getAverageItemLength: () => undefined, cleanStaleScrollOffsets: () => {}, }; @@ -62,6 +70,7 @@ function getKey(route: PlatformStackRouteProp | NavigationPartial function ScrollOffsetContextProvider({children}: ScrollOffsetContextProviderProps) { const [priorityMode] = useOnyx(ONYXKEYS.NVP_PRIORITY_MODE); const scrollOffsetsRef = useRef>({}); + const averageItemLengthsRef = useRef>({}); const previousPriorityMode = usePrevious(priorityMode); useEffect(() => { @@ -133,6 +142,17 @@ function ScrollOffsetContextProvider({children}: ScrollOffsetContextProviderProp return scrollOffsetsRef.current[getKey(route)]; }, []); + const saveAverageItemLength: ScrollOffsetContextValue['saveAverageItemLength'] = useCallback((route, length) => { + averageItemLengthsRef.current[getKey(route)] = length; + }, []); + + const getAverageItemLength: ScrollOffsetContextValue['getAverageItemLength'] = useCallback((route) => { + if (!averageItemLengthsRef.current) { + return; + } + return averageItemLengthsRef.current[getKey(route)]; + }, []); + const contextValue = useMemo( (): ScrollOffsetContextValue => ({ saveScrollOffset, @@ -140,8 +160,10 @@ function ScrollOffsetContextProvider({children}: ScrollOffsetContextProviderProp cleanStaleScrollOffsets, saveScrollIndex, getScrollIndex, + saveAverageItemLength, + getAverageItemLength, }), - [saveScrollOffset, getScrollOffset, cleanStaleScrollOffsets, saveScrollIndex, getScrollIndex], + [saveScrollOffset, getScrollOffset, cleanStaleScrollOffsets, saveScrollIndex, getScrollIndex, saveAverageItemLength, getAverageItemLength], ); return {children}; diff --git a/src/pages/workspace/WorkspacesListPage.tsx b/src/pages/workspace/WorkspacesListPage.tsx index b7390a772767e..4f1ffc6b5779b 100755 --- a/src/pages/workspace/WorkspacesListPage.tsx +++ b/src/pages/workspace/WorkspacesListPage.tsx @@ -1,7 +1,7 @@ import {useIsFocused, useRoute} from '@react-navigation/native'; import {Str} from 'expensify-common'; import React, {useCallback, useContext, useEffect, useLayoutEffect, useRef, useState} from 'react'; -import {Dimensions, FlatList, InteractionManager, View} from 'react-native'; +import {FlatList, InteractionManager, View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import ActivityIndicator from '@components/ActivityIndicator'; @@ -42,6 +42,7 @@ import useSearchResults from '@hooks/useSearchResults'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import useTransactionViolationOfWorkspace from '@hooks/useTransactionViolationOfWorkspace'; +import useWindowDimensions from '@hooks/useWindowDimensions'; import {isConnectionInProgress} from '@libs/actions/connections'; import {close} from '@libs/actions/Modal'; import {clearWorkspaceOwnerChangeFlow, isApprover as isApproverUserAction, requestWorkspaceOwnerChange} from '@libs/actions/Policy/Member'; @@ -86,8 +87,6 @@ import WorkspacesEmptyStateComponent from './WorkspacesEmptyStateComponent'; import WorkspacesListPageHeaderButton from './WorkspacesListPageHeaderButton'; import WorkspacesListRow from './WorkspacesListRow'; -type StyleNumericValue = Partial>; - type WorkspaceItem = {listItemType: 'workspace'} & ListItem & Required> & Pick & @@ -138,14 +137,11 @@ function WorkspacesListPage() { const theme = useTheme(); const styles = useThemeStyles(); - // Fallback estimated heights (in px) for a workspace row in wide and narrow layouts. - // Derived from actual style values: avatar + vertical padding (styles.p5 top + bottom) + margins. + // Fallback estimated height (in px) for a workspace row: avatar + vertical padding (styles.p5 top + bottom) + bottom margin (styles.mb2). // Used to calculate initialNumToRender when no measured height is available yet. - 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; + const rowMarginBottom = (styles.mb2 as Partial>)?.marginBottom ?? 0; + const rowPaddingVertical = (styles.p5 as Partial>)?.padding ?? 0; + const estimatedItemHeight = variables.avatarSizeNormal + rowPaddingVertical * 2 + rowMarginBottom; const expensifyIcons = useMemoizedLazyExpensifyIcons(['Building', 'Exit', 'Copy', 'Star', 'Trashcan', 'Transfer', 'Plus', 'FallbackWorkspaceAvatar']); const {translate, localeCompare} = useLocalize(); @@ -202,7 +198,8 @@ function WorkspacesListPage() { const policyToDelete = policies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyIDToDelete}`]; - const {saveScrollOffset, getScrollOffset} = useContext(ScrollOffsetContext); + const {saveScrollOffset, getScrollOffset, saveAverageItemLength, getAverageItemLength} = useContext(ScrollOffsetContext); + const {windowHeight} = useWindowDimensions(); const onScroll = useCallback>( (e) => { // If the layout measurement is 0, it means the list is not displayed but the onScroll may be triggered with offset value 0. @@ -216,7 +213,6 @@ function WorkspacesListPage() { ); const flatlistRef = useRef(null); - const measuredItemHeight = useRef(undefined); useLayoutEffect(() => { const scrollOffset = getScrollOffset(route); @@ -480,10 +476,10 @@ function WorkspacesListPage() { { - if (measuredItemHeight.current) { + if (getAverageItemLength(route)) { return; } - measuredItemHeight.current = e.nativeEvent.layout.height; + saveAverageItemLength(route, e.nativeEvent.layout.height); }} > { - if (savedScrollOffset <= 0) { + if (savedScrollOffset <= 0 || data.length === 0) { return undefined; } - 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); + const itemHeight = getAverageItemLength(route) ?? estimatedItemHeight; + const viewportItems = Math.ceil(windowHeight / itemHeight); return Math.min(Math.ceil(savedScrollOffset / itemHeight) + viewportItems, data.length); })(); diff --git a/src/styles/variables.ts b/src/styles/variables.ts index 1e49bf284a49c..0510b832e12ed 100644 --- a/src/styles/variables.ts +++ b/src/styles/variables.ts @@ -22,8 +22,6 @@ export default { inputComponentSizeNormal: 40, componentSizeLarge: 52, spacing2: 8, - spacing3: 12, - spacing5: 20, componentBorderRadius: 8, componentBorderRadiusSmall: 4, componentBorderRadiusMedium: 6, From 1fb355f8683443235b9430cc3f7fe9356ef12c2e Mon Sep 17 00:00:00 2001 From: "Roji Philip (via MelvinBot)" Date: Tue, 24 Mar 2026 05:42:59 +0000 Subject: [PATCH 08/11] Clean up averageItemLengthsRef entries alongside scrollOffsetsRef 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 --- src/components/ScrollOffsetContextProvider.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/components/ScrollOffsetContextProvider.tsx b/src/components/ScrollOffsetContextProvider.tsx index bf5155c3b0493..36af23156eac2 100644 --- a/src/components/ScrollOffsetContextProvider.tsx +++ b/src/components/ScrollOffsetContextProvider.tsx @@ -82,6 +82,7 @@ function ScrollOffsetContextProvider({children}: ScrollOffsetContextProviderProp for (const key of Object.keys(scrollOffsetsRef.current)) { if (key.includes(SCREENS.INBOX) || key.includes(SCREENS.SEARCH.ROOT)) { delete scrollOffsetsRef.current[key]; + delete averageItemLengthsRef.current[key]; } } }, [priorityMode, previousPriorityMode]); @@ -104,6 +105,7 @@ function ScrollOffsetContextProvider({children}: ScrollOffsetContextProviderProp } delete scrollOffsetsRef.current[key]; + delete averageItemLengthsRef.current[key]; } }, []); From 3839940781de35e2f6cbcc2e99f4e6eda507659e Mon Sep 17 00:00:00 2001 From: "Roji Philip (via MelvinBot)" Date: Tue, 24 Mar 2026 06:11:51 +0000 Subject: [PATCH 09/11] Add early return for zero layout height in onLayout callback Co-authored-by: Roji Philip --- src/pages/workspace/WorkspacesListPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/workspace/WorkspacesListPage.tsx b/src/pages/workspace/WorkspacesListPage.tsx index 4f1ffc6b5779b..e3bf1c53f626a 100755 --- a/src/pages/workspace/WorkspacesListPage.tsx +++ b/src/pages/workspace/WorkspacesListPage.tsx @@ -476,7 +476,7 @@ function WorkspacesListPage() { { - if (getAverageItemLength(route)) { + if (e.nativeEvent.layout.height === 0 || getAverageItemLength(route)) { return; } saveAverageItemLength(route, e.nativeEvent.layout.height); From 290b6863f3db1d0c270bf4f074b9cd63fd971279 Mon Sep 17 00:00:00 2001 From: "Roji Philip (via MelvinBot)" Date: Tue, 24 Mar 2026 06:39:42 +0000 Subject: [PATCH 10/11] Use safer onLayout guard and keep local ref for measured item 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 --- src/pages/workspace/WorkspacesListPage.tsx | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/pages/workspace/WorkspacesListPage.tsx b/src/pages/workspace/WorkspacesListPage.tsx index e3bf1c53f626a..93fee2a270cd1 100755 --- a/src/pages/workspace/WorkspacesListPage.tsx +++ b/src/pages/workspace/WorkspacesListPage.tsx @@ -213,6 +213,7 @@ function WorkspacesListPage() { ); const flatlistRef = useRef(null); + const measuredItemHeight = useRef(0); useLayoutEffect(() => { const scrollOffset = getScrollOffset(route); @@ -476,10 +477,14 @@ function WorkspacesListPage() { { - if (e.nativeEvent.layout.height === 0 || getAverageItemLength(route)) { + const height = e.nativeEvent.layout.height; + if (height <= 0) { return; } - saveAverageItemLength(route, e.nativeEvent.layout.height); + measuredItemHeight.current = height; + if (height !== getAverageItemLength(route)) { + saveAverageItemLength(route, height); + } }} > 0 ? measuredItemHeight.current : undefined; + const itemHeight = localMeasured ?? getAverageItemLength(route) ?? estimatedItemHeight; const viewportItems = Math.ceil(windowHeight / itemHeight); return Math.min(Math.ceil(savedScrollOffset / itemHeight) + viewportItems, data.length); })(); From cc18547cd8eb3253b1face980d35fef98c5d5f93 Mon Sep 17 00:00:00 2001 From: "{\"message\":\"Not Found\",\"documentation_url\":\"https://docs.github.com/rest/issues/comments#get-an-issue-comment\",\"status\":\"404\"} (via MelvinBot)" Date: Tue, 24 Mar 2026 11:51:31 +0000 Subject: [PATCH 11/11] Keep measuredItemHeight ref and averageItemLength in sync 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> --- src/pages/workspace/WorkspacesListPage.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/workspace/WorkspacesListPage.tsx b/src/pages/workspace/WorkspacesListPage.tsx index 93fee2a270cd1..9ff660cbb9fd9 100755 --- a/src/pages/workspace/WorkspacesListPage.tsx +++ b/src/pages/workspace/WorkspacesListPage.tsx @@ -213,7 +213,7 @@ function WorkspacesListPage() { ); const flatlistRef = useRef(null); - const measuredItemHeight = useRef(0); + const measuredItemHeight = useRef(getAverageItemLength(route) ?? 0); useLayoutEffect(() => { const scrollOffset = getScrollOffset(route); @@ -481,8 +481,8 @@ function WorkspacesListPage() { if (height <= 0) { return; } - measuredItemHeight.current = height; if (height !== getAverageItemLength(route)) { + measuredItemHeight.current = height; saveAverageItemLength(route, height); } }}