Skip to content
Merged
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 @@ -763,6 +763,7 @@
"lastiPhoneLogin",
"lastname",
"lefthook",
"legendapp",
"libc",
"Libc",
"libc's",
Expand Down
3 changes: 3 additions & 0 deletions jest/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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)) {
Expand All @@ -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});
Expand Down
166 changes: 166 additions & 0 deletions jest/setupMockLegendList.ts
Original file line number Diff line number Diff line change
@@ -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<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>;

/**
* 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<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);
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<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;
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<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,
}));
}
21 changes: 21 additions & 0 deletions package-lock.json

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

1 change: 1 addition & 0 deletions 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 Down
22 changes: 22 additions & 0 deletions src/components/LegendList/setLegendListItemZIndex.ts
Original file line number Diff line number Diff line change
@@ -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<Pick<NativeListItem, 'setNativeProps'>> {
return typeof value === 'object' && value !== null && 'setNativeProps' in value && typeof value.setNativeProps === 'function';
}

export default setLegendListItemZIndex;
31 changes: 31 additions & 0 deletions tests/unit/LegendListItemZIndexTest.ts
Original file line number Diff line number Diff line change
@@ -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<LegendListRef>({
getState: () =>
createMock<ReturnType<LegendListRef['getState']>>({
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<LegendListRef>({
getState: () =>
createMock<ReturnType<LegendListRef['getState']>>({
elementAtIndex: () => undefined,
}),
});

expect(setLegendListItemZIndex(list, 3, -3)).toBe(false);
});
});
Loading
Loading