@shopify/flash-list 1.2.2 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/dist/FlashList.d.ts +1 -1
  3. package/dist/FlashList.d.ts.map +1 -1
  4. package/dist/FlashList.js +8 -4
  5. package/dist/FlashList.js.map +1 -1
  6. package/dist/MasonryFlashList.d.ts +39 -0
  7. package/dist/MasonryFlashList.d.ts.map +1 -0
  8. package/dist/MasonryFlashList.js +241 -0
  9. package/dist/MasonryFlashList.js.map +1 -0
  10. package/dist/__tests__/MasonryFlashList.test.d.ts +2 -0
  11. package/dist/__tests__/MasonryFlashList.test.d.ts.map +1 -0
  12. package/dist/__tests__/MasonryFlashList.test.js +205 -0
  13. package/dist/__tests__/MasonryFlashList.test.js.map +1 -0
  14. package/dist/__tests__/helpers/mountMasonryFlashList.d.ts +18 -0
  15. package/dist/__tests__/helpers/mountMasonryFlashList.d.ts.map +1 -0
  16. package/dist/__tests__/helpers/mountMasonryFlashList.js +44 -0
  17. package/dist/__tests__/helpers/mountMasonryFlashList.js.map +1 -0
  18. package/dist/errors/ExceptionList.d.ts +4 -0
  19. package/dist/errors/ExceptionList.d.ts.map +1 -1
  20. package/dist/errors/ExceptionList.js +4 -0
  21. package/dist/errors/ExceptionList.js.map +1 -1
  22. package/dist/index.d.ts +1 -0
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +3 -1
  25. package/dist/index.js.map +1 -1
  26. package/dist/tsconfig.tsbuildinfo +1 -1
  27. package/dist/viewability/ViewabilityManager.d.ts.map +1 -1
  28. package/dist/viewability/ViewabilityManager.js +3 -3
  29. package/dist/viewability/ViewabilityManager.js.map +1 -1
  30. package/package.json +1 -1
  31. package/src/FlashList.tsx +10 -5
  32. package/src/MasonryFlashList.tsx +439 -0
  33. package/src/__tests__/MasonryFlashList.test.ts +235 -0
  34. package/src/__tests__/helpers/mountMasonryFlashList.tsx +65 -0
  35. package/src/errors/ExceptionList.ts +5 -0
  36. package/src/index.ts +7 -0
  37. package/src/viewability/ViewabilityManager.ts +2 -1
