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.
Files changed (70) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/README.md +75 -35
  3. package/bundle/virtual-scroller-dom.js +1 -1
  4. package/bundle/virtual-scroller-dom.js.map +1 -1
  5. package/bundle/virtual-scroller-react.js +1 -1
  6. package/bundle/virtual-scroller-react.js.map +1 -1
  7. package/bundle/virtual-scroller.js +1 -1
  8. package/bundle/virtual-scroller.js.map +1 -1
  9. package/commonjs/DOM/ListTopOffsetWatcher.js +2 -4
  10. package/commonjs/DOM/ListTopOffsetWatcher.js.map +1 -1
  11. package/commonjs/Layout.test.js +26 -24
  12. package/commonjs/Layout.test.js.map +1 -1
  13. package/commonjs/Scroll.js +8 -11
  14. package/commonjs/Scroll.js.map +1 -1
  15. package/commonjs/VirtualScroller.constructor.js +7 -8
  16. package/commonjs/VirtualScroller.constructor.js.map +1 -1
  17. package/commonjs/VirtualScroller.js +10 -1
  18. package/commonjs/VirtualScroller.js.map +1 -1
  19. package/commonjs/VirtualScroller.layout.js +62 -20
  20. package/commonjs/VirtualScroller.layout.js.map +1 -1
  21. package/commonjs/VirtualScroller.onRender.js +25 -2
  22. package/commonjs/VirtualScroller.onRender.js.map +1 -1
  23. package/commonjs/VirtualScroller.state.js +39 -0
  24. package/commonjs/VirtualScroller.state.js.map +1 -1
  25. package/commonjs/react/useCreateVirtualScroller.js +1 -1
  26. package/commonjs/react/useCreateVirtualScroller.js.map +1 -1
  27. package/commonjs/test/VirtualScroller.js +8 -3
  28. package/commonjs/test/VirtualScroller.js.map +1 -1
  29. package/commonjs/utility/px.test.js +9 -5
  30. package/commonjs/utility/px.test.js.map +1 -1
  31. package/commonjs/utility/scheduleOnNextTick.js +25 -0
  32. package/commonjs/utility/scheduleOnNextTick.js.map +1 -0
  33. package/index.d.ts +4 -0
  34. package/modules/DOM/ListTopOffsetWatcher.js +1 -6
  35. package/modules/DOM/ListTopOffsetWatcher.js.map +1 -1
  36. package/modules/Layout.test.js +20 -19
  37. package/modules/Layout.test.js.map +1 -1
  38. package/modules/Scroll.js +8 -11
  39. package/modules/Scroll.js.map +1 -1
  40. package/modules/VirtualScroller.constructor.js +7 -8
  41. package/modules/VirtualScroller.constructor.js.map +1 -1
  42. package/modules/VirtualScroller.js +9 -1
  43. package/modules/VirtualScroller.js.map +1 -1
  44. package/modules/VirtualScroller.layout.js +61 -23
  45. package/modules/VirtualScroller.layout.js.map +1 -1
  46. package/modules/VirtualScroller.onRender.js +24 -2
  47. package/modules/VirtualScroller.onRender.js.map +1 -1
  48. package/modules/VirtualScroller.state.js +39 -1
  49. package/modules/VirtualScroller.state.js.map +1 -1
  50. package/modules/react/useCreateVirtualScroller.js +1 -1
  51. package/modules/react/useCreateVirtualScroller.js.map +1 -1
  52. package/modules/test/VirtualScroller.js +5 -2
  53. package/modules/test/VirtualScroller.js.map +1 -1
  54. package/modules/utility/px.test.js +5 -3
  55. package/modules/utility/px.test.js.map +1 -1
  56. package/modules/utility/scheduleOnNextTick.js +15 -0
  57. package/modules/utility/scheduleOnNextTick.js.map +1 -0
  58. package/package.json +6 -5
  59. package/source/DOM/ListTopOffsetWatcher.js +0 -6
  60. package/source/Layout.test.js +21 -20
  61. package/source/Scroll.js +7 -9
  62. package/source/VirtualScroller.constructor.js +7 -7
  63. package/source/VirtualScroller.js +8 -0
  64. package/source/VirtualScroller.layout.js +61 -25
  65. package/source/VirtualScroller.onRender.js +29 -3
  66. package/source/VirtualScroller.state.js +38 -0
  67. package/source/react/useCreateVirtualScroller.js +1 -1
  68. package/source/test/VirtualScroller.js +6 -2
  69. package/source/utility/px.test.js +6 -3
  70. package/source/utility/scheduleOnNextTick.js +16 -0
@@ -1,6 +1,7 @@
1
- import Layout from './Layout.js'
1
+ import { describe, it } from 'mocha'
2
+ import { expect } from 'chai'
2
3
 
3
- import Engine from './test/Engine.js'
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
- }).should.deep.equal({
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
- }).should.deep.equal({
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
- }).should.deep.equal({
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
- }).should.deep.equal({
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
- }).should.deep.equal({
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
- ).should.deep.equal({
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
- ).should.deep.equal({
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.should.equal(2)
195
- errors[0].message.should.equal('[virtual-scroller] ~ Prepended items count 5 is not divisible by Columns Count 4 ~')
196
- errors[1].message.should.equal('[virtual-scroller] Layout reset required')
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.should.equal(true)
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
- waitForScrollingToStop
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.waitForScrollingToStop = waitForScrollingToStop
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 "re-layout when user stops scrolling" timer.
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
- _useTimeoutInRenderLoop,
54
- _waitForScrollingToStop,
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._useTimeoutInRenderLoop = _useTimeoutInRenderLoop
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
- waitForScrollingToStop: _waitForScrollingToStop
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
- waitForScrollingToStop
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
- waitForScrollingToStop,
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: ({ reason }) => this.onUpdateShownItemIndexes({
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 { LAYOUT_REASON } from './Layout.js'
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 do anything".
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
- // This prevents `state` being inconsistent, because, for example,
25
- // both `setItems()` and this function could update `VirtualScroller` state
26
- // and having them operate in parallel could result in incorrectly calculated
27
- // `beforeItemsHeight` / `afterItemsHeight` / `firstShownItemIndex` /
28
- // `lastShownItemIndex`, because, when operating in parallel, this function
29
- // would have different `items` than the `setItems()` function, so their
30
- // results could diverge.
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 right now.
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
- warn('Layout calculated in', layoutDuration, 'ms')
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 for estimated after items height calculation)', this.getAverageItemHeight())
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 neccessarily `start()`ed by the time it has been rendered (mounted).
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
- clearTimeout(this.layoutTimer)
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 = setTimeout(() => {
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
- }, 0)
631
+ })
601
632
  }
602
633
  }
603
634
 
604
- const SLOW_LAYOUT_DURATION = 15 // in milliseconds.
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
- // In React, `setTimeout()` is used to prevent a React error:
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
- if (this._useTimeoutInRenderLoop) {
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
- _useTimeoutInRenderLoop: true,
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
- _waitForScrollingToStop: false,
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().should.deep.include(expectedState)
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).should.equal('0px')
6
- px(1).should.equal('1px')
7
- px(1.2345).should.equal('1.23px')
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
+ }