import React, { useEffect } from "react";
import { Animated, ScrollView, Text, View } from "react-native";
import "@quilted/react-testing/matchers";
import { ProgressiveListView } from "recyclerlistview";
import Warnings from "../errors/Warnings";
import AutoLayoutView from "../native/auto-layout/AutoLayoutView";
import CellContainer from "../native/cell-container/CellContainer";
import { ListRenderItemInfo, RenderTargetOptions } from "../FlashListProps";
import { mountFlashList } from "./helpers/mountFlashList";
jest.mock("../native/cell-container/CellContainer", () => {
return jest.requireActual("../native/cell-container/CellContainer.ios.ts")
.default;
});
describe("FlashList", () => {
beforeEach(() => {
jest.clearAllMocks();
jest.useFakeTimers();
});
it("renders items", () => {
const flashList = mountFlashList();
expect(flashList).toContainReactComponent(Text, { children: "One" });
expect(flashList).toContainReactComponent(ProgressiveListView, {
isHorizontal: false,
});
});
it("sets ProgressiveListView to horizontal", () => {
const flashList = mountFlashList({ horizontal: true });
expect(flashList).toContainReactComponent(ProgressiveListView, {
isHorizontal: true,
});
});
it("calls prepareForLayoutAnimationRender", () => {
const flashList = mountFlashList({
keyExtractor: (item) => item,
});
const warn = jest.spyOn(console, "warn").mockReturnValue();
const prepareForLayoutAnimationRender = jest.spyOn(
flashList.instance!.recyclerlistview_unsafe!,
"prepareForLayoutAnimationRender"
);
flashList.instance.prepareForLayoutAnimationRender();
expect(prepareForLayoutAnimationRender).toHaveBeenCalledTimes(1);
expect(warn).not.toHaveBeenCalled();
});
it("sends a warning when prepareForLayoutAnimationRender without keyExtractor", () => {
const flashList = mountFlashList();
const warn = jest.spyOn(console, "warn").mockReturnValue();
const prepareForLayoutAnimationRender = jest.spyOn(
flashList.instance!.recyclerlistview_unsafe!,
"prepareForLayoutAnimationRender"
);
flashList.instance.prepareForLayoutAnimationRender();
expect(prepareForLayoutAnimationRender).not.toHaveBeenCalled();
expect(warn).toHaveBeenCalledWith(Warnings.missingKeyExtractor);
});
it("disables initial scroll correction on recyclerlistview if initialScrollIndex is in first row", () => {
let flashList = mountFlashList({ initialScrollIndex: 0, numColumns: 3 });
expect(
flashList.instance["getUpdatedWindowCorrectionConfig"]()
.applyToInitialOffset
).toBe(false);
flashList = mountFlashList({ initialScrollIndex: 3, numColumns: 3 });
expect(
flashList.instance["getUpdatedWindowCorrectionConfig"]()
.applyToInitialOffset
).toBe(true);
flashList = mountFlashList({ initialScrollIndex: 2, numColumns: 3 });
expect(
flashList.instance["getUpdatedWindowCorrectionConfig"]()
.applyToInitialOffset
).toBe(false);
});
it("assigns distance from window to window correction object", () => {
const flashList = mountFlashList({ estimatedFirstItemOffset: 100 });
expect(
flashList.instance["getUpdatedWindowCorrectionConfig"]().value.windowShift
).toBe(-100);
});
it("only forwards onBlankArea prop to AutoLayout when needed", () => {
const flashList = mountFlashList();
const autoLayoutView = flashList.find(AutoLayoutView)?.instance;
expect(autoLayoutView.props.onBlankAreaEvent).toBeUndefined();
flashList.setProps({ onBlankArea: () => {} });
expect(autoLayoutView.props.onBlankAreaEvent).not.toBeUndefined();
});
it("calls render item only when data of the items has changed", () => {
const renderItemMock = jest.fn(({ item }) => {
return {item};
});
const flashList = mountFlashList({
renderItem: renderItemMock,
data: ["One", "Two", "Three", "Four"],
});
// because we have 4 data items
expect(renderItemMock).toHaveBeenCalledTimes(4);
// reset counter
renderItemMock.mockClear();
// changes layout of all four items
flashList.setProps({ numColumns: 2 });
// render item should be called 0 times because only layout of items would have changed
expect(renderItemMock).toHaveBeenCalledTimes(0);
flashList.unmount();
});
it("keeps component mounted based on prepareForLayoutAnimationRender being called", () => {
// Tracks components being unmounted
const unmountMock = jest.fn();
const Item = ({ text }: { text: string }) => {
useEffect(() => {
return unmountMock;
}, []);
return {text};
};
const flashList = mountFlashList({
keyExtractor: (item) => item,
renderItem: ({ item }) => {
return ;
},
data: ["One", "Two", "Three", "Four"],
});
// Change data without prepareForLayoutAnimationRender
flashList.setProps({ data: ["One", "Two", "Three", "Five"] });
expect(unmountMock).not.toHaveBeenCalled();
// Before changing data, we run prepareForLayoutAnimationRender.
// This ensures component gets unmounted instead of being recycled to ensure layout animations run as expected.
flashList.instance.prepareForLayoutAnimationRender();
flashList.setProps({ data: ["One", "Two", "Three", "Six"] });
expect(unmountMock).toHaveBeenCalledTimes(1);
});
it("fires onLoad event", () => {
const onLoadMock = jest.fn();
// empty list
mountFlashList({ data: [], onLoad: onLoadMock });
expect(onLoadMock).toHaveBeenCalledWith({
elapsedTimeInMs: expect.any(Number),
});
onLoadMock.mockClear();
// non-empty list
const flashList = mountFlashList({ onLoad: onLoadMock });
flashList.find(ProgressiveListView)?.instance.onItemLayout(0);
expect(onLoadMock).toHaveBeenCalledWith({
elapsedTimeInMs: expect.any(Number),
});
});
it("loads an empty state", () => {
const EmptyComponent = () => {
return Empty;
};
const flashList = mountFlashList({
data: [],
ListEmptyComponent: EmptyComponent,
});
expect(flashList).toContainReactComponent(EmptyComponent);
});
it("loads header and footer in empty state", () => {
const HeaderComponent = () => {
return Empty;
};
const FooterComponent = () => {
return Empty;
};
const flashList = mountFlashList({
data: [],
ListHeaderComponent: HeaderComponent,
ListFooterComponent: FooterComponent,
});
expect(flashList).toContainReactComponent(HeaderComponent);
expect(flashList).toContainReactComponent(FooterComponent);
});
it("reports layout changes to the layout provider", () => {
const flashList = mountFlashList();
const reportItemLayoutMock = jest.spyOn(
flashList.instance.state.layoutProvider,
"reportItemLayout"
);
flashList.find(ProgressiveListView)?.instance.onItemLayout(0);
expect(reportItemLayoutMock).toHaveBeenCalledWith(0);
flashList.unmount();
});
it("should prefer overrideItemLayout over estimate and average", () => {
const flashList = mountFlashList({
overrideItemLayout: (layout) => {
layout.size = 50;
},
});
expect(flashList.instance.state.layoutProvider.averageItemSize).toBe(200);
expect(
flashList.instance.state
.layoutProvider!.getLayoutManager()!
.getLayouts()[0].height
).toBe(50);
});
it("should override span with overrideItemLayout", () => {
const renderItemMock = jest.fn(({ item }) => {
return {item};
});
mountFlashList({
overrideItemLayout: (layout) => {
layout.span = 2;
},
numColumns: 2,
estimatedItemSize: 300,
renderItem: renderItemMock,
});
expect(renderItemMock).toHaveBeenCalledTimes(3);
renderItemMock.mockClear();
mountFlashList({
overrideItemLayout: (layout, _, index) => {
if (index > 2) {
layout.span = 2;
}
},
data: new Array(20).fill(""),
numColumns: 3,
estimatedItemSize: 100,
renderItem: renderItemMock,
});
expect(renderItemMock).toHaveBeenCalledTimes(11);
});
it("overrideItemLayout should consider 0 as a valid span", () => {
const renderItemMock = jest.fn(({ item }) => {
return {item};
});
mountFlashList({
overrideItemLayout: (layout, _, index) => {
if (index < 4) {
layout.span = 0;
}
},
data: new Array(20).fill(""),
numColumns: 2,
renderItem: renderItemMock,
});
expect(renderItemMock).toHaveBeenCalledTimes(14);
});
it("reports onViewableItemsChanged for viewable items", () => {
const onViewableItemsChanged = jest.fn();
const onViewableItemsChangedForItemVisiblePercentThreshold = jest.fn();
const flashList = mountFlashList({
estimatedItemSize: 300,
viewabilityConfig: {
minimumViewTime: 250,
},
viewabilityConfigCallbackPairs: [
{
onViewableItemsChanged:
onViewableItemsChangedForItemVisiblePercentThreshold,
viewabilityConfig: {
itemVisiblePercentThreshold: 50,
waitForInteraction: true,
},
},
],
onViewableItemsChanged,
});
// onViewableItemsChanged is not called before 250 ms have elapsed
expect(onViewableItemsChanged).not.toHaveBeenCalled();
jest.advanceTimersByTime(250);
// Initial viewable items
expect(onViewableItemsChanged).toHaveBeenCalledWith({
changed: [
{
index: 0,
isViewable: true,
item: "One",
key: "0",
timestamp: expect.any(Number),
},
{
index: 1,
isViewable: true,
item: "Two",
key: "1",
timestamp: expect.any(Number),
},
{
index: 2,
isViewable: true,
item: "Three",
key: "2",
timestamp: expect.any(Number),
},
],
viewableItems: [
{
index: 0,
isViewable: true,
item: "One",
key: "0",
timestamp: expect.any(Number),
},
{
index: 1,
isViewable: true,
item: "Two",
key: "1",
timestamp: expect.any(Number),
},
{
index: 2,
isViewable: true,
item: "Three",
key: "2",
timestamp: expect.any(Number),
},
],
});
expect(
onViewableItemsChangedForItemVisiblePercentThreshold
).not.toHaveBeenCalled();
// onViewableItemsChangedForItemVisiblePercentThreshold waits for interaction before reporting viewable items
flashList.instance.recordInteraction();
jest.advanceTimersByTime(250);
expect(
onViewableItemsChangedForItemVisiblePercentThreshold
).toHaveBeenCalledWith({
changed: [
{
index: 0,
isViewable: true,
item: "One",
key: "0",
timestamp: expect.any(Number),
},
{
index: 1,
isViewable: true,
item: "Two",
key: "1",
timestamp: expect.any(Number),
},
{
index: 2,
isViewable: true,
item: "Three",
key: "2",
timestamp: expect.any(Number),
},
],
viewableItems: [
{
index: 0,
isViewable: true,
item: "One",
key: "0",
timestamp: expect.any(Number),
},
{
index: 1,
isViewable: true,
item: "Two",
key: "1",
timestamp: expect.any(Number),
},
{
index: 2,
isViewable: true,
item: "Three",
key: "2",
timestamp: expect.any(Number),
},
],
});
onViewableItemsChanged.mockReset();
onViewableItemsChangedForItemVisiblePercentThreshold.mockReset();
// Mocking a scroll that will make the first item not visible and the last item visible
jest
.spyOn(
flashList.instance!.recyclerlistview_unsafe!,
"getCurrentScrollOffset"
)
.mockReturnValue(200);
flashList.instance!.recyclerlistview_unsafe!.props.onVisibleIndicesChanged?.(
[0, 1, 2, 3],
[],
[]
);
flashList.instance!.recyclerlistview_unsafe!.props.onScroll?.(
{ nativeEvent: { contentOffset: { x: 0, y: 200 } } },
0,
200
);
jest.advanceTimersByTime(250);
expect(onViewableItemsChanged).toHaveBeenCalledWith({
changed: [
{
index: 3,
isViewable: true,
item: "Four",
key: "3",
timestamp: expect.any(Number),
},
],
viewableItems: [
{
index: 0,
isViewable: true,
item: "One",
key: "0",
timestamp: expect.any(Number),
},
{
index: 1,
isViewable: true,
item: "Two",
key: "1",
timestamp: expect.any(Number),
},
{
index: 2,
isViewable: true,
item: "Three",
key: "2",
timestamp: expect.any(Number),
},
{
index: 3,
isViewable: true,
item: "Four",
key: "3",
timestamp: expect.any(Number),
},
],
});
expect(
onViewableItemsChangedForItemVisiblePercentThreshold
).toHaveBeenCalledWith({
changed: [
{
index: 3,
isViewable: true,
item: "Four",
key: "3",
timestamp: expect.any(Number),
},
{
index: 0,
isViewable: false,
item: "One",
key: "0",
timestamp: expect.any(Number),
},
],
viewableItems: [
{
index: 1,
isViewable: true,
item: "Two",
key: "1",
timestamp: expect.any(Number),
},
{
index: 2,
isViewable: true,
item: "Three",
key: "2",
timestamp: expect.any(Number),
},
{
index: 3,
isViewable: true,
item: "Four",
key: "3",
timestamp: expect.any(Number),
},
],
});
});
it("viewability reports take into account estimatedFirstItemOffset", () => {
const onViewableItemsChanged = jest.fn();
mountFlashList({
estimatedFirstItemOffset: 200,
estimatedItemSize: 300,
onViewableItemsChanged,
viewabilityConfig: { itemVisiblePercentThreshold: 50 },
});
// onViewableItemsChanged is not called before 250 ms have elapsed
expect(onViewableItemsChanged).not.toHaveBeenCalled();
jest.advanceTimersByTime(250);
// Initial viewable items
expect(onViewableItemsChanged).toHaveBeenCalledWith({
changed: [
{
index: 0,
isViewable: true,
item: "One",
key: "0",
timestamp: expect.any(Number),
},
{
index: 1,
isViewable: true,
item: "Two",
key: "1",
timestamp: expect.any(Number),
},
],
viewableItems: [
{
index: 0,
isViewable: true,
item: "One",
key: "0",
timestamp: expect.any(Number),
},
{
index: 1,
isViewable: true,
item: "Two",
key: "1",
timestamp: expect.any(Number),
},
],
});
});
it("should not overlap header with sitcky index 0", () => {
const HeaderComponent = () => {
return Empty;
};
const flashList = mountFlashList({
ListHeaderComponent: HeaderComponent,
stickyHeaderIndices: [0],
});
// If sticky renders there'll be 6
expect(flashList.findAll(Text).length).toBe(5);
});
it("rerenders all items when layout manager changes", () => {
let countMounts = 0;
let currentId = 0;
// Effect will be triggered once per mount
const RenderComponent = ({ id }: { id?: number }) => {
useEffect(() => {
countMounts++;
}, [id]);
return Test;
};
const renderItem = () => {
return ;
};
const flashList = mountFlashList({
data: new Array(100).fill("1"),
estimatedItemSize: 70,
renderItem,
});
const scrollTo = (y: number) => {
flashList.find(ScrollView)?.trigger("onScroll", {
nativeEvent: { contentOffset: { x: 0, y } },
});
};
// Mocking some scrolls
scrollTo(200);
scrollTo(400);
scrollTo(600);
scrollTo(3000);
scrollTo(2000);
// changing id will trigger effects if components rerender
currentId = 1;
// capturing current component count to check later
const currentComponentCount = countMounts;
// resetting count
countMounts = 0;
// items widths before layout manager change should be 400
flashList.findAll(CellContainer).forEach((cell) => {
if (cell.props.index !== -1) {
expect(cell.instance.props.style.width).toBe(400);
}
});
// This will cause a layout manager change
flashList.find(ScrollView)?.trigger("onLayout", {
nativeEvent: { layout: { height: 400, width: 900 } },
});
// If counts match, then all components were updated
expect(countMounts).toBe(currentComponentCount);
// items widths after layout manager change should be 900
flashList.findAll(CellContainer).forEach((cell) => {
if (cell.props.index !== -1) {
expect(cell.instance.props.style.width).toBe(900);
}
});
flashList.unmount();
});
it("sends a warning when estimatedItemSize is not set", () => {
const warn = jest.spyOn(console, "warn").mockReturnValue();
const flashList = mountFlashList({
disableDefaultEstimatedItemSize: true,
renderItem: ({ item }) => {item},
});
const layoutData = flashList.instance.state.layoutProvider
.getLayoutManager()!
.getLayouts();
layoutData[0].height = 100;
layoutData[1].height = 200;
layoutData[2].height = 300;
flashList.find(ProgressiveListView)?.instance.onItemLayout(0);
expect(flashList.instance.state.layoutProvider.averageItemSize).toBe(100);
flashList.find(ProgressiveListView)?.instance.onItemLayout(1);
flashList.find(ProgressiveListView)?.instance.onItemLayout(2);
jest.advanceTimersByTime(1000);
const averageItemSize =
flashList.instance.state.layoutProvider.averageItemSize;
expect(warn).toHaveBeenCalledWith(
Warnings.estimatedItemSizeMissingWarning.replace(
"@size",
averageItemSize.toString()
)
);
expect(flashList.instance.state.layoutProvider.averageItemSize).toBe(175);
flashList.unmount();
});
it("clears size warning timeout on unmount", () => {
const warn = jest.spyOn(console, "warn").mockReturnValue();
const flashList = mountFlashList({
disableDefaultEstimatedItemSize: true,
});
flashList.find(ProgressiveListView)?.instance.onItemLayout(0);
flashList.unmount();
jest.advanceTimersByTime(1000);
expect(warn).toBeCalledTimes(0);
});
it("measure size of horizontal list when appropriate", () => {
let flashList = mountFlashList({
data: new Array(1).fill("1"),
horizontal: true,
});
const forceUpdate = jest.spyOn(flashList.instance, "forceUpdate");
// should contain 1 actual text and 1 dummy on mount
expect(flashList.findAll(Text).length).toBe(2);
// Trigger onLoad
flashList.instance["onItemLayout"](0);
jest.advanceTimersByTime(600);
expect(forceUpdate).toBeCalledTimes(1);
// TODO: Investigate why forceUpdate isn't working in tests, forcing an update
flashList.setProps({ overrideItemLayout: () => {} });
// After update the dummy should get removed
expect(flashList.findAll(Text).length).toBe(1);
flashList.unmount();
flashList = mountFlashList({
data: new Array(1).fill("1"),
horizontal: true,
disableHorizontalListHeightMeasurement: true,
});
// should contain 1 actual text as measurement is disabled
expect(flashList.findAll(Text).length).toBe(1);
flashList.unmount();
});
it("cancels post load setTimeout on unmount", () => {
const flashList = mountFlashList({
data: new Array(1).fill("1"),
horizontal: true,
});
const forceUpdate = jest.spyOn(flashList.instance, "forceUpdate");
flashList.instance["onItemLayout"](0);
flashList.unmount();
jest.advanceTimersByTime(600);
expect(forceUpdate).toBeCalledTimes(0);
});
it("uses 250 as draw distance on Android/iOS", () => {
const flashList = mountFlashList();
flashList.find(ProgressiveListView)?.instance.onItemLayout(0);
jest.advanceTimersByTime(1000);
expect(
flashList
.find(ProgressiveListView)
?.instance.getCurrentRenderAheadOffset()
).toBe(250);
flashList.unmount();
});
it("forwards correct renderTarget", () => {
const renderItem = ({ target }: ListRenderItemInfo) => {
return {target};
};
const flashList = mountFlashList({
data: ["0"],
stickyHeaderIndices: [0],
renderItem,
});
expect(flashList.find(Animated.View)?.find(Text)?.props.children).toBe(
RenderTargetOptions.StickyHeader
);
expect(flashList.find(View)?.find(Text)?.props.children).toBe(
RenderTargetOptions.Cell
);
const flashListHorizontal = mountFlashList({
renderItem,
horizontal: true,
});
expect(
flashListHorizontal
.findAllWhere((node: any) => node?.props?.style?.opacity === 0)[0]
.find(Text)?.props.children
).toBe("Measurement");
});
it("force updates items only when renderItem change", () => {
const renderItem = jest.fn(() => Test);
const flashList = mountFlashList({
data: new Array(1).fill("1"),
renderItem,
});
flashList.setProps({ data: new Array(1).fill("1") });
expect(renderItem).toBeCalledTimes(1);
const newRenderItem = jest.fn(() => Test);
flashList.setProps({
data: new Array(1).fill("1"),
renderItem: newRenderItem,
});
expect(newRenderItem).toBeCalledTimes(1);
});
it("forwards disableAutoLayout prop correctly", () => {
const flashList = mountFlashList();
expect(flashList.find(AutoLayoutView)?.props.disableAutoLayout).toBe(
undefined
);
flashList.setProps({ disableAutoLayout: true });
expect(flashList.find(AutoLayoutView)?.props.disableAutoLayout).toBe(true);
});
it("computes correct scrollTo offset when view position is specified", () => {
const flashList = mountFlashList({
data: new Array(40).fill(1).map((_, index) => {
return index.toString();
}),
});
const plv = flashList.find(ProgressiveListView)
?.instance as ProgressiveListView;
const scrollToOffset = jest.spyOn(plv, "scrollToOffset");
flashList.instance.scrollToIndex({ index: 10, viewPosition: 0.5 });
// Each item is 200px in height and to position it in the middle of the window (900 x 400), its offset needs to be
// reduced by 350px. That gives us 1650. Other test cases follow the same logic.
expect(scrollToOffset).toBeCalledWith(1650, 1650, false, true);
flashList.instance.scrollToItem({
item: "10",
viewPosition: 0.5,
});
expect(scrollToOffset).toBeCalledWith(1650, 1650, false, true);
flashList.setProps({ horizontal: true });
flashList.instance.scrollToItem({
item: "10",
viewPosition: 0.5,
});
expect(scrollToOffset).toBeCalledWith(1900, 1900, false, true);
flashList.unmount();
});
it("computes correct scrollTo offset when view offset is specified", () => {
const flashList = mountFlashList({
data: new Array(40).fill(1).map((_, index) => {
return index.toString();
}),
});
const plv = flashList.find(ProgressiveListView)
?.instance as ProgressiveListView;
const scrollToOffset = jest.spyOn(plv, "scrollToOffset");
// Each item is 200px in height and to position it in the middle of the window (900 x 400), it's offset needs to be
// reduced by 350px + 100px offset. That gives us 1550. Other test cases follow the same logic.
flashList.instance.scrollToIndex({
index: 10,
viewPosition: 0.5,
viewOffset: 100,
});
expect(scrollToOffset).toBeCalledWith(1550, 1550, false, true);
flashList.setProps({ horizontal: true });
flashList.instance.scrollToItem({
item: "10",
viewPosition: 0.5,
viewOffset: 100,
});
expect(scrollToOffset).toBeCalledWith(1800, 1800, false, true);
flashList.unmount();
});
it("applies horizontal content container padding for vertical list", () => {
const flashList = mountFlashList({
numColumns: 4,
contentContainerStyle: { paddingHorizontal: 10 },
});
let hasLayoutItems = false;
flashList.instance.state.layoutProvider
.getLayoutManager()!
.getLayouts()
.forEach((layout) => {
hasLayoutItems = true;
expect(layout.width).toBe(95);
});
expect(hasLayoutItems).toBe(true);
flashList.unmount();
});
it("applies vertical content container padding for horizontal list", () => {
const flashList = mountFlashList({
horizontal: true,
contentContainerStyle: { paddingVertical: 10 },
});
let hasLayoutItems = false;
flashList.instance.state.layoutProvider
.getLayoutManager()!
.getLayouts()
.forEach((layout) => {
hasLayoutItems = true;
expect(layout.height).toBe(880);
});
expect(hasLayoutItems).toBe(true);
flashList.unmount();
});
it("warns if rendered size is too small but only when it remain small for a duration", () => {
const flashList = mountFlashList({
data: new Array(1).fill("1"),
});
const warn = jest.spyOn(console, "warn").mockReturnValue();
const triggerLayout = (height: number, time: number) => {
flashList.find(ScrollView)?.trigger("onLayout", {
nativeEvent: { layout: { height, width: 900 } },
});
jest.advanceTimersByTime(time);
};
triggerLayout(0, 500);
triggerLayout(100, 1000);
triggerLayout(0, 1200);
expect(warn).toHaveBeenCalledTimes(1);
triggerLayout(100, 500);
triggerLayout(0, 500);
flashList.unmount();
jest.advanceTimersByTime(1200);
expect(warn).toHaveBeenCalledTimes(1);
});
});