Skip to content
Draft
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
1 change: 1 addition & 0 deletions cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -765,6 +765,7 @@
"lastiPhoneLogin",
"lastname",
"lefthook",
"legendapp",
"libc",
"Libc",
"libc's",
Expand Down
2 changes: 1 addition & 1 deletion jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ module.exports = {
'^.+\\.svg?$': 'jest-transformer-svg',
},
transformIgnorePatterns: [
'<rootDir>/node_modules/(?!.*(react-native|expo|react-navigation|uuid|@shopify\/flash-list).*/)',
'<rootDir>/node_modules/(?!.*(react-native|expo|react-navigation|uuid).*/)',
// Prevent Babel from transforming worklets in this file so they are treated as normal functions, otherwise FormatSelectionUtilsTest won't run.
'<rootDir>/node_modules/@expensify/react-native-live-markdown/lib/commonjs/parseExpensiMark.js',
],
Expand Down
154 changes: 153 additions & 1 deletion jest/setup.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import type {RenderInfo} from '@components/FlatList/RenderTaskQueue';

import '@shopify/flash-list/jestSetup';
import type * as LegendListModule from '@legendapp/list/react-native';
import type {ReactNode} from 'react';
import type React from 'react';
import type {ScrollViewProps, View as ReactNativeView} from 'react-native';
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';
Expand All @@ -28,6 +30,156 @@ if (!('GITHUB_REPOSITORY' in process.env)) {
setupMockImages();
mockFSLibrary();

// LegendList relies on native layout measurements that Jest does not produce. Render every item in a
// ScrollView so tests can exercise list content and callbacks without depending on another virtualized list.
jest.mock('@legendapp/list/react-native', () => {
const ReactActual = jest.requireActual<typeof React>('react');
const {ScrollView, View} = jest.requireActual<{ScrollView: React.ComponentType<ScrollViewProps>; View: typeof ReactNativeView}>('react-native');
const LegendListActual = jest.requireActual<typeof LegendListModule>('@legendapp/list/react-native');

type MockLegendListProps = LegendListModule.LegendListProps<unknown>;

const MockLegendList = ReactActual.forwardRef<unknown, MockLegendListProps>(
(
{
children,
data,
extraData,
getItemType,
ItemSeparatorComponent,
keyExtractor,
ListEmptyComponent,
ListFooterComponent,
ListFooterComponentStyle,
ListHeaderComponent,
ListHeaderComponentStyle,
maintainVisibleContentPosition: _maintainVisibleContentPosition,
onEndReached,
onEndReachedThreshold = 0.5,
onLoad,
onScroll,
onStartReached,
onStartReachedThreshold = 0.5,
recycleItems: _recycleItems,
renderItem,
...scrollViewProps
},
ref,
) => {
const onLoadRef = ReactActual.useRef(onLoad);
const listMetricsRef = ReactActual.useRef<{contentLength: number; scroll: number; scrollLength: number} | undefined>(undefined);
onLoadRef.current = onLoad;

ReactActual.useEffect(() => {
onLoadRef.current?.({elapsedTimeInMs: 0});
}, []);

ReactActual.useImperativeHandle(
ref,
() => ({
clearCaches: jest.fn(),
flashScrollIndicators: jest.fn(),
getAnimatableRef: () => null,
getNativeScrollRef: () => null,
getScrollableNode: () => null,
getScrollResponder: () => null,
getState: () => ({
data: data ?? [],
elementAtIndex: () => undefined,
endBuffered: (data?.length ?? 0) - 1,
startBuffered: 0,
...listMetricsRef.current,
}),
reportContentInset: jest.fn(),
scrollIndexIntoView: jest.fn(() => Promise.resolve()),
scrollItemIntoView: jest.fn(() => Promise.resolve()),
scrollToEnd: jest.fn(() => Promise.resolve()),
scrollToIndex: jest.fn(() => Promise.resolve()),
scrollToItem: jest.fn(() => Promise.resolve()),
scrollToOffset: jest.fn(() => Promise.resolve()),
setItemSize: jest.fn(),
setScrollProcessingEnabled: jest.fn(),
setVisibleContentAnchorOffset: jest.fn(),
}),
[data],
);

const handleScroll: NonNullable<ScrollViewProps['onScroll']> = (event) => {
const {contentOffset, contentSize, layoutMeasurement} = event.nativeEvent;
const isHorizontal = scrollViewProps.horizontal === true;
const offset = isHorizontal ? contentOffset.x : contentOffset.y;
const contentLength = isHorizontal ? contentSize.width : contentSize.height;
const visibleLength = isHorizontal ? layoutMeasurement.width : layoutMeasurement.height;
const distanceFromEnd = contentLength - visibleLength - offset;
listMetricsRef.current = {
contentLength,
scroll: offset,
scrollLength: visibleLength,
};
onScroll?.(event);

if (distanceFromEnd <= visibleLength * (onEndReachedThreshold ?? 0.5)) {
onEndReached?.({distanceFromEnd});
}
if (offset <= visibleLength * (onStartReachedThreshold ?? 0.5)) {
onStartReached?.({distanceFromStart: offset});
}
};

const renderedItems = data?.flatMap((item, index) => {
if (!renderItem) {
return [];
}

const itemKey = keyExtractor?.(item, index) ?? String(index);
const safeExtraData: unknown = extraData;
const itemElement = ReactActual.createElement(View, {key: itemKey}, renderItem({data, extraData: safeExtraData, index, item, type: getItemType?.(item, index)}));
if (!ItemSeparatorComponent || index === data.length - 1) {
return [itemElement];
}

return [itemElement, ReactActual.createElement(ItemSeparatorComponent, {key: `${itemKey}-separator`, leadingItem: item})];
});
const header = ReactActual.isValidElement(ListHeaderComponent) ? ListHeaderComponent : ListHeaderComponent && ReactActual.createElement(ListHeaderComponent);
const footer = ReactActual.isValidElement(ListFooterComponent) ? ListFooterComponent : ListFooterComponent && ReactActual.createElement(ListFooterComponent);
const empty = ReactActual.isValidElement(ListEmptyComponent) ? ListEmptyComponent : ListEmptyComponent && ReactActual.createElement(ListEmptyComponent);
let content = children;
if (data) {
content = data.length > 0 ? renderedItems : empty;
}

return ReactActual.createElement(
ScrollView,
{...scrollViewProps, onScroll: handleScroll},
header && ReactActual.createElement(View, {style: ListHeaderComponentStyle}, header),
content,
footer && ReactActual.createElement(View, {style: ListFooterComponentStyle}, footer),
);
},
);
const LegendList = jest.fn((props: MockLegendListProps & {ref?: React.Ref<unknown>}) => ReactActual.createElement(MockLegendList, props));

const useRecyclingState = <T>(valueOrInitializer: T | (() => T)) => ReactActual.useState(valueOrInitializer);

return {
...LegendListActual,
LegendList,
useAdaptiveRender: () => 'normal',
useAdaptiveRenderChange: jest.fn(),
useIsLastItem: () => false,
useListScrollSize: () => ({height: 0, width: 0}),
useRecyclingEffect: jest.fn(),
useRecyclingState,
useSyncLayout: () => jest.fn(),
useViewability: jest.fn(),
useViewabilityAmount: jest.fn(),
};
});

jest.mock('@legendapp/list/reanimated', () => ({
AnimatedLegendList: jest.requireMock<typeof LegendListModule>('@legendapp/list/react-native').LegendList,
}));

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

Expand Down
29 changes: 0 additions & 29 deletions jest/setupAfterEnv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,35 +37,6 @@ if (Keyboard && typeof Keyboard.addListener === 'function') {
}) as typeof Keyboard.addListener;
}

