Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
df48eb7
feat: migrate to LegendList
chrispader Aug 11, 2026
8de50eb
Merge branch 'main' into @chrispader/feat/legend-list
chrispader Aug 11, 2026
7a51077
deps: update legend list
chrispader Aug 14, 2026
7319180
feat: remove inverted list wrapper and update usages
chrispader Aug 14, 2026
85bfd02
chore: add Fullstory patch
chrispader Aug 14, 2026
77288fa
test: add unit tests for fullstory bug
chrispader Aug 14, 2026
76eee75
fix: keep list scrolled to end
chrispader Aug 14, 2026
9e5926b
fix: loading older report actions
chrispader Aug 17, 2026
e676505
refactor: decouple composer scroll events from list inversion
chrispader Aug 17, 2026
1cb89bf
fix: preserve chat behavior with LegendList
chrispader Aug 17, 2026
b348e3a
test: cover LegendList report behavior
chrispader Aug 17, 2026
37679f6
Merge branch 'main' into @chrispader/feat/legend-list
chrispader Aug 18, 2026
48323a9
fix: invalid imports after merge
chrispader Aug 18, 2026
0bdb3ce
perf: optimize LegendList recycling
chrispader Aug 18, 2026
442ff83
fix: reset stateful report actions on recycle
chrispader Aug 18, 2026
264a06e
deps: update LegendList
chrispader Aug 19, 2026
349ba35
fix: initial scroll
chrispader Aug 21, 2026
d91dc25
fix: remove `useCallback`
chrispader Aug 24, 2026
ac6b1c9
Merge branch 'main' into @chrispader/feat/legend-list
chrispader Aug 24, 2026
35fab42
fix: invalid initial scroll offset with LegendList
chrispader Aug 27, 2026
b5dbfab
chore: update LegendList
chrispader Aug 31, 2026
86b6e83
Merge branch 'main' into @chrispader/feat/legend-list
chrispader Aug 31, 2026
33d5ab0
fix: preserve LegendList anchors in Android MVCP
chrispader Aug 31, 2026
df4591f
fix: let LegendList own report action scroll anchoring
chrispader Aug 31, 2026
2738d37
Merge branch 'main' into @chrispader/feat/legend-list
chrispader Sep 3, 2026
5eafbcd
feat: show loading skeleton instead of blank page
chrispader Aug 25, 2026
90491eb
fix: show loading skeleton until fully rendered
chrispader Aug 25, 2026
e25e077
fix: let LegendList own report action scroll anchoring
chrispader Aug 31, 2026
b43c13a
fix: skeleton view alignment
chrispader Sep 3, 2026
92acfca
Merge branch 'main' into @chrispader/feat/legend-list
chrispader Sep 9, 2026
7e73a67
Merge branch 'codex/legend-list-initial-render-skeleton' into @chrisp…
chrispader Sep 9, 2026
d63a4bb
fix: lint
chrispader Sep 9, 2026
f560153
fix: TS errors
chrispader Sep 9, 2026
674ae3e
deps: update LegendList
chrispader Sep 9, 2026
bddc07c
revert: Fullstory ref wrapping tests
chrispader Sep 9, 2026
396fcf5
test: fix LegendList Jest regressions
chrispader Sep 9, 2026
db709a1
fix: add proper LegendList mock for reassure
chrispader Sep 9, 2026
a9cdd7f
test: prevent duplicate LegendList pagination requests
chrispader Sep 9, 2026
347f283
feat: add skeleton pages for chat pagination
chrispader Sep 9, 2026
6dcdbca
refactor: simplify chat pagination loading UI
chrispader Sep 9, 2026
a99e894
fix: stabilize chat pagination loader layout
chrispader Sep 9, 2026
f348e26
fix: restore chat pagination header estimate
chrispader Sep 9, 2026
1e37d2e
fix: improve pagination loader spacing and style usage
chrispader Sep 10, 2026
f9322f5
Merge branch 'main' into @chrispader/feat/legend-list
chrispader Sep 11, 2026
35651d6
fix: align pagination loading indicator sizing
chrispader Sep 11, 2026
391d1cf
fix: stabilize report actions loading behavior
chrispader Sep 11, 2026
9ac3a73
fix: allow report action menus to overflow list rows
chrispader Sep 14, 2026
02ac615
Merge branch 'main' into @chrispader/feat/legend-list
chrispader Sep 14, 2026
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
99 changes: 96 additions & 3 deletions jest/setup.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,26 @@
import type {RenderInfo} from '@components/FlatList/RenderTaskQueue';

