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
48 changes: 46 additions & 2 deletions config/eslint/eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,18 @@ const localRulesDir = path.resolve(projectRoot, 'eslint-plugin-local-rules');
rulesdir.RULES_DIR = [expensifyRulesDir, localRulesDir];

const restrictedImportPaths = [
{
name: '@components/FlatList',
message: "Use 'LegendList' from '@legendapp/list/react-native' instead.",
},
{
name: '@components/KeyboardDismissibleFlatList',
message: "Use 'LegendList' from '@legendapp/list/react-native' instead.",
},
{
name: '@components/KeyboardDismissibleFlatList/index',
message: "Use 'LegendList' from '@legendapp/list/react-native' instead.",
},
{
name: '@components/Button',
importNames: ['default'],
Expand All @@ -65,6 +77,8 @@ const restrictedImportPaths = [
'Pressable',
'Text',
'ScrollView',
'FlatList',
'FlatListProps',
'ActivityIndicator',
'Animated',
'findNodeHandle',
Expand All @@ -77,15 +91,17 @@ const restrictedImportPaths = [
"For 'StatusBar', please use '@libs/StatusBar' instead.",
"For 'Text', please use '@components/Text' instead.",
"For 'ScrollView', please use '@components/ScrollView' instead.",
"For 'FlatList' and 'FlatListProps', please use 'LegendList' and 'LegendListProps' from '@legendapp/list/react-native' instead.",
"For 'ActivityIndicator', please use '@components/ActivityIndicator' instead.",
"For 'Animated', please use 'Animated' from 'react-native-reanimated' instead.",
"For 'InteractionManager', please use afterTransition callbacks on Navigation/KeyboardUtils or other alternatives. See contributingGuides/INTERACTION_MANAGER.md.",
].join('\n'),
},
{
name: 'react-native-gesture-handler',
importNames: ['TouchableOpacity', 'TouchableWithoutFeedback', 'TouchableNativeFeedback', 'TouchableHighlight'],
message: "Please use 'PressableWithFeedback' and/or 'PressableWithoutFeedback' from '@components/Pressable' instead.",
importNames: ['TouchableOpacity', 'TouchableWithoutFeedback', 'TouchableNativeFeedback', 'TouchableHighlight', 'FlatList', 'FlatListProps'],
message:
"Use 'LegendList' from '@legendapp/list/react-native' for lists, and 'PressableWithFeedback' and/or 'PressableWithoutFeedback' from '@components/Pressable' for pressable components.",
},
{
name: 'awesome-phonenumber',
Expand Down Expand Up @@ -170,6 +186,22 @@ const restrictedImportPaths = [
];

const restrictedImportPatterns = [
{
group: [
'@shopify/flash-list',
'@shopify/flash-list/**',
'react-native-draggable-flatlist',
'react-native-draggable-flatlist/**',
'react-native/Libraries/Lists/FlatList*',
'react-native-web/dist/exports/FlatList',
'react-native-web/dist/cjs/exports/FlatList',
],
message: "Use 'LegendList' from '@legendapp/list/react-native' for lists or '@components/DraggableList' for draggable lists.",
},
{
group: ['@components/FlatList/FlatList', '@components/FlatList/FlatList/**', '@components/FlashList', '@components/FlashList/**'],
message: "Legacy list wrappers have been removed. Use 'LegendList' from '@legendapp/list/react-native' instead.",
},
{
group: ['**/assets/animations/**/*.json'],
message: "Do not import animations directly. Please use the '@components/LottieAnimations' import instead.",
Expand Down Expand Up @@ -391,6 +423,10 @@ const config = defineConfig([
},

// These are the original rules from AirBnB's style guide, modified to allow for...of loops and for...in loops
{
selector: 'JSXMemberExpression[property.name=/^(FlatList|FlashList)$/]',
message: "Use 'AnimatedLegendList' from '@legendapp/list/reanimated' for animated lists or 'LegendList' from '@legendapp/list/react-native' for plain lists.",
},
{
selector: 'LabeledStatement',
message: 'Labels are a form of GOTO; using them makes code confusing and hard to maintain and understand.',
Expand All @@ -402,6 +438,14 @@ const config = defineConfig([
],
'no-restricted-properties': [
'error',
{
property: 'FlatList',
message: "Use 'AnimatedLegendList' from '@legendapp/list/reanimated' for animated lists or 'LegendList' from '@legendapp/list/react-native' for plain lists.",
},
{
property: 'FlashList',
message: "Use 'LegendList' from '@legendapp/list/react-native' instead.",
},
{
object: 'Image',
property: 'getSize',
Expand Down
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
Loading
Loading