From dcbb08ab9decb7c0228a04d17976388cdc629449 Mon Sep 17 00:00:00 2001 From: chrispader Date: Wed, 9 Sep 2026 13:39:33 +0200 Subject: [PATCH 1/7] feat: add shared LegendList components --- cspell.json | 1 + jest/setup.ts | 134 ++++++++++++++++++ package-lock.json | 21 +++ package.json | 1 + .../LegendList/AnimatedLegendList.tsx | 23 +++ src/components/LegendList/index.tsx | 23 +++ src/components/LegendList/types.ts | 7 + tests/unit/LegendListTest.tsx | 117 +++++++++++++++ 8 files changed, 327 insertions(+) create mode 100644 src/components/LegendList/AnimatedLegendList.tsx create mode 100644 src/components/LegendList/index.tsx create mode 100644 src/components/LegendList/types.ts create mode 100644 tests/unit/LegendListTest.tsx 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..bf178b646fa0 100644 --- a/jest/setup.ts +++ b/jest/setup.ts @@ -1,7 +1,10 @@ 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'; @@ -28,6 +31,137 @@ 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('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; + + const MockLegendList = ReactActual.forwardRef( + ( + { + children, + data, + extraData, + getItemType, + ItemSeparatorComponent, + keyExtractor, + ListEmptyComponent, + ListFooterComponent, + ListFooterComponentStyle, + ListHeaderComponent, + ListHeaderComponentStyle, + maintainVisibleContentPosition: _maintainVisibleContentPosition, + onEndReached, + onEndReachedThreshold = 0.5, + onScroll, + onStartReached, + onStartReachedThreshold = 0.5, + recycleItems: _recycleItems, + renderItem, + ...scrollViewProps + }, + ref, + ) => { + ReactActual.useImperativeHandle( + ref, + () => ({ + clearCaches: jest.fn(), + flashScrollIndicators: jest.fn(), + getAnimatableRef: () => null, + getNativeScrollRef: () => null, + getScrollableNode: () => null, + getScrollResponder: () => null, + getState: () => ({data: data ?? []}), + 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) => { + 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; + + 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}) => 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, +})); + // Polyfill necessary for Onyx.init in jest/setupAfterEnv.ts Object.assign(global, {TextDecoder, TextEncoder}); 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/AnimatedLegendList.tsx b/src/components/LegendList/AnimatedLegendList.tsx new file mode 100644 index 000000000000..9f3cfce9c961 --- /dev/null +++ b/src/components/LegendList/AnimatedLegendList.tsx @@ -0,0 +1,23 @@ +import type {AnimatedLegendListProps} from '@legendapp/list/reanimated'; +import type {Ref} from 'react'; + +import {AnimatedLegendList as BaseAnimatedLegendList} from '@legendapp/list/reanimated'; + +import type {LegendListRef} from './types'; + +type Props = AnimatedLegendListProps & { + ref?: Ref; +}; + +function AnimatedLegendList({maintainVisibleContentPosition = false, recycleItems = false, ref, ...rest}: Props) { + return ( + + ref={ref} + maintainVisibleContentPosition={maintainVisibleContentPosition} + recycleItems={recycleItems} + {...rest} + /> + ); +} + +export default AnimatedLegendList; diff --git a/src/components/LegendList/index.tsx b/src/components/LegendList/index.tsx new file mode 100644 index 000000000000..98362d4edc53 --- /dev/null +++ b/src/components/LegendList/index.tsx @@ -0,0 +1,23 @@ +import type {Ref} from 'react'; + +import {LegendList as BaseLegendList} from '@legendapp/list/react-native'; + +import type {LegendListProps, LegendListRef} from './types'; + +type Props = LegendListProps & { + ref?: Ref; +}; + +function LegendList({maintainVisibleContentPosition = false, recycleItems = false, ref, ...rest}: Props) { + return ( + + ref={ref} + maintainVisibleContentPosition={maintainVisibleContentPosition} + recycleItems={recycleItems} + {...rest} + /> + ); +} + +export type {LegendListProps, LegendListRef, LegendListRenderItem, LegendListRenderItemProps, ViewToken} from './types'; +export default LegendList; diff --git a/src/components/LegendList/types.ts b/src/components/LegendList/types.ts new file mode 100644 index 000000000000..69d928372cb6 --- /dev/null +++ b/src/components/LegendList/types.ts @@ -0,0 +1,7 @@ +import type {LegendListRenderItemProps} from '@legendapp/list/react-native'; +import type {ReactNode} from 'react'; + +type LegendListRenderItem = (props: LegendListRenderItemProps) => ReactNode; + +export type {LegendListProps, LegendListRef, LegendListRenderItemProps, ViewToken} from '@legendapp/list/react-native'; +export type {LegendListRenderItem}; diff --git a/tests/unit/LegendListTest.tsx b/tests/unit/LegendListTest.tsx new file mode 100644 index 000000000000..903f2cd4e26c --- /dev/null +++ b/tests/unit/LegendListTest.tsx @@ -0,0 +1,117 @@ +import {fireEvent, render, screen} from '@testing-library/react-native'; + +import LegendList from '@components/LegendList'; +import AnimatedLegendList from '@components/LegendList/AnimatedLegendList'; + +import type {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']; + +function renderItem({item}: {item: string}) { + return ; +} + +describe('LegendList', () => { + it('disables recycling and visible-position maintenance by default', () => { + render( + , + ); + const props = jest.mocked(LibraryLegendList).mock.lastCall?.[0]; + + expect(props?.maintainVisibleContentPosition).toBe(false); + expect(props?.recycleItems).toBe(false); + }); + + it('preserves explicit list behavior settings', () => { + render( + , + ); + const props = jest.mocked(LibraryLegendList).mock.lastCall?.[0]; + + expect(props?.maintainVisibleContentPosition).toBe(true); + expect(props?.recycleItems).toBe(true); + }); + + it('uses the same safe defaults in the Reanimated wrapper', () => { + render( + , + ); + const props = jest.mocked(LibraryLegendList).mock.lastCall?.[0]; + + expect(props?.maintainVisibleContentPosition).toBe(false); + expect(props?.recycleItems).toBe(false); + }); +}); + +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 onScroll = jest.fn(); + 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(onEndReached).toHaveBeenCalledWith({distanceFromEnd: 100}); + }); + + it('provides the imperative scroll methods used by list consumers', async () => { + const ref = createRef(); + render( + , + ); + + await expect(ref.current?.scrollToIndex({index: 1})).resolves.toBeUndefined(); + await expect(ref.current?.scrollToOffset({offset: 20})).resolves.toBeUndefined(); + }); +}); From 857511e20e4ed1260fb4849811c34bcf5d6c1f80 Mon Sep 17 00:00:00 2001 From: chrispader Date: Wed, 9 Sep 2026 20:26:59 +0200 Subject: [PATCH 2/7] test: cover public LegendList wrapper types --- src/components/LegendList/index.tsx | 1 - tests/unit/LegendListTest.tsx | 10 ++++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/components/LegendList/index.tsx b/src/components/LegendList/index.tsx index 98362d4edc53..d5ce171e22ad 100644 --- a/src/components/LegendList/index.tsx +++ b/src/components/LegendList/index.tsx @@ -19,5 +19,4 @@ function LegendList({maintainVisibleContentPosition = false, re ); } -export type {LegendListProps, LegendListRef, LegendListRenderItem, LegendListRenderItemProps, ViewToken} from './types'; export default LegendList; diff --git a/tests/unit/LegendListTest.tsx b/tests/unit/LegendListTest.tsx index 903f2cd4e26c..b0beb2788a6b 100644 --- a/tests/unit/LegendListTest.tsx +++ b/tests/unit/LegendListTest.tsx @@ -2,8 +2,7 @@ import {fireEvent, render, screen} from '@testing-library/react-native'; import LegendList from '@components/LegendList'; import AnimatedLegendList from '@components/LegendList/AnimatedLegendList'; - -import type {LegendListRef} from '@legendapp/list/react-native'; +import type {LegendListRef, LegendListRenderItem, LegendListRenderItemProps, ViewToken} from '@components/LegendList/types'; import {LegendList as LibraryLegendList} from '@legendapp/list/react-native'; import {createRef} from 'react'; @@ -11,15 +10,17 @@ import {View} from 'react-native'; const DATA = ['first', 'second', 'third']; -function renderItem({item}: {item: string}) { +const renderItem: LegendListRenderItem = ({item}: LegendListRenderItemProps) => { return ; -} +}; describe('LegendList', () => { it('disables recycling and visible-position maintenance by default', () => { + const onViewableItemsChanged = jest.fn((info: {viewableItems: ViewToken[]}) => info.viewableItems); render( , @@ -27,6 +28,7 @@ describe('LegendList', () => { const props = jest.mocked(LibraryLegendList).mock.lastCall?.[0]; expect(props?.maintainVisibleContentPosition).toBe(false); + expect(props?.onViewableItemsChanged).toBe(onViewableItemsChanged); expect(props?.recycleItems).toBe(false); }); From 12e46ed8c17ee0923c5875c7cf1ae724d8c2a23b Mon Sep 17 00:00:00 2001 From: chrispader Date: Wed, 9 Sep 2026 22:19:22 +0200 Subject: [PATCH 3/7] fix: preserve LegendList library defaults --- .../LegendList/AnimatedLegendList.tsx | 12 +++------- src/components/LegendList/index.tsx | 12 +++------- tests/unit/LegendListTest.tsx | 22 +++++++++---------- 3 files changed, 17 insertions(+), 29 deletions(-) diff --git a/src/components/LegendList/AnimatedLegendList.tsx b/src/components/LegendList/AnimatedLegendList.tsx index 9f3cfce9c961..1ae10fa360f4 100644 --- a/src/components/LegendList/AnimatedLegendList.tsx +++ b/src/components/LegendList/AnimatedLegendList.tsx @@ -6,18 +6,12 @@ import {AnimatedLegendList as BaseAnimatedLegendList} from '@legendapp/list/rean import type {LegendListRef} from './types'; type Props = AnimatedLegendListProps & { + /** Ref forwarded to the underlying animated LegendList. */ ref?: Ref; }; -function AnimatedLegendList({maintainVisibleContentPosition = false, recycleItems = false, ref, ...rest}: Props) { - return ( - - ref={ref} - maintainVisibleContentPosition={maintainVisibleContentPosition} - recycleItems={recycleItems} - {...rest} - /> - ); +function AnimatedLegendList(props: Props) { + return {...props} />; } export default AnimatedLegendList; diff --git a/src/components/LegendList/index.tsx b/src/components/LegendList/index.tsx index d5ce171e22ad..4a4facac2045 100644 --- a/src/components/LegendList/index.tsx +++ b/src/components/LegendList/index.tsx @@ -5,18 +5,12 @@ import {LegendList as BaseLegendList} from '@legendapp/list/react-native'; import type {LegendListProps, LegendListRef} from './types'; type Props = LegendListProps & { + /** Ref forwarded to the underlying LegendList. */ ref?: Ref; }; -function LegendList({maintainVisibleContentPosition = false, recycleItems = false, ref, ...rest}: Props) { - return ( - - ref={ref} - maintainVisibleContentPosition={maintainVisibleContentPosition} - recycleItems={recycleItems} - {...rest} - /> - ); +function LegendList(props: Props) { + return {...props} />; } export default LegendList; diff --git a/tests/unit/LegendListTest.tsx b/tests/unit/LegendListTest.tsx index b0beb2788a6b..4839ccafffc7 100644 --- a/tests/unit/LegendListTest.tsx +++ b/tests/unit/LegendListTest.tsx @@ -15,7 +15,7 @@ const renderItem: LegendListRenderItem = ({item}: LegendListRenderItemPr }; describe('LegendList', () => { - it('disables recycling and visible-position maintenance by default', () => { + it('preserves library defaults when behavior settings are omitted', () => { const onViewableItemsChanged = jest.fn((info: {viewableItems: ViewToken[]}) => info.viewableItems); render( { ); const props = jest.mocked(LibraryLegendList).mock.lastCall?.[0]; - expect(props?.maintainVisibleContentPosition).toBe(false); + expect(props).not.toHaveProperty('maintainVisibleContentPosition'); expect(props?.onViewableItemsChanged).toBe(onViewableItemsChanged); - expect(props?.recycleItems).toBe(false); + expect(props).not.toHaveProperty('recycleItems'); }); - it('preserves explicit list behavior settings', () => { + it.each([true, false])('preserves explicit list behavior settings set to %s', (enabled) => { render( , ); const props = jest.mocked(LibraryLegendList).mock.lastCall?.[0]; - expect(props?.maintainVisibleContentPosition).toBe(true); - expect(props?.recycleItems).toBe(true); + expect(props?.maintainVisibleContentPosition).toBe(enabled); + expect(props?.recycleItems).toBe(enabled); }); - it('uses the same safe defaults in the Reanimated wrapper', () => { + it('preserves library defaults in the Reanimated wrapper', () => { render( { ); const props = jest.mocked(LibraryLegendList).mock.lastCall?.[0]; - expect(props?.maintainVisibleContentPosition).toBe(false); - expect(props?.recycleItems).toBe(false); + expect(props).not.toHaveProperty('maintainVisibleContentPosition'); + expect(props).not.toHaveProperty('recycleItems'); }); }); From 02d94db51b53d95a77dd18eda313c88d9286ad13 Mon Sep 17 00:00:00 2001 From: chrispader Date: Thu, 10 Sep 2026 12:18:36 +0200 Subject: [PATCH 4/7] refactor: remove LegendList component wrappers --- .../LegendList/AnimatedLegendList.tsx | 17 ------ src/components/LegendList/index.tsx | 16 ------ tests/unit/LegendListTest.tsx | 53 +------------------ 3 files changed, 1 insertion(+), 85 deletions(-) delete mode 100644 src/components/LegendList/AnimatedLegendList.tsx delete mode 100644 src/components/LegendList/index.tsx diff --git a/src/components/LegendList/AnimatedLegendList.tsx b/src/components/LegendList/AnimatedLegendList.tsx deleted file mode 100644 index 1ae10fa360f4..000000000000 --- a/src/components/LegendList/AnimatedLegendList.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import type {AnimatedLegendListProps} from '@legendapp/list/reanimated'; -import type {Ref} from 'react'; - -import {AnimatedLegendList as BaseAnimatedLegendList} from '@legendapp/list/reanimated'; - -import type {LegendListRef} from './types'; - -type Props = AnimatedLegendListProps & { - /** Ref forwarded to the underlying animated LegendList. */ - ref?: Ref; -}; - -function AnimatedLegendList(props: Props) { - return {...props} />; -} - -export default AnimatedLegendList; diff --git a/src/components/LegendList/index.tsx b/src/components/LegendList/index.tsx deleted file mode 100644 index 4a4facac2045..000000000000 --- a/src/components/LegendList/index.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import type {Ref} from 'react'; - -import {LegendList as BaseLegendList} from '@legendapp/list/react-native'; - -import type {LegendListProps, LegendListRef} from './types'; - -type Props = LegendListProps & { - /** Ref forwarded to the underlying LegendList. */ - ref?: Ref; -}; - -function LegendList(props: Props) { - return {...props} />; -} - -export default LegendList; diff --git a/tests/unit/LegendListTest.tsx b/tests/unit/LegendListTest.tsx index 4839ccafffc7..6c93d415c0b9 100644 --- a/tests/unit/LegendListTest.tsx +++ b/tests/unit/LegendListTest.tsx @@ -1,8 +1,6 @@ import {fireEvent, render, screen} from '@testing-library/react-native'; -import LegendList from '@components/LegendList'; -import AnimatedLegendList from '@components/LegendList/AnimatedLegendList'; -import type {LegendListRef, LegendListRenderItem, LegendListRenderItemProps, ViewToken} from '@components/LegendList/types'; +import type {LegendListRef, LegendListRenderItem, LegendListRenderItemProps} from '@components/LegendList/types'; import {LegendList as LibraryLegendList} from '@legendapp/list/react-native'; import {createRef} from 'react'; @@ -14,55 +12,6 @@ const renderItem: LegendListRenderItem = ({item}: LegendListRenderItemPr return ; }; -describe('LegendList', () => { - it('preserves library defaults when behavior settings are omitted', () => { - const onViewableItemsChanged = jest.fn((info: {viewableItems: ViewToken[]}) => info.viewableItems); - render( - , - ); - const props = jest.mocked(LibraryLegendList).mock.lastCall?.[0]; - - expect(props).not.toHaveProperty('maintainVisibleContentPosition'); - expect(props?.onViewableItemsChanged).toBe(onViewableItemsChanged); - expect(props).not.toHaveProperty('recycleItems'); - }); - - it.each([true, false])('preserves explicit list behavior settings set to %s', (enabled) => { - render( - , - ); - const props = jest.mocked(LibraryLegendList).mock.lastCall?.[0]; - - expect(props?.maintainVisibleContentPosition).toBe(enabled); - expect(props?.recycleItems).toBe(enabled); - }); - - it('preserves library defaults in the Reanimated wrapper', () => { - render( - , - ); - const props = jest.mocked(LibraryLegendList).mock.lastCall?.[0]; - - expect(props).not.toHaveProperty('maintainVisibleContentPosition'); - expect(props).not.toHaveProperty('recycleItems'); - }); -}); - describe('LegendList Jest mock', () => { it('renders every item without another virtualized list', () => { render( From 35c28464c432eac6ab7d4f58bf1c8891ece97ed9 Mon Sep 17 00:00:00 2001 From: chrispader Date: Thu, 10 Sep 2026 12:23:28 +0200 Subject: [PATCH 5/7] refactor: centralize LegendList integration utilities --- jest/setup.ts | 25 +++++++++++++-- .../LegendList/setLegendListItemZIndex.ts | 22 +++++++++++++ src/components/LegendList/types.ts | 7 ----- tests/unit/LegendListItemZIndexTest.ts | 31 +++++++++++++++++++ tests/unit/LegendListTest.tsx | 13 ++++++-- 5 files changed, 85 insertions(+), 13 deletions(-) create mode 100644 src/components/LegendList/setLegendListItemZIndex.ts delete mode 100644 src/components/LegendList/types.ts create mode 100644 tests/unit/LegendListItemZIndexTest.ts diff --git a/jest/setup.ts b/jest/setup.ts index bf178b646fa0..98bd75c5a569 100644 --- a/jest/setup.ts +++ b/jest/setup.ts @@ -57,6 +57,7 @@ jest.mock('@legendapp/list/react-native', () => { maintainVisibleContentPosition: _maintainVisibleContentPosition, onEndReached, onEndReachedThreshold = 0.5, + onLoad, onScroll, onStartReached, onStartReachedThreshold = 0.5, @@ -66,6 +67,14 @@ jest.mock('@legendapp/list/react-native', () => { }, 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, () => ({ @@ -75,7 +84,13 @@ jest.mock('@legendapp/list/react-native', () => { getNativeScrollRef: () => null, getScrollableNode: () => null, getScrollResponder: () => null, - getState: () => ({data: data ?? []}), + 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()), @@ -91,14 +106,18 @@ jest.mock('@legendapp/list/react-native', () => { ); const handleScroll: NonNullable = (event) => { - 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}); 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/src/components/LegendList/types.ts b/src/components/LegendList/types.ts deleted file mode 100644 index 69d928372cb6..000000000000 --- a/src/components/LegendList/types.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type {LegendListRenderItemProps} from '@legendapp/list/react-native'; -import type {ReactNode} from 'react'; - -type LegendListRenderItem = (props: LegendListRenderItemProps) => ReactNode; - -export type {LegendListProps, LegendListRef, LegendListRenderItemProps, ViewToken} from '@legendapp/list/react-native'; -export type {LegendListRenderItem}; 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 index 6c93d415c0b9..3c54fa36ab18 100644 --- a/tests/unit/LegendListTest.tsx +++ b/tests/unit/LegendListTest.tsx @@ -1,6 +1,6 @@ import {fireEvent, render, screen} from '@testing-library/react-native'; -import type {LegendListRef, LegendListRenderItem, LegendListRenderItemProps} from '@components/LegendList/types'; +import type {LegendListProps, LegendListRef} from '@legendapp/list/react-native'; import {LegendList as LibraryLegendList} from '@legendapp/list/react-native'; import {createRef} from 'react'; @@ -8,7 +8,7 @@ import {View} from 'react-native'; const DATA = ['first', 'second', 'third']; -const renderItem: LegendListRenderItem = ({item}: LegendListRenderItemProps) => { +const renderItem: NonNullable['renderItem']> = ({item}) => { return ; }; @@ -28,13 +28,15 @@ describe('LegendList Jest mock', () => { it('forwards scroll events and calculates the end distance', () => { const onEndReached = jest.fn(); - const onScroll = jest.fn(); + const ref = createRef(); + const onScroll = jest.fn(() => ref.current?.getState()); render( , @@ -49,19 +51,24 @@ describe('LegendList Jest mock', () => { }); expect(onScroll).toHaveBeenCalledTimes(1); + expect(onScroll).toHaveLastReturnedWith(expect.objectContaining({contentLength: 600, scroll: 100, scrollLength: 400})); expect(onEndReached).toHaveBeenCalledWith({distanceFromEnd: 100}); }); 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(); }); From 2d3d82e7bb39641e1a0c341c0c0b8ef5c7381545 Mon Sep 17 00:00:00 2001 From: chrispader Date: Thu, 10 Sep 2026 13:40:53 +0200 Subject: [PATCH 6/7] test: extract LegendList Jest mock --- jest/setup.ts | 154 +---------------------------------- jest/setupMockLegendList.ts | 157 ++++++++++++++++++++++++++++++++++++ 2 files changed, 159 insertions(+), 152 deletions(-) create mode 100644 jest/setupMockLegendList.ts diff --git a/jest/setup.ts b/jest/setup.ts index 98bd75c5a569..09de71c6a8ef 100644 --- a/jest/setup.ts +++ b/jest/setup.ts @@ -1,10 +1,8 @@ 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'; @@ -21,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)) { @@ -30,156 +29,7 @@ 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('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; - - 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); - 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; - 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}) => 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, -})); +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..8374da4deb29 --- /dev/null +++ b/jest/setupMockLegendList.ts @@ -0,0 +1,157 @@ +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); + 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; + 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}) => 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, + })); +} From de54750257953c5b647e4c092c4470bbd651a1b0 Mon Sep 17 00:00:00 2001 From: chrispader Date: Thu, 10 Sep 2026 13:59:25 +0200 Subject: [PATCH 7/7] test: latch LegendList edge callbacks --- jest/setupMockLegendList.ts | 13 +++++++++-- tests/unit/LegendListTest.tsx | 42 +++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/jest/setupMockLegendList.ts b/jest/setupMockLegendList.ts index 8374da4deb29..489bed5d311e 100644 --- a/jest/setupMockLegendList.ts +++ b/jest/setupMockLegendList.ts @@ -43,6 +43,7 @@ export default function setupMockLegendList() { ) => { 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(() => { @@ -86,6 +87,8 @@ export default function setupMockLegendList() { 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, @@ -93,10 +96,16 @@ export default function setupMockLegendList() { }; onScroll?.(event); - if (distanceFromEnd <= visibleLength * (onEndReachedThreshold ?? 0.5)) { + if (!isWithinEndThreshold) { + reachedEdgesRef.current.end = false; + } else if (!reachedEdgesRef.current.end) { + reachedEdgesRef.current.end = true; onEndReached?.({distanceFromEnd}); } - if (offset <= visibleLength * (onStartReachedThreshold ?? 0.5)) { + if (!isWithinStartThreshold) { + reachedEdgesRef.current.start = false; + } else if (!reachedEdgesRef.current.start) { + reachedEdgesRef.current.start = true; onStartReached?.({distanceFromStart: offset}); } }; diff --git a/tests/unit/LegendListTest.tsx b/tests/unit/LegendListTest.tsx index 3c54fa36ab18..6f4c536c0c5a 100644 --- a/tests/unit/LegendListTest.tsx +++ b/tests/unit/LegendListTest.tsx @@ -55,6 +55,48 @@ describe('LegendList Jest mock', () => { 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());