import '@shopify/flash-list/jestSetup';
import type * as LegendListModule from '@legendapp/list/react-native';
import type * as FlashListModule from '@shopify/flash-list';
import type React from 'react';

Check failure on line 6 in jest/setup.ts

View workflow job for this annotation

GitHub Actions / typecheck

Duplicate identifier 'React'.
import type {ReactNode} from 'react';
import type React from 'react';

Check failure on line 8 in jest/setup.ts

View workflow job for this annotation

GitHub Actions / typecheck

Duplicate identifier 'React'.
import type * as RNAppLogs from 'react-native-app-logs';
import type {ReadDirItem} from 'react-native-fs';
import type * as RNKeyboardController from 'react-native-keyboard-controller';

import 'react-native-gesture-handler/jestSetup';
import type * as RNKeyboardController from 'react-native-keyboard-controller';
import type Animated from 'react-native-reanimated';

import {useMemo} from 'react';
import 'setimmediate';
import {useMemo} from 'react';
import mockStorage from 'react-native-onyx/dist/storage/__mocks__';
import {TextDecoder, TextEncoder} from 'util';
import '@src/polyfills/PromiseWithResolvers';
import '@src/polyfills/requestIdleCallback';

import {TextDecoder, TextEncoder} from 'util';

import mockFSLibrary from './setupMockFullstoryLib';
import setupMockImages from './setupMockImages';
import setupMockLegendList from './setupMockLegendList';
Expand All @@ -31,6 +35,95 @@
mockFSLibrary();
setupMockLegendList();

// LegendList relies on native layout measurements that Jest does not produce. FlashList's Jest setup supplies
// deterministic layouts while keeping performance tests virtualized in the same way as the previous list.
jest.mock('@legendapp/list/react-native', () => {
const ReactActual = jest.requireActual<typeof React>('react');
const FlashListActual = jest.requireActual<typeof FlashListModule>('@shopify/flash-list').FlashList;
const LegendListModuleActual = jest.requireActual<typeof LegendListModule>('@legendapp/list/react-native');

type MockLegendListProps = Omit<FlashListModule.FlashListProps<unknown>, 'data' | 'initialScrollIndex' | 'initialScrollIndexParams' | 'maintainVisibleContentPosition'> & {
alignItemsAtEnd?: boolean;
data?: readonly unknown[];
initialScrollAtEnd?: boolean;
initialScrollIndex?: number | {index: number; viewOffset?: number; viewPosition?: number};
maintainScrollAtEnd?: unknown;
maintainScrollAtEndThreshold?: number;
maintainVisibleContentPosition?: unknown;
recycleItems?: boolean;
};
type MockLegendListState = {
contentLength: number;
scroll: number;
scrollLength: number;
};
type MockLegendListRef = FlashListModule.FlashListRef<unknown> & {
getState: () => MockLegendListState | undefined;
};

return {
...LegendListModuleActual,
LegendList: ReactActual.forwardRef<MockLegendListRef, MockLegendListProps>(
(
{
alignItemsAtEnd,
data = [],
initialScrollAtEnd,
initialScrollIndex,
maintainScrollAtEnd,
maintainScrollAtEndThreshold,
maintainVisibleContentPosition,
onEndReached,
onEndReachedThreshold = 0,
onScroll,
recycleItems,
...props
},
ref,
) => {
const listStateRef = ReactActual.useRef<MockLegendListState | undefined>(undefined);
const initialScrollConfig = typeof initialScrollIndex === 'number' ? {index: initialScrollIndex} : initialScrollIndex;
const flashListInitialScrollIndex = initialScrollAtEnd && data.length > 0 ? data.length - 1 : initialScrollConfig?.index;
const flashListInitialScrollIndexParams = initialScrollAtEnd
? {viewPosition: 1}
: initialScrollConfig && {viewOffset: initialScrollConfig.viewOffset, viewPosition: initialScrollConfig.viewPosition};
const handleScroll: NonNullable<FlashListModule.FlashListProps<unknown>['onScroll']> = (event) => {
const {contentOffset, contentSize, layoutMeasurement} = event.nativeEvent;
listStateRef.current = {
contentLength: contentSize.height,
scroll: contentOffset.y,
scrollLength: layoutMeasurement.height,
};
onScroll?.(event);
const distanceFromEnd = contentSize.height - layoutMeasurement.height - contentOffset.y;
if (distanceFromEnd <= layoutMeasurement.height * (onEndReachedThreshold ?? 0)) {
onEndReached?.();
}
};
const setRef = (instance: FlashListModule.FlashListRef<unknown> | null) => {
const mockInstance = instance && Object.assign(instance, {getState: () => listStateRef.current});
if (typeof ref === 'function') {
ref(mockInstance);
return;
}
if (ref) {
Object.assign(ref, {current: mockInstance});
}
};

return ReactActual.createElement(FlashListActual<unknown>, {
...props,
data,
initialScrollIndex: flashListInitialScrollIndex,
initialScrollIndexParams: flashListInitialScrollIndexParams,
onScroll: handleScroll,
ref: setRef,
});
},
),
};
});

