Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 26 additions & 2 deletions src/components/ScrollOffsetContextProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ type ScrollOffsetContextValue = {
/** Get scroll index value for given screen */
getScrollIndex: (route: PlatformStackRouteProp<ParamListBase>) => number | undefined;

/** Save the measured average item length for a given screen's list */
saveAverageItemLength: (route: PlatformStackRouteProp<ParamListBase>, length: number) => void;

/** Get the saved average item length for a given screen's list */
getAverageItemLength: (route: PlatformStackRouteProp<ParamListBase>) => number | undefined;

/** Clean scroll offsets of screen that aren't anymore in the state */
cleanStaleScrollOffsets: (state: State) => void;
};
Expand All @@ -36,6 +42,8 @@ const defaultValue: ScrollOffsetContextValue = {
getScrollOffset: () => undefined,
saveScrollIndex: () => {},
getScrollIndex: () => undefined,
saveAverageItemLength: () => {},
getAverageItemLength: () => undefined,
cleanStaleScrollOffsets: () => {},
};

Expand All @@ -62,6 +70,7 @@ function getKey(route: PlatformStackRouteProp<ParamListBase> | NavigationPartial
function ScrollOffsetContextProvider({children}: ScrollOffsetContextProviderProps) {
const [priorityMode] = useOnyx(ONYXKEYS.NVP_PRIORITY_MODE);
const scrollOffsetsRef = useRef<Record<string, number>>({});
const averageItemLengthsRef = useRef<Record<string, number>>({});
const previousPriorityMode = usePrevious(priorityMode);

useEffect(() => {
Expand All @@ -73,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]);
Expand All @@ -95,12 +105,13 @@ function ScrollOffsetContextProvider({children}: ScrollOffsetContextProviderProp
}

delete scrollOffsetsRef.current[key];
delete averageItemLengthsRef.current[key];
}
}, []);

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);
Expand Down Expand Up @@ -133,15 +144,28 @@ 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,
getScrollOffset,
cleanStaleScrollOffsets,
saveScrollIndex,
getScrollIndex,
saveAverageItemLength,
getAverageItemLength,
}),
[saveScrollOffset, getScrollOffset, cleanStaleScrollOffsets, saveScrollIndex, getScrollIndex],
[saveScrollOffset, getScrollOffset, cleanStaleScrollOffsets, saveScrollIndex, getScrollIndex, saveAverageItemLength, getAverageItemLength],
);

