virtual-scroller 1.16.2 → 1.16.4
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.
- package/CHANGELOG.md +7 -0
- package/README.md +75 -35
- package/bundle/virtual-scroller-dom.js +1 -1
- package/bundle/virtual-scroller-dom.js.map +1 -1
- package/bundle/virtual-scroller-react.js +1 -1
- package/bundle/virtual-scroller-react.js.map +1 -1
- package/bundle/virtual-scroller.js +1 -1
- package/bundle/virtual-scroller.js.map +1 -1
- package/commonjs/DOM/ListTopOffsetWatcher.js +2 -4
- package/commonjs/DOM/ListTopOffsetWatcher.js.map +1 -1
- package/commonjs/Layout.test.js +26 -24
- package/commonjs/Layout.test.js.map +1 -1
- package/commonjs/Scroll.js +8 -11
- package/commonjs/Scroll.js.map +1 -1
- package/commonjs/VirtualScroller.constructor.js +7 -8
- package/commonjs/VirtualScroller.constructor.js.map +1 -1
- package/commonjs/VirtualScroller.js +10 -1
- package/commonjs/VirtualScroller.js.map +1 -1
- package/commonjs/VirtualScroller.layout.js +62 -20
- package/commonjs/VirtualScroller.layout.js.map +1 -1
- package/commonjs/VirtualScroller.onRender.js +25 -2
- package/commonjs/VirtualScroller.onRender.js.map +1 -1
- package/commonjs/VirtualScroller.state.js +39 -0
- package/commonjs/VirtualScroller.state.js.map +1 -1
- package/commonjs/react/useCreateVirtualScroller.js +1 -1
- package/commonjs/react/useCreateVirtualScroller.js.map +1 -1
- package/commonjs/test/VirtualScroller.js +8 -3
- package/commonjs/test/VirtualScroller.js.map +1 -1
- package/commonjs/utility/px.test.js +9 -5
- package/commonjs/utility/px.test.js.map +1 -1
- package/commonjs/utility/scheduleOnNextTick.js +25 -0
- package/commonjs/utility/scheduleOnNextTick.js.map +1 -0
- package/index.d.ts +4 -0
- package/modules/DOM/ListTopOffsetWatcher.js +1 -6
- package/modules/DOM/ListTopOffsetWatcher.js.map +1 -1
- package/modules/Layout.test.js +20 -19
- package/modules/Layout.test.js.map +1 -1
- package/modules/Scroll.js +8 -11
- package/modules/Scroll.js.map +1 -1
- package/modules/VirtualScroller.constructor.js +7 -8
- package/modules/VirtualScroller.constructor.js.map +1 -1
- package/modules/VirtualScroller.js +9 -1
- package/modules/VirtualScroller.js.map +1 -1
- package/modules/VirtualScroller.layout.js +61 -23
- package/modules/VirtualScroller.layout.js.map +1 -1
- package/modules/VirtualScroller.onRender.js +24 -2
- package/modules/VirtualScroller.onRender.js.map +1 -1
- package/modules/VirtualScroller.state.js +39 -1
- package/modules/VirtualScroller.state.js.map +1 -1
- package/modules/react/useCreateVirtualScroller.js +1 -1
- package/modules/react/useCreateVirtualScroller.js.map +1 -1
- package/modules/test/VirtualScroller.js +5 -2
- package/modules/test/VirtualScroller.js.map +1 -1
- package/modules/utility/px.test.js +5 -3
- package/modules/utility/px.test.js.map +1 -1
- package/modules/utility/scheduleOnNextTick.js +15 -0
- package/modules/utility/scheduleOnNextTick.js.map +1 -0
- package/package.json +6 -5
- package/source/DOM/ListTopOffsetWatcher.js +0 -6
- package/source/Layout.test.js +21 -20
- package/source/Scroll.js +7 -9
- package/source/VirtualScroller.constructor.js +7 -7
- package/source/VirtualScroller.js +8 -0
- package/source/VirtualScroller.layout.js +61 -25
- package/source/VirtualScroller.onRender.js +29 -3
- package/source/VirtualScroller.state.js +38 -0
- package/source/react/useCreateVirtualScroller.js +1 -1
- package/source/test/VirtualScroller.js +6 -2
- package/source/utility/px.test.js +6 -3
- package/source/utility/scheduleOnNextTick.js +16 -0
package/source/Layout.test.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { describe, it } from 'mocha'
|
|
2
|
+
import { expect } from 'chai'
|
|
2
3
|
|
|
3
|
-
import
|
|
4
|
+
import Layout from './Layout.js'
|
|
4
5
|
|
|
5
6
|
describe('Layout', function() {
|
|
6
7
|
it('should work', function() {
|
|
@@ -29,61 +30,61 @@ describe('Layout', function() {
|
|
|
29
30
|
})
|
|
30
31
|
|
|
31
32
|
// Initial render.
|
|
32
|
-
layout.getShownItemIndexes({
|
|
33
|
+
expect(layout.getShownItemIndexes({
|
|
33
34
|
itemsCount: items.length,
|
|
34
35
|
visibleAreaInsideTheList: {
|
|
35
36
|
top: 0,
|
|
36
37
|
bottom: SCREEN_HEIGHT
|
|
37
38
|
}
|
|
38
|
-
}).
|
|
39
|
+
})).to.deep.equal({
|
|
39
40
|
firstShownItemIndex: 0,
|
|
40
41
|
lastShownItemIndex: 2
|
|
41
42
|
})
|
|
42
43
|
|
|
43
44
|
// The first item is almost hidden.
|
|
44
|
-
layout.getShownItemIndexes({
|
|
45
|
+
expect(layout.getShownItemIndexes({
|
|
45
46
|
itemsCount: items.length,
|
|
46
47
|
visibleAreaInsideTheList: {
|
|
47
48
|
top: SCREEN_HEIGHT + ITEM_HEIGHT - 1,
|
|
48
49
|
bottom: (SCREEN_HEIGHT + ITEM_HEIGHT - 1) + SCREEN_HEIGHT
|
|
49
50
|
}
|
|
50
|
-
}).
|
|
51
|
+
})).to.deep.equal({
|
|
51
52
|
firstShownItemIndex: 0,
|
|
52
53
|
lastShownItemIndex: 4
|
|
53
54
|
})
|
|
54
55
|
|
|
55
56
|
// The first item is hidden.
|
|
56
|
-
layout.getShownItemIndexes({
|
|
57
|
+
expect(layout.getShownItemIndexes({
|
|
57
58
|
itemsCount: items.length,
|
|
58
59
|
visibleAreaInsideTheList: {
|
|
59
60
|
top: SCREEN_HEIGHT + ITEM_HEIGHT,
|
|
60
61
|
bottom: (SCREEN_HEIGHT + ITEM_HEIGHT) + SCREEN_HEIGHT
|
|
61
62
|
}
|
|
62
|
-
}).
|
|
63
|
+
})).to.deep.equal({
|
|
63
64
|
firstShownItemIndex: 1,
|
|
64
65
|
lastShownItemIndex: 4
|
|
65
66
|
})
|
|
66
67
|
|
|
67
68
|
// A new item at the bottom is almost visible.
|
|
68
|
-
layout.getShownItemIndexes({
|
|
69
|
+
expect(layout.getShownItemIndexes({
|
|
69
70
|
itemsCount: items.length,
|
|
70
71
|
visibleAreaInsideTheList: {
|
|
71
72
|
top: (ITEM_HEIGHT + VERTICAL_SPACING) * 5 - SCREEN_HEIGHT * 2,
|
|
72
73
|
bottom: (ITEM_HEIGHT + VERTICAL_SPACING) * 5 - SCREEN_HEIGHT
|
|
73
74
|
}
|
|
74
|
-
}).
|
|
75
|
+
})).to.deep.equal({
|
|
75
76
|
firstShownItemIndex: 1,
|
|
76
77
|
lastShownItemIndex: 4
|
|
77
78
|
})
|
|
78
79
|
|
|
79
80
|
// A new item at the bottom is visible.
|
|
80
|
-
layout.getShownItemIndexes({
|
|
81
|
+
expect(layout.getShownItemIndexes({
|
|
81
82
|
itemsCount: items.length,
|
|
82
83
|
visibleAreaInsideTheList: {
|
|
83
84
|
top: (ITEM_HEIGHT + VERTICAL_SPACING) * 5 + 1 - SCREEN_HEIGHT * 2,
|
|
84
85
|
bottom: (ITEM_HEIGHT + VERTICAL_SPACING) * 5 + 1 - SCREEN_HEIGHT
|
|
85
86
|
}
|
|
86
|
-
}).
|
|
87
|
+
})).to.deep.equal({
|
|
87
88
|
firstShownItemIndex: 1,
|
|
88
89
|
lastShownItemIndex: 5
|
|
89
90
|
})
|
|
@@ -112,7 +113,7 @@ describe('Layout', function() {
|
|
|
112
113
|
getScrollableContainerHeight: () => scrollableContainer.height
|
|
113
114
|
})
|
|
114
115
|
|
|
115
|
-
layout.getLayoutUpdateForItemsDiff(
|
|
116
|
+
expect(layout.getLayoutUpdateForItemsDiff(
|
|
116
117
|
{
|
|
117
118
|
firstShownItemIndex: 3,
|
|
118
119
|
lastShownItemIndex: 5,
|
|
@@ -126,7 +127,7 @@ describe('Layout', function() {
|
|
|
126
127
|
itemsCount: 5 + 5 + items.length,
|
|
127
128
|
columnsCount: 1
|
|
128
129
|
}
|
|
129
|
-
).
|
|
130
|
+
)).to.deep.equal({
|
|
130
131
|
firstShownItemIndex: 5 + 3,
|
|
131
132
|
lastShownItemIndex: 5 + 5,
|
|
132
133
|
beforeItemsHeight: (5 + 3) * (ITEM_HEIGHT + VERTICAL_SPACING),
|
|
@@ -164,7 +165,7 @@ describe('Layout', function() {
|
|
|
164
165
|
errors.push(error)
|
|
165
166
|
}
|
|
166
167
|
|
|
167
|
-
layout.getLayoutUpdateForItemsDiff(
|
|
168
|
+
expect(layout.getLayoutUpdateForItemsDiff(
|
|
168
169
|
{
|
|
169
170
|
firstShownItemIndex: 3,
|
|
170
171
|
lastShownItemIndex: 5,
|
|
@@ -180,7 +181,7 @@ describe('Layout', function() {
|
|
|
180
181
|
shouldRestoreScrollPosition: true,
|
|
181
182
|
onResetGridLayout: () => shouldResetGridLayout = true
|
|
182
183
|
}
|
|
183
|
-
).
|
|
184
|
+
)).to.deep.equal({
|
|
184
185
|
firstShownItemIndex: 0,
|
|
185
186
|
lastShownItemIndex: 5 + 5,
|
|
186
187
|
beforeItemsHeight: 0,
|
|
@@ -191,10 +192,10 @@ describe('Layout', function() {
|
|
|
191
192
|
// Use the default behavior of just `throw`-ing such errors.
|
|
192
193
|
global.VirtualScrollerCatchError = undefined
|
|
193
194
|
// Verify the errors that have been `throw`-n.
|
|
194
|
-
errors.length.
|
|
195
|
-
errors[0].message.
|
|
196
|
-
errors[1].message.
|
|
195
|
+
expect(errors.length).to.equal(2)
|
|
196
|
+
expect(errors[0].message).to.equal('[virtual-scroller] ~ Prepended items count 5 is not divisible by Columns Count 4 ~')
|
|
197
|
+
expect(errors[1].message).to.equal('[virtual-scroller] Layout reset required')
|
|
197
198
|
|
|
198
|
-
shouldResetGridLayout.
|
|
199
|
+
expect(shouldResetGridLayout).to.equal(true)
|
|
199
200
|
})
|
|
200
201
|
})
|
package/source/Scroll.js
CHANGED
|
@@ -21,7 +21,7 @@ export default class Scroll {
|
|
|
21
21
|
getListTopOffset,
|
|
22
22
|
getPrerenderMargin,
|
|
23
23
|
onScrolledToTop,
|
|
24
|
-
|
|
24
|
+
delayUnnecessaryRerenderUntilStopsScrolling
|
|
25
25
|
}) {
|
|
26
26
|
this.isInBypassMode = isInBypassMode
|
|
27
27
|
this.scrollableContainer = scrollableContainer
|
|
@@ -36,7 +36,7 @@ export default class Scroll {
|
|
|
36
36
|
this.getListTopOffset = getListTopOffset
|
|
37
37
|
this.getPrerenderMargin = getPrerenderMargin
|
|
38
38
|
this.onScrolledToTop = onScrolledToTop
|
|
39
|
-
this.
|
|
39
|
+
this.delayUnnecessaryRerenderUntilStopsScrolling = delayUnnecessaryRerenderUntilStopsScrolling
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
start() {
|
|
@@ -151,22 +151,20 @@ export default class Scroll {
|
|
|
151
151
|
)
|
|
152
152
|
)
|
|
153
153
|
|
|
154
|
-
if (forceUpdate) {
|
|
154
|
+
if (forceUpdate || this.delayUnnecessaryRerenderUntilStopsScrolling === false) {
|
|
155
155
|
log('The user has scrolled far enough: perform a re-layout')
|
|
156
|
-
} else {
|
|
157
|
-
log('The user is scrolling: perform a re-layout when they stop scrolling')
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
if (forceUpdate || this.waitForScrollingToStop === false) {
|
|
161
156
|
return this.onScroll()
|
|
162
157
|
}
|
|
163
158
|
|
|
159
|
+
log('The user is scrolling: perform a re-layout when they stop scrolling')
|
|
160
|
+
|
|
164
161
|
// If a re-layout is already scheduled at the next "frame",
|
|
165
|
-
// don't schedule a
|
|
162
|
+
// don't schedule a timer to wait for the user to stop scrolling.
|
|
166
163
|
if (this.isImmediateLayoutScheduled()) {
|
|
167
164
|
return
|
|
168
165
|
}
|
|
169
166
|
|
|
167
|
+
// Schedule a timer to wait for the user to stop scrolling.
|
|
170
168
|
this.shouldCallOnScrollListenerWhenStopsScrolling = true
|
|
171
169
|
this.watchOnStopScrolling()
|
|
172
170
|
}
|
|
@@ -50,8 +50,8 @@ export default function VirtualScrollerConstructor(
|
|
|
50
50
|
onItemInitialRender,
|
|
51
51
|
// `onItemFirstRender(i)` is deprecated, use `onItemInitialRender(item)` instead.
|
|
52
52
|
onItemFirstRender,
|
|
53
|
-
|
|
54
|
-
|
|
53
|
+
delayRecursiveRenderUntilNextTick,
|
|
54
|
+
_delayUnnecessaryRerenderUntilStopsScrolling,
|
|
55
55
|
engine
|
|
56
56
|
} = options
|
|
57
57
|
|
|
@@ -136,7 +136,7 @@ export default function VirtualScrollerConstructor(
|
|
|
136
136
|
// This can happen when a component repeatedly calls
|
|
137
137
|
// `.setState()` inside `componentWillUpdate()` or `componentDidUpdate()`.
|
|
138
138
|
// React limits the number of nested updates to prevent infinite loops."
|
|
139
|
-
this.
|
|
139
|
+
this._delayRecursiveRenderUntilNextTick = delayRecursiveRenderUntilNextTick
|
|
140
140
|
|
|
141
141
|
// `_getItemId()` function is used in `_getItemIndexByItemOrIndex()` function.
|
|
142
142
|
this._getItemId = getItemId
|
|
@@ -200,7 +200,7 @@ export default function VirtualScrollerConstructor(
|
|
|
200
200
|
measureItemsBatchSize,
|
|
201
201
|
initialScrollPosition,
|
|
202
202
|
onScrollPositionChange,
|
|
203
|
-
|
|
203
|
+
delayUnnecessaryRerenderUntilStopsScrolling: _delayUnnecessaryRerenderUntilStopsScrolling
|
|
204
204
|
})
|
|
205
205
|
|
|
206
206
|
if (state) {
|
|
@@ -219,7 +219,7 @@ function createHelpers({
|
|
|
219
219
|
measureItemsBatchSize,
|
|
220
220
|
initialScrollPosition,
|
|
221
221
|
onScrollPositionChange,
|
|
222
|
-
|
|
222
|
+
delayUnnecessaryRerenderUntilStopsScrolling
|
|
223
223
|
}) {
|
|
224
224
|
this.itemsContainer = this.engine.createItemsContainer(
|
|
225
225
|
this.getItemsContainerElement
|
|
@@ -326,7 +326,7 @@ function createHelpers({
|
|
|
326
326
|
isInBypassMode: this.isInBypassMode,
|
|
327
327
|
scrollableContainer: this.scrollableContainer,
|
|
328
328
|
itemsContainer: this.itemsContainer,
|
|
329
|
-
|
|
329
|
+
delayUnnecessaryRerenderUntilStopsScrolling,
|
|
330
330
|
onScroll: ({ delayed } = {}) => {
|
|
331
331
|
this.onUpdateShownItemIndexes({
|
|
332
332
|
reason: delayed ? LAYOUT_REASON.STOPPED_SCROLLING : LAYOUT_REASON.SCROLL
|
|
@@ -350,7 +350,7 @@ function createHelpers({
|
|
|
350
350
|
if (this.engine.watchListTopOffset) {
|
|
351
351
|
this.listTopOffsetWatcher = this.engine.watchListTopOffset({
|
|
352
352
|
getListTopOffset: this.getListTopOffsetInsideScrollableContainer,
|
|
353
|
-
onListTopOffsetChange: (
|
|
353
|
+
onListTopOffsetChange: () => this.onUpdateShownItemIndexes({
|
|
354
354
|
reason: LAYOUT_REASON.TOP_OFFSET_CHANGED
|
|
355
355
|
})
|
|
356
356
|
})
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import VirtualScrollerConstructor from './VirtualScroller.constructor.js'
|
|
2
2
|
import { LAYOUT_REASON } from './Layout.js'
|
|
3
|
+
import { unscheduleOnNextTick } from './utility/scheduleOnNextTick.js'
|
|
3
4
|
import log, { warn, reportError } from './utility/debug.js'
|
|
4
5
|
|
|
5
6
|
import {
|
|
@@ -184,7 +185,14 @@ export default class VirtualScroller {
|
|
|
184
185
|
}
|
|
185
186
|
|
|
186
187
|
// Cancel any scheduled layout.
|
|
188
|
+
// Any pending state update is discarded here.
|
|
187
189
|
this.cancelLayoutTimer({})
|
|
190
|
+
|
|
191
|
+
// Cancel any scheduled "recursive re-render duration" timer.
|
|
192
|
+
if (this._stateUpdateSequenceStartedAtResetTimer !== undefined) {
|
|
193
|
+
unscheduleOnNextTick(this._stateUpdateSequenceStartedAtResetTimer)
|
|
194
|
+
this._stateUpdateSequenceStartedAtResetTimer = undefined
|
|
195
|
+
}
|
|
188
196
|
}
|
|
189
197
|
|
|
190
198
|
hasToBeStarted() {
|
|
@@ -1,19 +1,19 @@
|
|
|
1
|
-
// For some weird reason, in Chrome, `setTimeout()` would lag up to a second (or more) behind.
|
|
2
|
-
// Turns out, Chrome developers have deprecated `setTimeout()` API entirely without asking anyone.
|
|
3
|
-
// Replacing `setTimeout()` with `requestAnimationFrame()` can work around that Chrome bug.
|
|
4
|
-
// https://github.com/bvaughn/react-virtualized/issues/722
|
|
5
|
-
import { setTimeout, clearTimeout } from 'request-animation-frame-timeout'
|
|
6
|
-
|
|
7
1
|
import log, { warn, isDebug, reportError } from './utility/debug.js'
|
|
8
|
-
import {
|
|
2
|
+
import { scheduleOnNextTick, unscheduleOnNextTick } from './utility/scheduleOnNextTick.js'
|
|
9
3
|
|
|
4
|
+
import { LAYOUT_REASON } from './Layout.js'
|
|
10
5
|
import ItemNotRenderedError from './ItemNotRenderedError.js'
|
|
11
6
|
|
|
12
7
|
export default function() {
|
|
13
8
|
this.onUpdateShownItemIndexes = ({ reason, stateUpdate }) => {
|
|
14
|
-
// In case of "don't
|
|
9
|
+
// In case of "don't perform this particular re-layout
|
|
10
|
+
// because a more-priority one is already scheduled".
|
|
15
11
|
const skip = () => {
|
|
16
12
|
if (stateUpdate) {
|
|
13
|
+
// Don't update layout. Just update some state.
|
|
14
|
+
// `stateUpdate` isn't supposed to affect the layout in any way
|
|
15
|
+
// because it doesn't contain any properties that affect the rendering of the list.
|
|
16
|
+
// I.e. it doesn't contain properties like `beforeItemsHeight` or `afterItemsHeight`, etc.
|
|
17
17
|
this.updateState(stateUpdate)
|
|
18
18
|
}
|
|
19
19
|
}
|
|
@@ -21,13 +21,26 @@ export default function() {
|
|
|
21
21
|
// If new `items` have been set and are waiting to be applied,
|
|
22
22
|
// or if the viewport width has changed requiring a re-layout,
|
|
23
23
|
// then temporarily stop all other updates like "on scroll" updates.
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
// `
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
24
|
+
//
|
|
25
|
+
// This prevents `state` from becoming inconsistent, because several
|
|
26
|
+
// "contenders" (layout reasons) could be attempting to update
|
|
27
|
+
// `VirtualScroller` state in parallel between two successive renders.
|
|
28
|
+
//
|
|
29
|
+
// For example, both `VirtualScroller.setItems()` and `VirtualScroller.scroll.onScroll()`
|
|
30
|
+
// "contenders" could be attempting to update `VirtualScroller` state in parallel,
|
|
31
|
+
// and the layout calculated by one of them would overwrite the layout calculated by another.
|
|
32
|
+
//
|
|
33
|
+
// Which one should have the priority in such case then?
|
|
34
|
+
// In this specific example, `VirtualScroller.setItems()` should be the one
|
|
35
|
+
// because only it knows the correct new `items` whereas other "contenders"
|
|
36
|
+
// have the old stale `items`.
|
|
37
|
+
//
|
|
38
|
+
// Another example of a "contender" that should have a priority is
|
|
39
|
+
// "on list width has changed" handler because only it has access
|
|
40
|
+
// to the correct new width of the list rather than the old stale width.
|
|
41
|
+
//
|
|
42
|
+
// So in these two "priority" cases any other re-layout should be ignored.
|
|
43
|
+
//
|
|
31
44
|
if (this.newItemsWillBeRendered || this.widthHasChanged || this._isResizing) {
|
|
32
45
|
return skip()
|
|
33
46
|
}
|
|
@@ -37,11 +50,12 @@ export default function() {
|
|
|
37
50
|
return skip()
|
|
38
51
|
}
|
|
39
52
|
|
|
40
|
-
// Cancel a "re-layout when user stops scrolling" timer
|
|
53
|
+
// Cancel a scheduled "re-layout when user stops scrolling" timer
|
|
54
|
+
// because a re-layout will be performed here now.
|
|
41
55
|
this.scroll.cancelScheduledLayout()
|
|
42
56
|
|
|
43
|
-
// Cancel a re-layout that is scheduled to run at the next "frame",
|
|
44
|
-
// because a re-layout will be performed
|
|
57
|
+
// Cancel a scheduled re-layout that is scheduled to run at the next "frame",
|
|
58
|
+
// because a re-layout will be performed here now.
|
|
45
59
|
stateUpdate = this.cancelLayoutTimer({ stateUpdate })
|
|
46
60
|
|
|
47
61
|
// Perform a re-layout.
|
|
@@ -126,7 +140,24 @@ export default function() {
|
|
|
126
140
|
if (layoutDuration < SLOW_LAYOUT_DURATION) {
|
|
127
141
|
// log('Calculated in', layoutDuration, 'ms')
|
|
128
142
|
} else {
|
|
129
|
-
|
|
143
|
+
// Getting this warning would mean that for a given (large) count of items,
|
|
144
|
+
// calculating `beforeItemsHeight` or `afterItemsHeight` requires too much time
|
|
145
|
+
// in order to iterate over all not-currently-visible items to add up their heights.
|
|
146
|
+
//
|
|
147
|
+
// Because layout calculation is a blocking procedure, it "freezes" the application
|
|
148
|
+
// for a given amount of milliseconds. If the amount of those milliseconds is longer
|
|
149
|
+
// than 16ms, frames are being dropped in a 60fps scenario.
|
|
150
|
+
// Modern phones even use 120fps as a baseline, so the timeframe requirement
|
|
151
|
+
// gets even more strict.
|
|
152
|
+
//
|
|
153
|
+
// What could be a way of dealing with this?
|
|
154
|
+
//
|
|
155
|
+
// * The code could cache clustered item heights in order to perform less add-up operations.
|
|
156
|
+
// For example, the code could cache the total height of items 1..100, 101..200, etc.
|
|
157
|
+
// Then 1..1000, 1001..2000, etc. Then 1..10000, 10001..20000, etc. So the clusters
|
|
158
|
+
// would just get progressively larger as more `items` are added.
|
|
159
|
+
//
|
|
160
|
+
warn('Layout calculation time too high:', layoutDuration, 'ms')
|
|
130
161
|
}
|
|
131
162
|
if (this.getColumnsCount()) {
|
|
132
163
|
log('Columns count', this.getColumnsCount())
|
|
@@ -135,7 +166,7 @@ export default function() {
|
|
|
135
166
|
log('Last shown item index', lastShownItemIndex)
|
|
136
167
|
log('Before items height', beforeItemsHeight)
|
|
137
168
|
log('After items height (actual or estimated)', afterItemsHeight)
|
|
138
|
-
log('Average item height (used
|
|
169
|
+
log('Average item height (will be used to calculate estimated "after items height")', this.getAverageItemHeight())
|
|
139
170
|
if (isDebug()) {
|
|
140
171
|
log('Item heights', this.getState().itemHeights.slice())
|
|
141
172
|
log('Item states', this.getState().itemStates.slice())
|
|
@@ -425,7 +456,7 @@ export default function() {
|
|
|
425
456
|
if (previousHeight === undefined) {
|
|
426
457
|
// There're valid cases when the item still hasn't been measured and `onItemHeightDidChange(item)`
|
|
427
458
|
// function was called for it. That's because measuring items is only done after the `VirtualScroller`
|
|
428
|
-
// has `start()`ed. But it's not
|
|
459
|
+
// has `start()`ed. But it's not necessarily `start()`ed by the time it has been rendered (mounted).
|
|
429
460
|
// For example, the React component `<VirtualScroller/>` provides an `readyToStart={false}` property
|
|
430
461
|
// in order to let the application finish initializing itself before starting the `VirtualScroller`.
|
|
431
462
|
// So there're legitimate cases when a `VirtualScroller` is already rendered but is not `start()`ed yet.
|
|
@@ -572,7 +603,7 @@ export default function() {
|
|
|
572
603
|
|
|
573
604
|
this.cancelLayoutTimer = ({ stateUpdate }) => {
|
|
574
605
|
if (this.layoutTimer) {
|
|
575
|
-
|
|
606
|
+
unscheduleOnNextTick(this.layoutTimer)
|
|
576
607
|
this.layoutTimer = undefined
|
|
577
608
|
// Merge state updates.
|
|
578
609
|
if (stateUpdate || this.layoutTimerStateUpdate) {
|
|
@@ -590,15 +621,20 @@ export default function() {
|
|
|
590
621
|
|
|
591
622
|
this.scheduleLayoutTimer = ({ reason, stateUpdate }) => {
|
|
592
623
|
this.layoutTimerStateUpdate = stateUpdate
|
|
593
|
-
this.layoutTimer =
|
|
624
|
+
this.layoutTimer = scheduleOnNextTick(() => {
|
|
594
625
|
this.layoutTimerStateUpdate = undefined
|
|
595
626
|
this.layoutTimer = undefined
|
|
596
627
|
this.onUpdateShownItemIndexes({
|
|
597
628
|
reason,
|
|
598
629
|
stateUpdate
|
|
599
630
|
})
|
|
600
|
-
}
|
|
631
|
+
})
|
|
601
632
|
}
|
|
602
633
|
}
|
|
603
634
|
|
|
604
|
-
const
|
|
635
|
+
const FPS = 60 // though 120 fps is common on smartphones these days.
|
|
636
|
+
const FRAME_DURATION = 1000 / FPS // in milliseconds
|
|
637
|
+
|
|
638
|
+
// Layout is considered "too slow" when it "blocks" the "main thread"
|
|
639
|
+
// for so long that it starts causing "frame drops".
|
|
640
|
+
const SLOW_LAYOUT_DURATION = FRAME_DURATION
|
|
@@ -204,11 +204,16 @@ export default function() {
|
|
|
204
204
|
}
|
|
205
205
|
|
|
206
206
|
if (layoutUpdateReason) {
|
|
207
|
+
// Update layout.
|
|
207
208
|
updateStateRightAfterRender.call(this, {
|
|
208
209
|
stateUpdate,
|
|
209
210
|
reason: layoutUpdateReason
|
|
210
211
|
})
|
|
211
212
|
} else if (stateUpdate) {
|
|
213
|
+
// Don't update layout. Just update some state.
|
|
214
|
+
// `stateUpdate` isn't supposed to affect the layout in any way
|
|
215
|
+
// because it doesn't contain any properties that affect the rendering of the list.
|
|
216
|
+
// I.e. it doesn't contain properties like `beforeItemsHeight` or `afterItemsHeight`, etc.
|
|
212
217
|
this.updateState(stateUpdate)
|
|
213
218
|
} else {
|
|
214
219
|
log('~ Finished Layout ~')
|
|
@@ -327,12 +332,25 @@ export default function() {
|
|
|
327
332
|
reason,
|
|
328
333
|
stateUpdate
|
|
329
334
|
}) {
|
|
330
|
-
//
|
|
335
|
+
// React doesn't like it when `setState()` is called during render:
|
|
336
|
+
//
|
|
331
337
|
// "Maximum update depth exceeded.
|
|
332
338
|
// This can happen when a component repeatedly calls
|
|
333
339
|
// `.updateState()` inside `componentWillUpdate()` or `componentDidUpdate()`.
|
|
334
340
|
// React limits the number of nested updates to prevent infinite loops."
|
|
335
|
-
|
|
341
|
+
//
|
|
342
|
+
// To work around that, in React it always prefers a delayed state update
|
|
343
|
+
// over an immediate state update right after a prevous state update.
|
|
344
|
+
//
|
|
345
|
+
// Another case when it should introduce an artificial momentary delay
|
|
346
|
+
// in a chain of immediate "syncrhonous" re-renders is when such chain
|
|
347
|
+
// of re-renders has been freezing the UI for far too long.
|
|
348
|
+
//
|
|
349
|
+
if (this._delayRecursiveRenderUntilNextTick || (
|
|
350
|
+
this._delayRecursiveRenderUntilNextTick !== false &&
|
|
351
|
+
this._stateUpdateSequenceStartedAt !== undefined &&
|
|
352
|
+
Date.now() - this._stateUpdateSequenceStartedAt > UNINTERRUPTED_RECURSIVE_STATE_UPDATE_SEQUENCE_MAX_ALLOWED_DURATION
|
|
353
|
+
)) {
|
|
336
354
|
// Cancel a previously scheduled re-layout.
|
|
337
355
|
stateUpdate = this.cancelLayoutTimer({ stateUpdate })
|
|
338
356
|
// Schedule a new re-layout.
|
|
@@ -395,4 +413,12 @@ export default function() {
|
|
|
395
413
|
widthHasChanged
|
|
396
414
|
}
|
|
397
415
|
}
|
|
398
|
-
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const FPS = 60 // though 120 fps is common on smartphones these days.
|
|
419
|
+
const FRAME_DURATION = 1000 / FPS // in milliseconds
|
|
420
|
+
|
|
421
|
+
// Allow recursive "synchronous" re-renders
|
|
422
|
+
// until they start "blocking" the "main thread"
|
|
423
|
+
// for so long that it starts causing "frame drops".
|
|
424
|
+
const UNINTERRUPTED_RECURSIVE_STATE_UPDATE_SEQUENCE_MAX_ALLOWED_DURATION = FRAME_DURATION
|
|
@@ -2,6 +2,7 @@ import fillArray from './utility/fillArray.js'
|
|
|
2
2
|
import log, { warn, isDebug, reportError } from './utility/debug.js'
|
|
3
3
|
import { cleanUpBeforeResizeState } from './BeforeResize.js'
|
|
4
4
|
import getStateSnapshot from './utility/getStateSnapshot.js'
|
|
5
|
+
import { scheduleOnNextTick } from './utility/scheduleOnNextTick.js'
|
|
5
6
|
|
|
6
7
|
// There're three main places where state is updated:
|
|
7
8
|
//
|
|
@@ -84,6 +85,43 @@ export default function createStateHelpers({
|
|
|
84
85
|
log(getStateSnapshot(stateUpdate))
|
|
85
86
|
}
|
|
86
87
|
|
|
88
|
+
// Start tracking the time that elapsed since the start of a state update,
|
|
89
|
+
// because by default a state update causes a "syncrhonous" re-render
|
|
90
|
+
// which could, in turn, require an immediate follow-up re-render,
|
|
91
|
+
// which could again require an immediate follow-up re-render, etc,
|
|
92
|
+
// causing a "synchronous" re-render recursion, potentially freezing the UI.
|
|
93
|
+
//
|
|
94
|
+
// An avalanche of immediate follow-up re-renders usually happens
|
|
95
|
+
// when there're a lot of items in the list — hundreds of thousands —
|
|
96
|
+
// and the user suddenly scrolls down at the very bottom of the list
|
|
97
|
+
// causing a long chain of re-renders when the list starts gradually rendering
|
|
98
|
+
// all the items from top to bottom until it gets to the bottom of the list.
|
|
99
|
+
//
|
|
100
|
+
// In order to prevent freezing the UI in such cases, and to provide the user
|
|
101
|
+
// with the ability to break away from this kind of recursion, `VirtualScroller`
|
|
102
|
+
// has to introduce a momentary delay between successive "synchronous" re-renders.
|
|
103
|
+
//
|
|
104
|
+
// At the same time, it should only introduce such artificial momentary delay
|
|
105
|
+
// when it's really required in order to not hinder the list's performance unnecessarily.
|
|
106
|
+
//
|
|
107
|
+
// A kind of "golen mean" then is to measure the time that a succession of
|
|
108
|
+
// "synchronous" re-renders has already been going for, and interrupt it
|
|
109
|
+
// with a momentary delay if it has been going on for too long.
|
|
110
|
+
// How much's too long? Well, it could be a duration of a single frame.
|
|
111
|
+
//
|
|
112
|
+
// P.S. If this `.updateState()` call was initiated from another `.updateState()` call,
|
|
113
|
+
// i.e. if it was done recursively, then the time tracking will have already been started.
|
|
114
|
+
//
|
|
115
|
+
if (this._stateUpdateSequenceStartedAt === undefined) {
|
|
116
|
+
this._stateUpdateSequenceStartedAt = Date.now()
|
|
117
|
+
// Automatically reset `this._stateUpdateSequenceStartedAt` variable on "next tick",
|
|
118
|
+
// i.e. outside of a "synchronous" recursion.
|
|
119
|
+
this._stateUpdateSequenceStartedAtResetTimer = scheduleOnNextTick(() => {
|
|
120
|
+
this._stateUpdateSequenceStartedAtResetTimer = undefined
|
|
121
|
+
this._stateUpdateSequenceStartedAt = undefined
|
|
122
|
+
})
|
|
123
|
+
}
|
|
124
|
+
|
|
87
125
|
// Ensure that a non-initial `stateUpdate` can only contain an `items`
|
|
88
126
|
// property when it comes from a `setItems()` call.
|
|
89
127
|
if (stateUpdate.items) {
|
|
@@ -36,7 +36,7 @@ export default function useVirtualScroller({
|
|
|
36
36
|
getItemsContainerElement,
|
|
37
37
|
items,
|
|
38
38
|
{
|
|
39
|
-
|
|
39
|
+
delayRecursiveRenderUntilNextTick: true,
|
|
40
40
|
// `estimatedItemHeight` is deprecated, use `getEstimatedItemHeight()` instead.
|
|
41
41
|
estimatedItemHeight,
|
|
42
42
|
getEstimatedItemHeight,
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { describe, it } from 'mocha'
|
|
2
|
+
import { expect } from 'chai'
|
|
3
|
+
|
|
1
4
|
import VirtualScroller from '../VirtualScroller.js'
|
|
2
5
|
import Engine from './Engine.js'
|
|
3
6
|
|
|
@@ -89,7 +92,8 @@ export default class TestVirtualScroller {
|
|
|
89
92
|
this.virtualScroller = new VirtualScroller(getItemsContainerElement, items, {
|
|
90
93
|
scrollableContainer: scrollableContainerElement,
|
|
91
94
|
engine: Engine,
|
|
92
|
-
|
|
95
|
+
_delayUnnecessaryRerenderUntilStopsScrolling: false,
|
|
96
|
+
delayRecursiveRenderUntilNextTick: false,
|
|
93
97
|
getItemId,
|
|
94
98
|
getColumnsCount,
|
|
95
99
|
state: initialState,
|
|
@@ -151,7 +155,7 @@ export default class TestVirtualScroller {
|
|
|
151
155
|
|
|
152
156
|
verifyState(expectedState) {
|
|
153
157
|
// `mocha`/`chai`.
|
|
154
|
-
this.virtualScroller.getState().
|
|
158
|
+
expect(this.virtualScroller.getState()).to.deep.include(expectedState)
|
|
155
159
|
}
|
|
156
160
|
|
|
157
161
|
expectStateUpdate(stateUpdate, callback) {
|
|
@@ -1,9 +1,12 @@
|
|
|
1
|
+
import { describe, it } from 'mocha'
|
|
2
|
+
import { expect } from 'chai'
|
|
3
|
+
|
|
1
4
|
import px from './px.js'
|
|
2
5
|
|
|
3
6
|
describe('utility/px', function() {
|
|
4
7
|
it('should truncate px values', function() {
|
|
5
|
-
px(0).
|
|
6
|
-
px(1).
|
|
7
|
-
px(1.2345).
|
|
8
|
+
expect(px(0)).to.equal('0px')
|
|
9
|
+
expect(px(1)).to.equal('1px')
|
|
10
|
+
expect(px(1.2345)).to.equal('1.23px')
|
|
8
11
|
})
|
|
9
12
|
})
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// For some weird reason, in Chrome, `setTimeout()` would lag up to a second (or more) behind.
|
|
2
|
+
// Turns out, Chrome developers have deprecated `setTimeout()` API entirely without asking anyone.
|
|
3
|
+
// Replacing `setTimeout()` with `requestAnimationFrame()` can work around that Chrome bug.
|
|
4
|
+
// https://github.com/bvaughn/react-virtualized/issues/722
|
|
5
|
+
import { setTimeout, clearTimeout } from 'request-animation-frame-timeout'
|
|
6
|
+
|
|
7
|
+
// This function could also use `requestAnimationFrame()` function
|
|
8
|
+
// but `setTimeout()` with `0` delay has practically the same effect
|
|
9
|
+
// and at the same time it's more universally supported, e.g. in Node.js.
|
|
10
|
+
export function scheduleOnNextTick(func) {
|
|
11
|
+
return setTimeout(func, 0)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function unscheduleOnNextTick(timerId) {
|
|
15
|
+
clearTimeout(timerId)
|
|
16
|
+
}
|