@@ -0,0 +1,235 @@
1
+ import { ScrollView, Text, View } from "react-native";
2
+ import "@quilted/react-testing/matchers";
3
+ import { ProgressiveListView } from "recyclerlistview";
4
+ import React from "react";
5
+
6
+ import {
7
+ MasonryFlashListProps,
8
+ MasonryFlashListRef,
9
+ } from "../MasonryFlashList";
10
+ import FlashList from "../FlashList";
11
+
12
+ import { mountMasonryFlashList } from "./helpers/mountMasonryFlashList";
13
+
14
+ describe("MasonryFlashList", () => {
15
+ beforeEach(() => {
16
+ jest.clearAllMocks();
17
+ jest.useFakeTimers();
18
+ });
19
+
20
+ it("renders items and has 3 internal lists", () => {
21
+ const masonryFlashList = mountMasonryFlashList();
22
+ expect(masonryFlashList.findAll(ProgressiveListView).length).toBe(3);
23
+ expect(masonryFlashList).toContainReactComponent(Text, { children: "One" });
24
+ expect(masonryFlashList).toContainReactComponent(ProgressiveListView, {
25
+ isHorizontal: false,
26
+ });
27
+ masonryFlashList.unmount();
28
+ });
29
+ it("raised onLoad event only when first internal child mounts", () => {
30
+ const onLoadMock = jest.fn();
31
+ const ref = React.createRef<MasonryFlashListRef<string>>();
32
+ const masonryFlashList = mountMasonryFlashList(
33
+ {
34
+ onLoad: onLoadMock,
35
+ },
36
+ ref
37
+ );
38
+ expect(onLoadMock).not.toHaveBeenCalled();
39
+ masonryFlashList.findAll(ProgressiveListView)[1]?.instance.onItemLayout(0);
40
+ expect(onLoadMock).toHaveBeenCalledTimes(1);
41
+
42
+ // on load shouldn't be passed to wrapper list
43
+ expect((ref.current as FlashList<string>).props.onLoad).toBeUndefined();
44
+ masonryFlashList.unmount();
45
+ });
46
+ it("can resize columns using getColumnFlex", () => {
47
+ const masonryFlashList = mountMasonryFlashList({
48
+ getColumnFlex: (_, column) => (column === 0 ? 1 : 3),
49
+ });
50
+ const progressiveListView =
51
+ masonryFlashList.find(ProgressiveListView)!.instance;
52
+ expect(progressiveListView.getLayout(0).width).toBe(100);
53
+ expect(progressiveListView.getLayout(1).width).toBe(300);
54
+
55
+ expect(masonryFlashList.findAll(ProgressiveListView).length).toBe(3);
56
+ masonryFlashList.findAll(ProgressiveListView).forEach((plv, index) => {
57
+ if (index === 1) {
58
+ expect(plv.instance.props.layoutSize.width).toBe(100);
59
+ }
60
+ if (index === 2) {
61
+ expect(plv.instance.props.layoutSize.width).toBe(300);
62
+ }
63
+ });
64
+ masonryFlashList.unmount();
65
+ });
66
+ it("mounts a single ScrollView", () => {
67
+ const masonryFlashList = mountMasonryFlashList();
68
+ expect(masonryFlashList.findAll(ScrollView)).toHaveLength(1);
69
+ masonryFlashList.unmount();
70
+ });
71
+ it("forwards single onScroll event to external listener", () => {
72
+ const onScrollMock = jest.fn();
73
+ const masonryFlashList = mountMasonryFlashList({
74
+ onScroll: onScrollMock,
75
+ });
76
+ masonryFlashList.find(ScrollView)?.instance.props.onScroll({
77
+ nativeEvent: { contentOffset: { x: 0, y: 0 } },
78
+ });
79
+ expect(onScrollMock).toHaveBeenCalledTimes(1);
80
+ masonryFlashList.unmount();
81
+ });
82
+ it("updates scroll offset of all internal lists", () => {
83
+ const onScrollMock = jest.fn();
84
+ const masonryFlashList = mountMasonryFlashList({
85
+ onScroll: onScrollMock,
86
+ });
87
+ masonryFlashList.find(ScrollView)?.instance.props.onScroll({
88
+ nativeEvent: { contentOffset: { x: 0, y: 100 } },
89
+ });
90
+ masonryFlashList.findAll(ProgressiveListView).forEach((list) => {
91
+ expect(list.instance.getCurrentScrollOffset()).toBe(100);
92
+ });
93
+ masonryFlashList.unmount();
94
+ });
95
+ it("has a valid ref object", () => {
96
+ const ref = React.createRef<MasonryFlashListRef<string>>();
97
+ const masonryFlashList = mountMasonryFlashList({}, ref);
98
+ expect(ref.current).toBeDefined();
99
+ masonryFlashList.unmount();
100
+ });
101
+ it("forwards overrideItemLayout to internal lists", () => {
102
+ const overrideItemLayout = jest.fn((layout) => {
103
+ layout.size = 300;
104
+ });
105
+ const masonryFlashList = mountMasonryFlashList({
106
+ overrideItemLayout,
107
+ });
108
+ expect(masonryFlashList.findAll(ProgressiveListView).length).toBe(3);
109
+ masonryFlashList.findAll(ProgressiveListView).forEach((list, index) => {
110
+ if (index !== 0) {
111
+ expect(list.instance.getLayout(0).height).toBe(300);
112
+ }
113
+ });
114
+ masonryFlashList.unmount();
115
+ });
116
+ it("forwards keyExtractor to internal list", () => {
117
+ const keyExtractor = (_: string, index: number) => (index + 1).toString();
118
+ const masonryFlashList = mountMasonryFlashList({
119
+ keyExtractor,
120
+ });
121
+ expect(masonryFlashList.findAll(ProgressiveListView).length).toBe(3);
122
+ expect(
123
+ masonryFlashList
124
+ .findAll(ProgressiveListView)[0]
125
+ .instance.props.dataProvider.getStableId(0)
126
+ ).toBe("0");
127
+ expect(
128
+ masonryFlashList
129
+ .findAll(ProgressiveListView)[1]
130
+ .instance.props.dataProvider.getStableId(0)
131
+ ).toBe("1");
132
+ expect(
133
+ masonryFlashList
134
+ .findAll(ProgressiveListView)[2]
135
+ .instance.props.dataProvider.getStableId(0)
136
+ ).toBe("2");
137
+ masonryFlashList.unmount();
138
+ });
139
+ it("correctly maps list indices to actual indices", () => {
140
+ const data = new Array(20).fill(0).map((_, index) => index.toString());
141
+ const getItemType = (item: string, index: number) => {
142
+ expect(index.toString()).toBe(item);
143
+ return 0;
144
+ };
145
+ const renderItem: MasonryFlashListProps<string>["renderItem"] = ({
146
+ item,
147
+ index,
148
+ }) => {
149
+ expect(index.toString()).toBe(item);
150
+ return null;
151
+ };
152
+ const overrideItemLayout: MasonryFlashListProps<string>["overrideItemLayout"] =
153
+ (layout, item: string, index: number) => {
154
+ expect(index.toString()).toBe(item);
155
+ };
156
+ const keyExtractor = (item: string, index: number) => {
157
+ expect(index.toString()).toBe(item);
158
+ return index.toString();
159
+ };
160
+ const onViewableItemsChanged: MasonryFlashListProps<string>["onViewableItemsChanged"] =
161
+ (info) => {
162
+ info.viewableItems.forEach((viewToken) => {
163
+ expect(viewToken.index?.toString()).toBe(viewToken.item);
164
+ });
165
+ };
166
+
167
+ const masonryFlashList = mountMasonryFlashList({
168
+ data,
169
+ renderItem,
170
+ getItemType,
171
+ overrideItemLayout,
172
+ keyExtractor,
173
+ onViewableItemsChanged,
174
+ });
175
+ jest.advanceTimersByTime(1000);
176
+ masonryFlashList.unmount();
177
+ });
178
+ it("internal list height should be derived from the parent and width from itself", () => {
179
+ const masonryFlashList = mountMasonryFlashList({
180
+ testID: "MasonryProxyScrollView",
181
+ });
182
+ expect(masonryFlashList.findAll(ProgressiveListView).length).toBe(3);
183
+ masonryFlashList.findAll(View).forEach((view: any) => {
184
+ view.props?.onLayout?.({
185
+ nativeEvent: { layout: { width: 500, height: 500 } },
186
+ });
187
+ });
188
+ masonryFlashList.findAll(ProgressiveListView).forEach((list, index) => {
189
+ if (index !== 0) {
190
+ expect(list.instance.getRenderedSize().width).toBe(500);
191
+ expect(list.instance.getRenderedSize().height).toBe(900);
192
+ }
193
+ });
194
+ masonryFlashList.unmount();
195
+ });
196
+ it("can optimize item arrangement", () => {
197
+ const columnCount = 3;
198
+ const data = new Array(999).fill(null).map((_, index) => {
199
+ return "1";
200
+ });
201
+ const masonryFlashList = mountMasonryFlashList({
202
+ data,
203
+ optimizeItemArrangement: true,
204
+ numColumns: columnCount,
205
+ overrideItemLayout(layout, _, index, __, ___?) {
206
+ layout.size = ((index * 10) % 100) + 100 / ((index % columnCount) + 1);
207
+ },
208
+ });
209
+ expect(masonryFlashList.findAll(ProgressiveListView).length).toBe(4);
210
+
211
+ // I've verified that the following values are correct by observing the algorithm in action
212
+ // Captured values will help prevent regression in the future
213
+ expect(
214
+ Math.floor(
215
+ masonryFlashList
216
+ .findAll(ProgressiveListView)[1]
217
+ .instance.getContentDimension().height
218
+ )
219
+ ).toBe(35306);
220
+ expect(
221
+ Math.floor(
222
+ masonryFlashList
223
+ .findAll(ProgressiveListView)[2]
224
+ .instance.getContentDimension().height
225
+ )
226
+ ).toBe(35313);
227
+ expect(
228
+ Math.floor(
229
+ masonryFlashList
230
+ .findAll(ProgressiveListView)[3]
231
+ .instance.getContentDimension().height
232
+ )
233
+ ).toBe(35339);
234
+ });
235
+ });
@@ -0,0 +1,65 @@
1
+ import React from "react";
2
+ import { Text } from "react-native";
3
+ import "@quilted/react-testing/matchers";
4
+ import { mount, Root } from "@quilted/react-testing";
5
+
6
+ import { ListRenderItem } from "../../FlashListProps";
7
+ import {
8
+ MasonryFlashList,
9
+ MasonryFlashListProps,
10
+ MasonryFlashListRef,
11
+ } from "../../MasonryFlashList";
12
+
13
+ jest.mock("../../FlashList", () => {
14
+ const ActualFlashList = jest.requireActual("../../FlashList").default;
15
+ class MockFlashList extends ActualFlashList {
16
+ componentDidMount() {
17
+ super.componentDidMount();
18
+ this.rlvRef?._scrollComponent?._scrollViewRef?.props.onLayout({
19
+ nativeEvent: { layout: { height: 900, width: 400 } },
20
+ });
21
+ }
22
+ }
23
+ return MockFlashList;
24
+ });
25
+
26
+ export type MockMasonryFlashListProps = Omit<
27
+ MasonryFlashListProps<string>,
28
+ "estimatedItemSize" | "data" | "renderItem"
29
+ > & {
30
+ estimatedItemSize?: number;
31
+ data?: string[];
32
+ renderItem?: ListRenderItem<string>;
33
+ };
34
+
35
+ /**
36
+ * Helper to mount MasonryFlashList for testing.
37
+ */
38
+ export const mountMasonryFlashList = (
39
+ props?: MockMasonryFlashListProps,
40
+ ref?: React.RefObject<MasonryFlashListRef<string>>
41
+ ) => {
42
+ const flashList = mount(renderMasonryFlashList(props, ref)) as Omit<
43
+ Root<MasonryFlashListProps<string>>,
44
+ "instance"
45
+ > & {
46
+ instance: MasonryFlashListRef<string>;
47
+ };
48
+ return flashList;
49
+ };
50
+
51
+ export function renderMasonryFlashList(
52
+ props?: MockMasonryFlashListProps,
53
+ ref?: React.RefObject<MasonryFlashListRef<string>>
54
+ ) {
55
+ return (
56
+ <MasonryFlashList
57
+ {...props}
58
+ ref={ref}
59
+ numColumns={props?.numColumns ?? 2}
60
+ renderItem={props?.renderItem || (({ item }) => <Text>{item}</Text>)}
61
+ estimatedItemSize={props?.estimatedItemSize ?? 200}
62
+ data={props?.data || ["One", "Two", "Three", "Four"]}
63
+ />
64
+ );
65
+ }
@@ -19,5 +19,10 @@ const ExceptionList = {
19
19
  "You can set exactly one of itemVisiblePercentThreshold or viewAreaCoveragePercentThreshold. Specifying both is not supported.",
20
20
  type: "MultipleViewabilityThresholdTypesException",
21
21
  },