return <ScrollOffsetContext.Provider value={contextValue}>{children}</ScrollOffsetContext.Provider>;
Expand Down
150 changes: 109 additions & 41 deletions src/pages/workspace/WorkspacesListPage.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';
Expand All @@ -40,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';
Expand Down Expand Up @@ -68,6 +71,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';
Expand Down Expand Up @@ -132,6 +136,13 @@ function WorkspacesListPage() {
const icons = useMemoizedLazyExpensifyIcons(['Building', 'Exit', 'Copy', 'Star', 'Trashcan', 'Transfer', 'FallbackWorkspaceAvatar']);
const theme = useTheme();
const styles = useThemeStyles();

// 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 Partial<Record<'marginBottom', number>>)?.marginBottom ?? 0;
const rowPaddingVertical = (styles.p5 as Partial<Record<'padding', number>>)?.padding ?? 0;
const estimatedItemHeight = variables.avatarSizeNormal + rowPaddingVertical * 2 + rowMarginBottom;

const expensifyIcons = useMemoizedLazyExpensifyIcons(['Building', 'Exit', 'Copy', 'Star', 'Trashcan', 'Transfer', 'Plus', 'FallbackWorkspaceAvatar']);
const {translate, localeCompare} = useLocalize();
useDocumentTitle(translate('common.workspaces'));
Expand Down Expand Up @@ -187,13 +198,40 @@ function WorkspacesListPage() {

const policyToDelete = policies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyIDToDelete}`];

const {saveScrollOffset, getScrollOffset, saveAverageItemLength, getAverageItemLength} = useContext(ScrollOffsetContext);
const {windowHeight} = useWindowDimensions();
const onScroll = useCallback<NonNullable<ScrollViewProps['onScroll']>>(
(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<FlatList | null>(null);
const measuredItemHeight = useRef<number>(getAverageItemLength(route) ?? 0);

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<FlatList | null>(null);
const [lastAccessedWorkspacePolicyID] = useOnyx(ONYXKEYS.LAST_ACCESSED_WORKSPACE_POLICY_ID);

const hasCardFeedOrExpensifyCard =
Expand Down Expand Up @@ -436,48 +474,61 @@ function WorkspacesListPage() {
}

return (
<OfflineWithFeedback
<View
key={`${item.title}_${index}`}
pendingAction={item.pendingAction}
errorRowStyles={[styles.ph5, styles.mt3]}
onClose={item.dismissError}
errors={item.errors}
style={styles.mb2}
shouldShowErrorMessages={item.policyID !== policyIDToDelete}
shouldHideOnDelete={false}
onLayout={(e) => {
const height = e.nativeEvent.layout.height;
if (height <= 0) {
return;
}
if (height !== getAverageItemLength(route)) {
measuredItemHeight.current = height;
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.

}
}}
>
<PressableWithoutFeedback
accessible={false}
style={[styles.mh5]}
disabled={item.disabled}
onPress={item.action}
sentryLabel={CONST.SENTRY_LABEL.WORKSPACE.WORKSPACE_MENU_ITEM}
<OfflineWithFeedback
pendingAction={item.pendingAction}
errorRowStyles={[styles.ph5, styles.mt3]}
onClose={item.dismissError}
errors={item.errors}
style={styles.mb2}
shouldShowErrorMessages={item.policyID !== policyIDToDelete}
shouldHideOnDelete={false}
>
{({hovered}) => (
<WorkspacesListRow
title={item.title}
policyID={item.policyID}
menuItems={threeDotsMenuItems}
workspaceIcon={item.icon}
ownerAccountID={item.ownerAccountID}
workspaceType={item.type}
shouldAnimateInHighlight={shouldAnimateInHighlight}
isJoinRequestPending={item?.isJoinRequestPending}
rowStyles={hovered && styles.hoveredComponentBG}
layoutWidth={isLessThanMediumScreen ? CONST.LAYOUT_WIDTH.NARROW : CONST.LAYOUT_WIDTH.WIDE}
brickRoadIndicator={item.brickRoadIndicator}
shouldDisableThreeDotsMenu={item.disabled}
style={[item.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE ? styles.offlineFeedbackDeleted : {}]}
isDefault={isDefault}
isLoadingBill={isLoadingBill}
resetLoadingSpinnerIconIndex={resetLoadingSpinnerIconIndex}
isHovered={hovered}
disabled={item.disabled}
onPress={item.action}
/>
)}
</PressableWithoutFeedback>
</OfflineWithFeedback>
<PressableWithoutFeedback
accessible={false}
style={[styles.mh5]}
disabled={item.disabled}
onPress={item.action}
sentryLabel={CONST.SENTRY_LABEL.WORKSPACE.WORKSPACE_MENU_ITEM}
>
{({hovered}) => (
<WorkspacesListRow
title={item.title}
policyID={item.policyID}
menuItems={threeDotsMenuItems}
workspaceIcon={item.icon}
ownerAccountID={item.ownerAccountID}
workspaceType={item.type}
shouldAnimateInHighlight={shouldAnimateInHighlight}
isJoinRequestPending={item?.isJoinRequestPending}
rowStyles={hovered && styles.hoveredComponentBG}
layoutWidth={isLessThanMediumScreen ? CONST.LAYOUT_WIDTH.NARROW : CONST.LAYOUT_WIDTH.WIDE}
brickRoadIndicator={item.brickRoadIndicator}
shouldDisableThreeDotsMenu={item.disabled}
style={[item.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE ? styles.offlineFeedbackDeleted : {}]}
isDefault={isDefault}
isLoadingBill={isLoadingBill}
resetLoadingSpinnerIconIndex={resetLoadingSpinnerIconIndex}
isHovered={hovered}
disabled={item.disabled}
onPress={item.action}
/>
)}
</PressableWithoutFeedback>
</OfflineWithFeedback>
</View>
);
};

Expand Down Expand Up @@ -683,6 +734,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 (persisted across navigations via ScrollOffsetContext), otherwise falls back to an estimate.
const savedScrollOffset = getScrollOffset(route) ?? 0;
const computedInitialNumToRender = (() => {
if (savedScrollOffset <= 0 || data.length === 0) {
return undefined;
}
// eslint-disable-next-line react-hooks/refs -- Reading the local measured height during render is intentional; the value is an optimization hint for initialNumToRender.
const localMeasured = measuredItemHeight.current > 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);
Comment on lines +748 to +749

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?

})();

// eslint-disable-next-line react/no-unused-prop-types
const renderItem = ({item, index}: {item: WorkspaceOrDomainListItem; index: number}) => {
switch (item.listItemType) {
Expand Down Expand Up @@ -765,6 +831,8 @@ function WorkspacesListPage() {
ListHeaderComponent={listHeaderComponent}
keyboardShouldPersistTaps="handled"
contentContainerStyle={styles.pb20}
onScroll={onScroll}
initialNumToRender={computedInitialNumToRender}
/>
)}
</View>
Expand Down
Loading