diff --git a/cspell.json b/cspell.json index 2acc4c0385af..786c2758735a 100644 --- a/cspell.json +++ b/cspell.json @@ -763,6 +763,7 @@ "lastiPhoneLogin", "lastname", "lefthook", + "legendapp", "libc", "Libc", "libc's", diff --git a/jest/setup.ts b/jest/setup.ts index 3a6464839967..09de71c6a8ef 100644 --- a/jest/setup.ts +++ b/jest/setup.ts @@ -2,6 +2,7 @@ import type {RenderInfo} from '@components/FlatList/RenderTaskQueue'; import '@shopify/flash-list/jestSetup'; import type {ReactNode} from 'react'; +import type React from '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'; @@ -18,6 +19,7 @@ import '@src/polyfills/requestIdleCallback'; import mockFSLibrary from './setupMockFullstoryLib'; import setupMockImages from './setupMockImages'; +import setupMockLegendList from './setupMockLegendList'; // Needed for tests to have the necessary environment variables set if (!('GITHUB_REPOSITORY' in process.env)) { @@ -27,6 +29,7 @@ if (!('GITHUB_REPOSITORY' in process.env)) { setupMockImages(); mockFSLibrary(); +setupMockLegendList(); // Polyfill necessary for Onyx.init in jest/setupAfterEnv.ts Object.assign(global, {TextDecoder, TextEncoder}); diff --git a/jest/setupMockLegendList.ts b/jest/setupMockLegendList.ts new file mode 100644 index 000000000000..489bed5d311e --- /dev/null +++ b/jest/setupMockLegendList.ts @@ -0,0 +1,166 @@ +import type * as LegendListModule from '@legendapp/list/react-native'; +import type React from 'react'; +import type {ScrollViewProps, View as ReactNativeView} from 'react-native'; + +export default function setupMockLegendList() { + jest.mock('@legendapp/list/react-native', () => { + const ReactActual = jest.requireActual('react'); + const {ScrollView, View} = jest.requireActual<{ScrollView: React.ComponentType; View: typeof ReactNativeView}>('react-native'); + const LegendListActual = jest.requireActual('@legendapp/list/react-native'); + + type MockLegendListProps = LegendListModule.LegendListProps; + + /** + * 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. + */ + const MockLegendList = ReactActual.forwardRef( + ( + { + 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); + const reachedEdgesRef = ReactActual.useRef({end: false, start: false}); + 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 = (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; + const isWithinEndThreshold = distanceFromEnd <= visibleLength * (onEndReachedThreshold ?? 0.5); + const isWithinStartThreshold = offset <= visibleLength * (onStartReachedThreshold ?? 0.5); + listMetricsRef.current = { + contentLength, + scroll: offset, + scrollLength: visibleLength, + }; + onScroll?.(event); + + if (!isWithinEndThreshold) { + reachedEdgesRef.current.end = false; + } else if (!reachedEdgesRef.current.end) { + reachedEdgesRef.current.end = true; + onEndReached?.({distanceFromEnd}); + } + if (!isWithinStartThreshold) { + reachedEdgesRef.current.start = false; + } else if (!reachedEdgesRef.current.start) { + reachedEdgesRef.current.start = true; + 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}) => ReactActual.createElement(MockLegendList, props)); + + const useRecyclingState = (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('@legendapp/list/react-native').LegendList, + })); +} diff --git a/package-lock.json b/package-lock.json index c7fb16b5f958..9f281864e26b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,6 +33,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", @@ -11407,6 +11408,26 @@ "dev": true, "license": "MIT" }, + "node_modules/@legendapp/list": { + "version": "3.3.10", + "resolved": "https://registry.npmjs.org/@legendapp/list/-/list-3.3.10.tgz", + "integrity": "sha512-S8cwV11oJJD2m47JoRnCmlgbUrnY/f48TPL1zl1C3wwyrvru53Zf1NtYFlMtAU6lUKh/cU9RIffLI7unbf3kmg==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.5.0" + }, + "peerDependencies": { + "react": "*" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, "node_modules/@lottiefiles/dotlottie-react": { "version": "0.13.5", "resolved": "https://registry.npmjs.org/@lottiefiles/dotlottie-react/-/dotlottie-react-0.13.5.tgz", diff --git a/package.json b/package.json index af5c4294e5f3..526863340136 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/LegendList/setLegendListItemZIndex.ts b/src/components/LegendList/setLegendListItemZIndex.ts new file mode 100644 index 000000000000..9570fc3d1e16 --- /dev/null +++ b/src/components/LegendList/setLegendListItemZIndex.ts @@ -0,0 +1,22 @@ +import type {LegendListRef} from '@legendapp/list/react-native'; + +type NativeListItem = { + setNativeProps?: (props: {style: {zIndex: number}}) => void; +}; + +/** Applies z-index to LegendList's native item container rather than to the rendered item inside it. */ +function setLegendListItemZIndex(list: LegendListRef | null, index: number, zIndex: number): boolean { + const itemContainer: unknown = list?.getState().elementAtIndex(index); + if (!isNativeListItem(itemContainer)) { + return false; + } + + itemContainer.setNativeProps({style: {zIndex}}); + return true; +} + +function isNativeListItem(value: unknown): value is NativeListItem & Required> { + return typeof value === 'object' && value !== null && 'setNativeProps' in value && typeof value.setNativeProps === 'function'; +} + +export default setLegendListItemZIndex; diff --git a/tests/unit/LegendListItemZIndexTest.ts b/tests/unit/LegendListItemZIndexTest.ts new file mode 100644 index 000000000000..cdf37f85cb33 --- /dev/null +++ b/tests/unit/LegendListItemZIndexTest.ts @@ -0,0 +1,31 @@ +import setLegendListItemZIndex from '@components/LegendList/setLegendListItemZIndex'; + +import type {LegendListRef} from '@legendapp/list/react-native'; + +import createMock from '../utils/createMock'; + +describe('setLegendListItemZIndex', () => { + it('updates the native LegendList item container', () => { + const setNativeProps = jest.fn(); + const list = createMock({ + getState: () => + createMock>({ + elementAtIndex: () => ({setNativeProps}), + }), + }); + + expect(setLegendListItemZIndex(list, 3, -3)).toBe(true); + expect(setNativeProps).toHaveBeenCalledWith({style: {zIndex: -3}}); + }); + + it('does nothing when the item container is not mounted', () => { + const list = createMock({ + getState: () => + createMock>({ + elementAtIndex: () => undefined, + }), + }); + + expect(setLegendListItemZIndex(list, 3, -3)).toBe(false); + }); +}); diff --git a/tests/unit/LegendListTest.tsx b/tests/unit/LegendListTest.tsx new file mode 100644 index 000000000000..6f4c536c0c5a --- /dev/null +++ b/tests/unit/LegendListTest.tsx @@ -0,0 +1,117 @@ +import {fireEvent, render, screen} from '@testing-library/react-native'; + +import type {LegendListProps, LegendListRef} from '@legendapp/list/react-native'; + +import {LegendList as LibraryLegendList} from '@legendapp/list/react-native'; +import {createRef} from 'react'; +import {View} from 'react-native'; + +const DATA = ['first', 'second', 'third']; + +const renderItem: NonNullable['renderItem']> = ({item}) => { + return ; +}; + +describe('LegendList Jest mock', () => { + it('renders every item without another virtualized list', () => { + render( + , + ); + + for (const item of DATA) { + expect(screen.getByTestId(item)).toBeOnTheScreen(); + } + }); + + it('forwards scroll events and calculates the end distance', () => { + const onEndReached = jest.fn(); + const ref = createRef(); + const onScroll = jest.fn(() => ref.current?.getState()); + render( + , + ); + + fireEvent.scroll(screen.getByTestId('legend-list'), { + nativeEvent: { + contentOffset: {x: 0, y: 100}, + contentSize: {height: 600, width: 300}, + layoutMeasurement: {height: 400, width: 300}, + }, + }); + + expect(onScroll).toHaveBeenCalledTimes(1); + expect(onScroll).toHaveLastReturnedWith(expect.objectContaining({contentLength: 600, scroll: 100, scrollLength: 400})); + expect(onEndReached).toHaveBeenCalledWith({distanceFromEnd: 100}); + }); + + it('fires edge callbacks again only after leaving and re-entering their thresholds', () => { + const onEndReached = jest.fn(); + const onStartReached = jest.fn(); + render( + , + ); + + const scrollTo = (y: number) => { + fireEvent.scroll(screen.getByTestId('legend-list'), { + nativeEvent: { + contentOffset: {x: 0, y}, + contentSize: {height: 1000, width: 100}, + layoutMeasurement: {height: 100, width: 100}, + }, + }); + }; + + scrollTo(860); + scrollTo(870); + expect(onEndReached).toHaveBeenCalledTimes(1); + + scrollTo(800); + scrollTo(860); + expect(onEndReached).toHaveBeenCalledTimes(2); + + scrollTo(40); + scrollTo(30); + expect(onStartReached).toHaveBeenCalledTimes(1); + + scrollTo(100); + scrollTo(40); + expect(onStartReached).toHaveBeenCalledTimes(2); + }); + + it('provides the imperative scroll methods used by list consumers', async () => { + const ref = createRef(); + const onLoad = jest.fn(() => ref.current?.getState()); + render( + , + ); + + expect(onLoad).toHaveBeenCalledTimes(1); + expect(onLoad).toHaveLastReturnedWith(expect.objectContaining({data: DATA, endBuffered: DATA.length - 1, startBuffered: 0})); + await expect(ref.current?.scrollToIndex({index: 1})).resolves.toBeUndefined(); + await expect(ref.current?.scrollToOffset({offset: 20})).resolves.toBeUndefined(); + }); +});