// Polyfill necessary for Onyx.init in jest/setupAfterEnv.ts
Object.assign(global, {TextDecoder, TextEncoder});

Expand Down
3 changes: 2 additions & 1 deletion patches/react-native/details.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,10 @@

### [react-native+0.86.0+002+fixMVCPAndroid.patch](react-native+0.86.0+002+fixMVCPAndroid.patch)

- Reason: Fixes content jumping issues with `MaintainVisibleContentPosition` on Android, particularly in bidirectional pagination scenarios. The patch makes two key improvements:
- Reason: Fixes content jumping issues with `MaintainVisibleContentPosition` on Android, particularly in bidirectional pagination scenarios. The patch:
1. Changes when the first visible view is calculated - now happens on scroll events instead of during Fabric's willMountItems lifecycle, which was causing incorrect updates
2. Improves first visible view selection logic to handle Fabric's z-index-based view reordering by finding the view with the smallest position that's still greater than the scroll position
3. Preserves a positioned, zero-sized first child as a scroll anchor. LegendList moves this anchor to compensate for item measurements. Selecting its surrounding container or rejecting its empty frame prevents native scroll compensation and makes the chat jump as estimated rows shrink.
- Upstream PR/issue: https://github.com/facebook/react-native/pull/46247
- E/App issue: 🛑
- PR Introducing Patch: https://github.com/Expensify/App/pull/46315 (introduced), https://github.com/Expensify/App/pull/45289 (refactored)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,16 @@ index 2bee605..ba26e7b 100644
for (i in config.minIndexForVisible until contentView.childCount) {
val child = contentView.getChildAt(i)

@@ -128,27 +135,49 @@ internal class MaintainVisibleScrollPositionHelper<ScrollViewT>(
@@ -128,27 +135,57 @@ internal class MaintainVisibleScrollPositionHelper<ScrollViewT>(
val position = if (horizontal) child.x + child.width else child.y + child.height

+ // Virtualized lists can use a zero-sized, positioned first child as their scroll anchor.
+ // Preserve it instead of choosing the container that holds all rendered items.
+ if (i == config.minIndexForVisible && child.width == 0 && child.height == 0 && position > currentScroll) {
+ firstVisibleView = child
+ break
+ }
+
// If the child is partially visible or this is the last child, select it as the anchor.
- if (position > currentScroll || i == contentView.childCount - 1) {
- firstVisibleViewRef = WeakReference(child)
Expand Down Expand Up @@ -70,7 +77,8 @@ index 2bee605..ba26e7b 100644
}
+ val frame = Rect()
+ firstVisibleView.getHitRect(frame)
+ if (frame.width() > 0 || frame.height() > 0) {
+ // Zero-sized anchors have a meaningful position even though they have no area.
+ if (frame.width() > 0 || frame.height() > 0 || frame.left != 0 || frame.top != 0) {
+ prevFirstVisibleFrame = frame
+ } else {
+ prevFirstVisibleFrame = null
Expand Down
1 change: 1 addition & 0 deletions src/CONST/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7015,6 +7015,7 @@ const CONST = {

MOBILE_PAGINATION_SIZE: 15,
WEB_PAGINATION_SIZE: 30,
PAGINATION_LOADING_INDICATOR_HEIGHT: 72,

/** Dimensions for illustration shown in Confirmation Modal */
CONFIRM_CONTENT_SVG_SIZE: {
Expand Down

This file was deleted.

28 changes: 0 additions & 28 deletions src/components/FlashList/InvertedFlashList/index.tsx

This file was deleted.

2 changes: 1 addition & 1 deletion src/components/FlashList/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {FlashList as ShopifyFlashList} from '@shopify/flash-list';
import React from 'react';

function FlashList<T>({onScroll: onScrollProp, inverted, ...restProps}: FlashListProps<T>) {
const emitComposerScrollEvents = useEmitComposerScrollEvents({enabled: true, inverted});
const emitComposerScrollEvents = useEmitComposerScrollEvents({enabled: !!inverted});

const handleScroll = (e: NativeSyntheticEvent<NativeScrollEvent>) => {
onScrollProp?.(e);
Expand Down
28 changes: 26 additions & 2 deletions src/components/FlashList/types.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,31 @@
import type {RefObject} from 'react';
import type {FlatList} from 'react-native';

type ScrollToIndexParams = {
animated?: boolean;
index: number;
viewOffset?: number;
viewPosition?: number;
};

type ScrollToOffsetParams = {
animated?: boolean;
offset: number;
};

type ScrollToEndParams = {
animated?: boolean;
};

/** Common imperative API used by the report scroll manager across FlatList, FlashList, and LegendList. */
type ActionListRef = {
scrollToIndex: (params: ScrollToIndexParams) => void;
scrollToOffset: (params: ScrollToOffsetParams) => void;
scrollToEnd: (params?: ScrollToEndParams) => void;
getNativeScrollRef?: () => unknown;
};

/** Ref to the underlying list instance attached via `ref={}`. */
type FlatListRefType = RefObject<FlatList<unknown> | null> | null;
type FlatListRefType = RefObject<ActionListRef | null> | null;

export default FlatListRefType;
export type {ActionListRef};
2 changes: 1 addition & 1 deletion src/components/FlatList/FlatList/index.ios.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ function CustomFlatList<T>({
[onMomentumScrollEnd],
);

const emitComposerScrollEvents = useEmitComposerScrollEvents({enabled: !enableAnimatedKeyboardDismissal, inverted: restProps.inverted});
const emitComposerScrollEvents = useEmitComposerScrollEvents({enabled: !enableAnimatedKeyboardDismissal && !!restProps.inverted});
const handleScroll = useCallback(
(e: NativeSyntheticEvent<NativeScrollEvent>) => {
onScrollProp?.(e);
Expand Down
2 changes: 1 addition & 1 deletion src/components/FlatList/FlatList/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,7 @@ function MVCPFlatList<T>({
};
}, []);

const emitComposerScrollEvents = useEmitComposerScrollEvents({enabled: true, inverted: restProps.inverted});
const emitComposerScrollEvents = useEmitComposerScrollEvents({enabled: !!restProps.inverted});
const handleScroll = useCallback(
(e: NativeSyntheticEvent<NativeScrollEvent>) => {
onScrollProp?.(e);
Expand Down
2 changes: 1 addition & 1 deletion src/components/KeyboardDismissibleFlatList/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {useKeyboardDismissibleFlatListActions} from './KeyboardDismissibleFlatLi
function KeyboardDismissibleFlatList<T>({onScroll: onScrollProp, inverted, ref, ...restProps}: AnimatedFlatListWithCellRendererProps<T>) {
const {onScroll: onScrollHandleKeyboard} = useKeyboardDismissibleFlatListActions();

const emitComposerScrollEvents = useEmitComposerScrollEvents({enabled: true, inverted});
const emitComposerScrollEvents = useEmitComposerScrollEvents({enabled: !!inverted});

const additionalOnScroll = useAnimatedScrollHandler({
onScroll: emitComposerScrollEvents,
Expand Down
22 changes: 22 additions & 0 deletions src/components/LegendList/allowLegendListItemOverflow.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import type {LegendListRef} from '@legendapp/list/react-native';

/**
* LegendList uses paint containment for web item containers, which clips descendants that render
* outside their row. Remove only paint containment so inline menus can overflow while layout and
* style containment keep the rest of the row isolated.
*/
function allowLegendListItemOverflow(list: LegendListRef | null, index: number): boolean {
const itemContainer: unknown = list?.getState().elementAtIndex(index);
if (!isRecord(itemContainer) || !isRecord(itemContainer.style) || typeof itemContainer.style.contain !== 'string') {
return false;
}

itemContainer.style.contain = 'layout style';
return true;
}

function isRecord(value: unknown): value is Record<PropertyKey, unknown> {
return typeof value === 'object' && value !== null;
}

export default allowLegendListItemOverflow;
Original file line number Diff line number Diff line change
Expand Up @@ -637,8 +637,12 @@ function MoneyRequestReportActionsList({onLayout}: MoneyRequestReportListProps)
hasNextActionMadeBySameActor(visibleReportActions, indexWithinReportActions, isOffline);
const shouldDisableContextMenuForConciergeDraft = isDraftPendingCompletion && draftReportActionID === reportAction.reportActionID;

// This value cannot be memoized, because it is based on the indexWithinReportActions which changes on every render.
// eslint-disable-next-line react/jsx-no-constructed-context-values
const reportActionIndexContextValue = {index: indexWithinReportActions, isNewest: indexWithinReportActions === visibleReportActions.length - 1};

return (
<ReportActionIndexContext.Provider value={indexWithinReportActions}>
<ReportActionIndexContext.Provider value={reportActionIndexContextValue}>
<ReportActionsListItemRenderer
reportAction={reportAction}
parentReportAction={parentReportAction}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import MoneyReportHeader from '@components/MoneyReportHeader';
import MoneyRequestHeader from '@components/MoneyRequestHeader';
import OfflineWithFeedback from '@components/OfflineWithFeedback';
import MoneyRequestReceiptView from '@components/ReportActionItem/MoneyRequestReceiptView';
import ReportActionsSkeletonView from '@components/ReportActionsSkeletonView';
import ReportActionsSkeletonCover, {ReportActionsAnimatedSkeletonCover} from '@components/ReportActionsSkeletonCover';
import ReportHeaderSkeletonView from '@components/ReportHeaderSkeletonView';

import {useIsAppLoadPending, useIsReportLoadPending} from '@hooks/useInFlightRequests';
Expand Down Expand Up @@ -105,7 +105,7 @@ function InitialLoadingSkeleton({styles, onLayout}: {styles: ThemeStyles; onLayo
<View style={[styles.appContentHeader, styles.borderBottom]}>
<ReportHeaderSkeletonView onBackButtonPress={() => {}} />
</View>
<ReportActionsSkeletonView />
<ReportActionsAnimatedSkeletonCover />
</View>
);
}
Expand Down Expand Up @@ -217,7 +217,7 @@ function MoneyRequestReportView({report, reportIDFromRoute, reportLoadingState,
}

if (shouldShowEmptyActionsSkeleton) {
return <ReportActionsSkeletonView shouldAnimate={false} />;
return <ReportActionsSkeletonCover />;
}

if (!report) {
Expand All @@ -228,7 +228,7 @@ function MoneyRequestReportView({report, reportIDFromRoute, reportLoadingState,
return (
<View style={styles.flex1}>
<ReportHeaderSkeletonView />
<ReportActionsSkeletonView />
<ReportActionsAnimatedSkeletonCover />
{shouldDisplayReportFooter ? <ReportFooter /> : null}
</View>
);
Expand Down
Loading
Loading