22
+ overrideItemLayoutRequiredForMasonryOptimization: {
23
+ message:
24
+ "optimizeItemArrangement has been enabled on `MasonryFlashList` but overrideItemLayout is not set.",
25
+ type: "InvariantViolation",
26
+ },
22
27
  };
23
28
  export default ExceptionList;
package/src/index.ts CHANGED
@@ -28,6 +28,13 @@ export {
28
28
  BlankAreaTrackerResult,
29
29
  BlankAreaTrackerConfig,
30
30
  } from "./benchmark/useBlankAreaTracker";
31
+ export {
32
+ MasonryFlashList,
33
+ MasonryFlashListProps,
34
+ MasonryFlashListScrollEvent,
35
+ MasonryFlashListRef,
36
+ MasonryListItem,
37
+ } from "./MasonryFlashList";
31
38
  export { JSFPSMonitor, JSFPSResult } from "./benchmark/JSFPSMonitor";
32
39
  export { autoScroll, Cancellable } from "./benchmark/AutoScrollHelper";
33
40
  export { default as ViewToken } from "./viewability/ViewToken";
@@ -68,7 +68,8 @@ export default class ViewabilityManager<T> {
68
68
 
69
69
  public updateViewableItems = (newViewableIndices?: number[]) => {
70
70
  const listSize =
71
- this.flashListRef.recyclerlistview_unsafe?.getRenderedSize();
71
+ this.flashListRef.recyclerlistview_unsafe?.getRenderedSize() ??
72
+ this.flashListRef.props.estimatedListSize;
72
73
  if (listSize === undefined || !this.shouldListenToVisibleIndices) {
73
74
  return;
74
75
  }