@shopify/flash-list 1.2.2 → 1.3.1

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 +12 -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 +51 -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 +226 -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 +459 -0
  33. package/src/__tests__/MasonryFlashList.test.ts +264 -0
  34. package/src/__tests__/helpers/mountMasonryFlashList.tsx +65 -0
  35. package/src/errors/ExceptionList.ts +5 -0
  36. package/src/index.ts +9 -0
  37. package/src/viewability/ViewabilityManager.ts +2 -1
@@ -0,0 +1,264 @@
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("invokes renderItem with columnIndex and columnSpan", () => {
30
+ const mockRenderItem = jest.fn(() => null);
31
+ const masonryFlashList = mountMasonryFlashList({
32
+ renderItem: mockRenderItem,
33
+ data: ["One", "Two", "Three"],
34
+ numColumns: 3,
35
+ });
36
+ expect(mockRenderItem).toHaveBeenCalledWith(
37
+ expect.objectContaining({
38
+ columnIndex: 0,
39
+ columnSpan: 1,
40
+ })
41
+ );
42
+
43
+ expect(mockRenderItem).toHaveBeenCalledWith(
44
+ expect.objectContaining({
45
+ columnIndex: 1,
46
+ columnSpan: 1,
47
+ })
48
+ );
49
+
50
+ expect(mockRenderItem).toHaveBeenCalledWith(
51
+ expect.objectContaining({
52
+ columnSpan: 1,
53
+ columnIndex: 2,
54
+ })
55
+ );
56
+ masonryFlashList.unmount();
57
+ });
58
+ it("raised onLoad event only when first internal child mounts", () => {
59
+ const onLoadMock = jest.fn();
60
+ const ref = React.createRef<MasonryFlashListRef<string>>();
61
+ const masonryFlashList = mountMasonryFlashList(
62
+ {
63
+ onLoad: onLoadMock,
64
+ },
65
+ ref
66
+ );
67
+ expect(onLoadMock).not.toHaveBeenCalled();
68
+ masonryFlashList.findAll(ProgressiveListView)[1]?.instance.onItemLayout(0);
69
+ expect(onLoadMock).toHaveBeenCalledTimes(1);
70
+
71
+ // on load shouldn't be passed to wrapper list
72
+ expect((ref.current as FlashList<string>).props.onLoad).toBeUndefined();
73
+ masonryFlashList.unmount();
74
+ });
75
+ it("can resize columns using getColumnFlex", () => {
76
+ const masonryFlashList = mountMasonryFlashList({
77
+ getColumnFlex: (_, column) => (column === 0 ? 1 : 3),
78
+ });
79
+ const progressiveListView =
80
+ masonryFlashList.find(ProgressiveListView)!.instance;
81
+ expect(progressiveListView.getLayout(0).width).toBe(100);
82
+ expect(progressiveListView.getLayout(1).width).toBe(300);
83
+
84
+ expect(masonryFlashList.findAll(ProgressiveListView).length).toBe(3);
85
+ masonryFlashList.findAll(ProgressiveListView).forEach((plv, index) => {
86
+ if (index === 1) {
87
+ expect(plv.instance.props.layoutSize.width).toBe(100);
88
+ }
89
+ if (index === 2) {
90
+ expect(plv.instance.props.layoutSize.width).toBe(300);
91
+ }
92
+ });
93
+ masonryFlashList.unmount();
94
+ });
95
+ it("mounts a single ScrollView", () => {
96
+ const masonryFlashList = mountMasonryFlashList();
97
+ expect(masonryFlashList.findAll(ScrollView)).toHaveLength(1);
98
+ masonryFlashList.unmount();
99
+ });
100
+ it("forwards single onScroll event to external listener", () => {
101
+ const onScrollMock = jest.fn();
102
+ const masonryFlashList = mountMasonryFlashList({
103
+ onScroll: onScrollMock,
104
+ });
105
+ masonryFlashList.find(ScrollView)?.instance.props.onScroll({
106
+ nativeEvent: { contentOffset: { x: 0, y: 0 } },
107
+ });
108
+ expect(onScrollMock).toHaveBeenCalledTimes(1);
109
+ masonryFlashList.unmount();
110
+ });
111
+ it("updates scroll offset of all internal lists", () => {
112
+ const onScrollMock = jest.fn();
113
+ const masonryFlashList = mountMasonryFlashList({
114
+ onScroll: onScrollMock,
115
+ });
116
+ masonryFlashList.find(ScrollView)?.instance.props.onScroll({
117
+ nativeEvent: { contentOffset: { x: 0, y: 100 } },
118
+ });
119
+ masonryFlashList.findAll(ProgressiveListView).forEach((list) => {
120
+ expect(list.instance.getCurrentScrollOffset()).toBe(100);
121
+ });
122
+ masonryFlashList.unmount();
123
+ });
124
+ it("has a valid ref object", () => {
125
+ const ref = React.createRef<MasonryFlashListRef<string>>();
126
+ const masonryFlashList = mountMasonryFlashList({}, ref);
127
+ expect(ref.current).toBeDefined();
128
+ masonryFlashList.unmount();
129
+ });
130
+ it("forwards overrideItemLayout to internal lists", () => {
131
+ const overrideItemLayout = jest.fn((layout) => {
132
+ layout.size = 300;
133
+ });
134
+ const masonryFlashList = mountMasonryFlashList({
135
+ overrideItemLayout,
136
+ });
137
+ expect(masonryFlashList.findAll(ProgressiveListView).length).toBe(3);
138
+ masonryFlashList.findAll(ProgressiveListView).forEach((list, index) => {
139
+ if (index !== 0) {
140
+ expect(list.instance.getLayout(0).height).toBe(300);
141
+ }
142
+ });
143
+ masonryFlashList.unmount();
144
+ });
145
+ it("forwards keyExtractor to internal list", () => {
146
+ const keyExtractor = (_: string, index: number) => (index + 1).toString();
147
+ const masonryFlashList = mountMasonryFlashList({
148
+ keyExtractor,
149
+ });
150
+ expect(masonryFlashList.findAll(ProgressiveListView).length).toBe(3);
151
+ expect(
152
+ masonryFlashList
153
+ .findAll(ProgressiveListView)[0]
154
+ .instance.props.dataProvider.getStableId(0)
155
+ ).toBe("0");
156
+ expect(
157
+ masonryFlashList
158
+ .findAll(ProgressiveListView)[1]
159
+ .instance.props.dataProvider.getStableId(0)
160
+ ).toBe("1");
161
+ expect(
162
+ masonryFlashList
163
+ .findAll(ProgressiveListView)[2]
164
+ .instance.props.dataProvider.getStableId(0)
165
+ ).toBe("2");
166
+ masonryFlashList.unmount();
167
+ });
168
+ it("correctly maps list indices to actual indices", () => {
169
+ const data = new Array(20).fill(0).map((_, index) => index.toString());
170
+ const getItemType = (item: string, index: number) => {
171
+ expect(index.toString()).toBe(item);
172
+ return 0;
173
+ };
174
+ const renderItem: MasonryFlashListProps<string>["renderItem"] = ({
175
+ item,
176
+ index,
177
+ }) => {
178
+ expect(index.toString()).toBe(item);
179
+ return null;
180
+ };
181
+ const overrideItemLayout: MasonryFlashListProps<string>["overrideItemLayout"] =
182
+ (layout, item: string, index: number) => {
183
+ expect(index.toString()).toBe(item);
184
+ };
185
+ const keyExtractor = (item: string, index: number) => {
186
+ expect(index.toString()).toBe(item);
187
+ return index.toString();
188
+ };
189
+ const onViewableItemsChanged: MasonryFlashListProps<string>["onViewableItemsChanged"] =
190
+ (info) => {
191
+ info.viewableItems.forEach((viewToken) => {
192
+ expect(viewToken.index?.toString()).toBe(viewToken.item);
193
+ });
194
+ };
195
+
196
+ const masonryFlashList = mountMasonryFlashList({
197
+ data,
198
+ renderItem,
199
+ getItemType,
200
+ overrideItemLayout,
201
+ keyExtractor,
202
+ onViewableItemsChanged,
203
+ });
204
+ jest.advanceTimersByTime(1000);
205
+ masonryFlashList.unmount();
206
+ });
207
+ it("internal list height should be derived from the parent and width from itself", () => {
208
+ const masonryFlashList = mountMasonryFlashList({
209
+ testID: "MasonryProxyScrollView",
210
+ });
211
+ expect(masonryFlashList.findAll(ProgressiveListView).length).toBe(3);
212
+ masonryFlashList.findAll(View).forEach((view: any) => {
213
+ view.props?.onLayout?.({
214
+ nativeEvent: { layout: { width: 500, height: 500 } },
215
+ });
216
+ });
217
+ masonryFlashList.findAll(ProgressiveListView).forEach((list, index) => {
218
+ if (index !== 0) {
219
+ expect(list.instance.getRenderedSize().width).toBe(500);
220
+ expect(list.instance.getRenderedSize().height).toBe(900);
221
+ }
222
+ });
223
+ masonryFlashList.unmount();
224
+ });
225
+ it("can optimize item arrangement", () => {
226
+ const columnCount = 3;
227
+ const data = new Array(999).fill(null).map((_, index) => {
228
+ return "1";
229
+ });
230
+ const masonryFlashList = mountMasonryFlashList({
231
+ data,
232
+ optimizeItemArrangement: true,
233
+ numColumns: columnCount,
234
+ overrideItemLayout(layout, _, index, __, ___?) {
235
+ layout.size = ((index * 10) % 100) + 100 / ((index % columnCount) + 1);
236
+ },
237
+ });
238
+ expect(masonryFlashList.findAll(ProgressiveListView).length).toBe(4);
239
+
240
+ // I've verified that the following values are correct by observing the algorithm in action
241
+ // Captured values will help prevent regression in the future
242
+ expect(
243
+ Math.floor(
244
+ masonryFlashList
245
+ .findAll(ProgressiveListView)[1]
246
+ .instance.getContentDimension().height
247
+ )
248
+ ).toBe(35306);
249
+ expect(
250
+ Math.floor(
251
+ masonryFlashList
252
+ .findAll(ProgressiveListView)[2]
253
+ .instance.getContentDimension().height
254
+ )
255
+ ).toBe(35313);
256
+ expect(
257
+ Math.floor(
258
+ masonryFlashList
259
+ .findAll(ProgressiveListView)[3]
260
+ .instance.getContentDimension().height
261
+ )
262
+ ).toBe(35339);
263
+ });
264
+ });
@@ -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,15 @@ 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
+ MasonryListRenderItem,
38
+ MasonryListRenderItemInfo,
39
+ } from "./MasonryFlashList";
31
40
  export { JSFPSMonitor, JSFPSResult } from "./benchmark/JSFPSMonitor";
32
41
  export { autoScroll, Cancellable } from "./benchmark/AutoScrollHelper";
33
42
  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
  }