// This mock must live in setupAfterEnv (not setupFiles) because @shopify/flash-list/jestSetup,
// imported in setup.ts, registers its own measureLayout mock. Placing ours here ensures it
// runs after FlashList's setup and takes precedence.
jest.mock(
'@shopify/flash-list/dist/recyclerview/utils/measureLayout',
() =>
({
...jest.requireActual('@shopify/flash-list/dist/recyclerview/utils/measureLayout'),
measureParentSize: jest.fn().mockImplementation(() => ({
x: 0,
y: 0,
width: 300,
height: 400,
})),
measureFirstChildLayout: jest.fn().mockImplementation(() => ({
x: 0,
y: 0,
width: 300,
height: 400,
})),
measureItemLayout: jest.fn().mockImplementation(() => ({
x: 0,
y: 0,
width: 300,
height: 75,
})),
}) as Record<string, unknown>,
);

// Auto-initialize Onyx for tests.
// Tests that already call Onyx.init() in their own beforeAll will safely re-configure Onyx —
// the second init() just re-runs initStoreValues and re-resolves the already-resolved deferred task.
Expand Down
33 changes: 21 additions & 12 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@
"@fullstory/react-native": "^1.10.2",
"@gorhom/portal": "^1.0.14",
"@invertase/react-native-apple-authentication": "^2.5.0",
"@legendapp/list": "^3.3.10",
"@lottiefiles/dotlottie-react": "0.13.5",
"@onfido/react-native-sdk": "15.1.0",
"@pusher/pusher-websocket-react-native": "^1.3.1",
Expand All @@ -131,7 +132,6 @@
"@sbaiahmed1/react-native-biometrics": "0.15.0",
"@sentry/core": "10.47.0",
"@sentry/react-native": "8.7.0",
"@shopify/flash-list": "2.3.0",
"@shopify/react-native-skia": "^2.4.18",
"@ua/react-native-airship": "26.5.0",
"array.prototype.tosorted": "^1.1.4",
Expand Down

This file was deleted.

This file was deleted.

Loading
Loading