/
VariableSizeList.js
298 lines (278 loc) · 11.5 KB
/
VariableSizeList.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
import React, { PureComponent } from 'react';
import ReactTestRenderer from 'react-test-renderer';
import { VariableSizeList } from '..';
const findScrollContainer = rendered => rendered.root.children[0].children[0];
describe('VariableSizeList', () => {
let itemRenderer, itemSize, defaultProps, onItemsRendered;
// Use PureComponent to test memoization.
// Pass through to itemRenderer mock for easier test assertions.
class PureItemRenderer extends PureComponent {
render() {
return itemRenderer(this.props);
}
}
beforeEach(() => {
jest.useFakeTimers();
itemRenderer = jest.fn(({ style, ...rest }) => (
<div style={style}>{JSON.stringify(rest, null, 2)}</div>
));
itemSize = jest.fn(index => 25 + index);
onItemsRendered = jest.fn();
defaultProps = {
children: PureItemRenderer,
estimatedItemSize: 25,
height: 100,
itemCount: 20,
itemSize,
onItemsRendered,
width: 50,
};
});
// Much of the shared List functionality is already tested by FixedSizeList tests.
// This test covers functionality that is unique to VariableSizeList.
it('should render an empty list', () => {
ReactTestRenderer.create(
<VariableSizeList {...defaultProps} itemCount={0} />
);
expect(itemSize).not.toHaveBeenCalled();
expect(itemRenderer).not.toHaveBeenCalled();
expect(onItemsRendered).not.toHaveBeenCalled();
});
it('changing itemSize does not impact the rendered items', () => {
const rendered = ReactTestRenderer.create(
<VariableSizeList {...defaultProps} />
);
itemRenderer.mockClear();
rendered.update(
<VariableSizeList
{...defaultProps}
itemSize={index => 50}
onItemsRendered={onItemsRendered}
/>
);
expect(itemRenderer).not.toHaveBeenCalled();
});
describe('estimatedItemSize', () => {
it('should estimate an initial scrollable size based on this value', () => {
const itemSize = jest.fn(() => 25);
const rendered = ReactTestRenderer.create(
<VariableSizeList
{...defaultProps}
estimatedItemSize={50}
height={100}
itemCount={100}
itemSize={itemSize}
overscanCount={0}
/>
);
// We'll render 5 rows initially, each at 25px tall (125px total).
// The remaining 95 rows will be estimated at 50px tall (4,750px total).
// This means an initial height estimate of 4,875px.
expect(itemSize).toHaveBeenCalledTimes(5);
const scrollContainer = findScrollContainer(rendered);
expect(scrollContainer.props.style.height).toEqual(4875);
});
it('should udpate the scrollable size as more items are measured', () => {
const itemSize = jest.fn(() => 25);
const rendered = ReactTestRenderer.create(
<VariableSizeList
{...defaultProps}
estimatedItemSize={50}
itemCount={100}
itemSize={itemSize}
overscanCount={0}
/>
);
rendered.getInstance().scrollToItem(18);
// Including the additional 1 (minimum) overscan row,
// We've now measured 20 rows, each at 25px tall (500px total).
// The remaining 80 rows will be estimated at 50px tall (4,500px total).
// This means an updated height estimate of 4,500px.
expect(itemSize).toHaveBeenCalledTimes(20);
const scrollContainer = findScrollContainer(rendered);
expect(scrollContainer.props.style.height).toEqual(4500);
});
});
describe('scrollToItem method', () => {
it('should not set invalid offsets when the list contains few items', () => {
const onScroll = jest.fn();
const rendered = ReactTestRenderer.create(
<VariableSizeList {...defaultProps} itemCount={3} onScroll={onScroll} />
);
onScroll.mockClear();
// Offset should not be negative.
rendered.getInstance().scrollToItem(0);
expect(onScroll).toHaveBeenCalledWith({
scrollDirection: 'backward',
scrollOffset: 0,
scrollUpdateWasRequested: true,
});
});
it('should scroll to the correct item for align = "auto"', () => {
const onItemsRendered = jest.fn();
const rendered = ReactTestRenderer.create(
<VariableSizeList {...defaultProps} onItemsRendered={onItemsRendered} />
);
// Scroll down enough to show item 10 at the bottom.
rendered.getInstance().scrollToItem(10, 'auto');
// No need to scroll again; item 9 is already visible.
// Overscan indices will change though, since direction changes.
rendered.getInstance().scrollToItem(9, 'auto');
// Scroll up enough to show item 2 at the top.
rendered.getInstance().scrollToItem(2, 'auto');
expect(onItemsRendered.mock.calls).toMatchSnapshot();
});
it('should scroll to the correct item for align = "start"', () => {
const onItemsRendered = jest.fn();
const rendered = ReactTestRenderer.create(
<VariableSizeList {...defaultProps} onItemsRendered={onItemsRendered} />
);
// Scroll down enough to show item 10 at the top.
rendered.getInstance().scrollToItem(10, 'start');
// Scroll back up so that item 9 is at the top.
// Overscroll direction wil change too.
rendered.getInstance().scrollToItem(9, 'start');
// Item 19 can't align at the top because there aren't enough items.
// Scroll down as far as possible though.
// Overscroll direction wil change again.
rendered.getInstance().scrollToItem(19, 'start');
expect(onItemsRendered.mock.calls).toMatchSnapshot();
});
it('should scroll to the correct item for align = "end"', () => {
const onItemsRendered = jest.fn();
const rendered = ReactTestRenderer.create(
<VariableSizeList {...defaultProps} onItemsRendered={onItemsRendered} />
);
// Scroll down enough to show item 10 at the bottom.
rendered.getInstance().scrollToItem(10, 'end');
// Scroll back up so that item 9 is at the bottom.
// Overscroll direction wil change too.
rendered.getInstance().scrollToItem(9, 'end');
// Item 1 can't align at the bottom because it's too close to the beginning.
// Scroll up as far as possible though.
// Overscroll direction wil change again.
rendered.getInstance().scrollToItem(1, 'end');
expect(onItemsRendered.mock.calls).toMatchSnapshot();
});
it('should scroll to the correct item for align = "center"', () => {
const onItemsRendered = jest.fn();
const rendered = ReactTestRenderer.create(
<VariableSizeList {...defaultProps} onItemsRendered={onItemsRendered} />
);
// Scroll down enough to show item 10 in the middle.
rendered.getInstance().scrollToItem(10, 'center');
// Scroll back up so that item 9 is in the middle.
// Overscroll direction wil change too.
rendered.getInstance().scrollToItem(9, 'center');
// Item 1 can't align in the middle because it's too close to the beginning.
// Scroll up as far as possible though.
// Overscroll direction wil change again.
rendered.getInstance().scrollToItem(1, 'center');
expect(onItemsRendered.mock.calls).toMatchSnapshot();
// Item 19 can't align in the middle because it's too close to the end.
// Scroll down as far as possible though.
// Overscroll direction wil change again.
rendered.getInstance().scrollToItem(19, 'center');
expect(onItemsRendered.mock.calls).toMatchSnapshot();
});
});
describe('resetAfterIndex method', () => {
it('should recalculate the estimated total size', () => {
const itemSize = jest.fn(() => 75);
const rendered = ReactTestRenderer.create(
<VariableSizeList {...defaultProps} itemSize={index => 25} />
);
rendered.getInstance().scrollToItem(19);
// We've measured every item initially.
const scrollContainer = findScrollContainer(rendered);
expect(scrollContainer.props.style.height).toEqual(500);
// Supplying a new itemSize alone should not impact anything.
rendered.update(
<VariableSizeList {...defaultProps} itemSize={itemSize} />
);
expect(scrollContainer.props.style.height).toEqual(500);
// Reset styles after index 15,
// And verify that the new estimated total takes this into account.
rendered.getInstance().resetAfterIndex(15);
rendered.getInstance().scrollToItem(19);
expect(itemSize).toHaveBeenCalledTimes(5);
expect(scrollContainer.props.style.height).toEqual(750);
});
it('should delay the recalculation of the estimated total size if shouldForceUpdate is false', () => {
const rendered = ReactTestRenderer.create(
<VariableSizeList
{...defaultProps}
estimatedItemSize={30}
overscanCount={1}
itemSize={index => 25}
/>
);
const scrollContainer = findScrollContainer(rendered);
// The estimated total size should be (100 + 25 * 1 + 30 * 15)px = 575px.
expect(scrollContainer.props.style.height).toEqual(575);
// Supplying a new itemSize alone should not impact anything.
// Although the list get re-rendered by passing inline functions,
// but it still use the cached metrics to calculate the estimated size.
rendered.update(
<VariableSizeList
{...defaultProps}
estimatedItemSize={30}
overscanCount={1}
itemSize={index => 20}
/>
);
expect(scrollContainer.props.style.height).toEqual(575);
// Reset calculation cache but don't re-render the list,
// the estimated total size should stay the same.
rendered.getInstance().resetAfterIndex(0, false);
expect(scrollContainer.props.style.height).toEqual(575);
// Pass inline function to make the list re-render.
rendered.update(
<VariableSizeList
{...defaultProps}
estimatedItemSize={30}
overscanCount={1}
itemSize={index => 20}
/>
);
// The estimated total height should be (100 + 20 * 1 + 30 * 14)px = 540px.
expect(scrollContainer.props.style.height).toEqual(540);
});
it('should re-render items after the specified index with updated styles', () => {
const itemSize = jest.fn(() => 75);
const rendered = ReactTestRenderer.create(
<VariableSizeList
{...defaultProps}
itemCount={5}
itemSize={index => 25}
/>
);
// We've rendered 5 rows initially.
expect(itemRenderer).toHaveBeenCalledTimes(5);
expect(itemRenderer.mock.calls[3][0].style.height).toBe(25);
// Supplying a new itemSize alone should not impact anything.
rendered.update(
<VariableSizeList {...defaultProps} itemCount={5} itemSize={itemSize} />
);
// Reset styles for rows 4 and 5.
// And verify that the affected rows are re-rendered with new styles.
itemRenderer.mockClear();
rendered.getInstance().resetAfterIndex(3);
expect(itemRenderer).toHaveBeenCalledTimes(5);
expect(itemRenderer.mock.calls[3][0].style.height).toBe(75);
});
});
describe('props validation', () => {
beforeEach(() => spyOn(console, 'error'));
it('should fail if non-function itemSize is provided', () => {
expect(() =>
ReactTestRenderer.create(
<VariableSizeList {...defaultProps} itemSize={123} />
)
).toThrow(
'An invalid "itemSize" prop has been specified. ' +
'Value should be a function. "number" was specified.'
);
});
});
});