virtual-scroller 1.7.6 → 1.8.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 (153) hide show
  1. package/CHANGELOG.md +29 -1
  2. package/README.md +139 -33
  3. package/babel.config.js +25 -0
  4. package/babel.js +5 -0
  5. package/bundle/index-bypass.html +1 -1
  6. package/bundle/index-dom.html +1 -1
  7. package/bundle/index-grid.html +1 -2
  8. package/bundle/index-scrollableContainer.html +1 -1
  9. package/bundle/index-tbody-scrollableContainer.html +2 -0
  10. package/bundle/index-tbody.html +2 -0
  11. package/bundle/virtual-scroller-dom.js +1 -1
  12. package/bundle/virtual-scroller-dom.js.map +1 -1
  13. package/bundle/virtual-scroller-react.js +1 -1
  14. package/bundle/virtual-scroller-react.js.map +1 -1
  15. package/bundle/virtual-scroller.js +1 -1
  16. package/bundle/virtual-scroller.js.map +1 -1
  17. package/commonjs/BeforeResize.js +319 -0
  18. package/commonjs/BeforeResize.js.map +1 -0
  19. package/commonjs/DOM/Engine.js +46 -0
  20. package/commonjs/DOM/Engine.js.map +1 -0
  21. package/commonjs/DOM/ItemsContainer.js +78 -0
  22. package/commonjs/DOM/ItemsContainer.js.map +1 -0
  23. package/commonjs/DOM/{WaitForStylesToLoad.js → ListTopOffsetWatcher.js} +56 -35
  24. package/commonjs/DOM/ListTopOffsetWatcher.js.map +1 -0
  25. package/commonjs/DOM/ScrollableContainer.js +56 -81
  26. package/commonjs/DOM/ScrollableContainer.js.map +1 -1
  27. package/commonjs/DOM/VirtualScroller.js +20 -15
  28. package/commonjs/DOM/VirtualScroller.js.map +1 -1
  29. package/commonjs/DOM/tbody.js +2 -2
  30. package/commonjs/ItemHeights.js +13 -20
  31. package/commonjs/ItemHeights.js.map +1 -1
  32. package/commonjs/Layout.js +588 -215
  33. package/commonjs/Layout.js.map +1 -1
  34. package/commonjs/Layout.test.js +191 -0
  35. package/commonjs/Layout.test.js.map +1 -0
  36. package/commonjs/ListHeightChangeWatcher.js +126 -0
  37. package/commonjs/ListHeightChangeWatcher.js.map +1 -0
  38. package/commonjs/Resize.js +22 -21
  39. package/commonjs/Resize.js.map +1 -1
  40. package/commonjs/Scroll.js +148 -88
  41. package/commonjs/Scroll.js.map +1 -1
  42. package/commonjs/VirtualScroller.js +1269 -390
  43. package/commonjs/VirtualScroller.js.map +1 -1
  44. package/commonjs/getItemCoordinates.js.map +1 -1
  45. package/commonjs/getItemsDiff.js.map +1 -1
  46. package/commonjs/getVerticalSpacing.js +8 -8
  47. package/commonjs/getVerticalSpacing.js.map +1 -1
  48. package/commonjs/react/VirtualScroller.js +31 -37
  49. package/commonjs/react/VirtualScroller.js.map +1 -1
  50. package/commonjs/utility/debounce.js +26 -4
  51. package/commonjs/utility/debounce.js.map +1 -1
  52. package/commonjs/utility/debug.js +51 -12
  53. package/commonjs/utility/debug.js.map +1 -1
  54. package/commonjs/utility/getStateSnapshot.js +50 -0
  55. package/commonjs/utility/getStateSnapshot.js.map +1 -0
  56. package/commonjs/utility/px.js +1 -1
  57. package/commonjs/utility/px.js.map +1 -1
  58. package/commonjs/utility/px.test.js +14 -0
  59. package/commonjs/utility/px.test.js.map +1 -0
  60. package/commonjs/utility/shallowEqual.js +1 -1
  61. package/commonjs/utility/shallowEqual.js.map +1 -1
  62. package/commonjs/utility/throttle.js.map +1 -1
  63. package/dom/index.d.ts +23 -0
  64. package/index.d.ts +84 -0
  65. package/modules/BeforeResize.js +310 -0
  66. package/modules/BeforeResize.js.map +1 -0
  67. package/modules/DOM/Engine.js +27 -0
  68. package/modules/DOM/Engine.js.map +1 -0
  69. package/modules/DOM/ItemsContainer.js +71 -0
  70. package/modules/DOM/ItemsContainer.js.map +1 -0
  71. package/modules/DOM/{WaitForStylesToLoad.js → ListTopOffsetWatcher.js} +57 -35
  72. package/modules/DOM/ListTopOffsetWatcher.js.map +1 -0
  73. package/modules/DOM/ScrollableContainer.js +55 -80
  74. package/modules/DOM/ScrollableContainer.js.map +1 -1
  75. package/modules/DOM/VirtualScroller.js +15 -14
  76. package/modules/DOM/VirtualScroller.js.map +1 -1
  77. package/modules/ItemHeights.js +8 -19
  78. package/modules/ItemHeights.js.map +1 -1
  79. package/modules/Layout.js +582 -213
  80. package/modules/Layout.js.map +1 -1
  81. package/modules/Layout.test.js +185 -0
  82. package/modules/Layout.test.js.map +1 -0
  83. package/modules/ListHeightChangeWatcher.js +119 -0
  84. package/modules/ListHeightChangeWatcher.js.map +1 -0
  85. package/modules/Resize.js +21 -20
  86. package/modules/Resize.js.map +1 -1
  87. package/modules/Scroll.js +148 -87
  88. package/modules/Scroll.js.map +1 -1
  89. package/modules/VirtualScroller.js +1263 -390
  90. package/modules/VirtualScroller.js.map +1 -1
  91. package/modules/getItemCoordinates.js.map +1 -1
  92. package/modules/getItemsDiff.js.map +1 -1
  93. package/modules/getVerticalSpacing.js +8 -8
  94. package/modules/getVerticalSpacing.js.map +1 -1
  95. package/modules/react/VirtualScroller.js +31 -37
  96. package/modules/react/VirtualScroller.js.map +1 -1
  97. package/modules/utility/debounce.js +26 -4
  98. package/modules/utility/debounce.js.map +1 -1
  99. package/modules/utility/debug.js +47 -10
  100. package/modules/utility/debug.js.map +1 -1
  101. package/modules/utility/getStateSnapshot.js +43 -0
  102. package/modules/utility/getStateSnapshot.js.map +1 -0
  103. package/modules/utility/px.js +1 -1
  104. package/modules/utility/px.js.map +1 -1
  105. package/modules/utility/px.test.js +9 -0
  106. package/modules/utility/px.test.js.map +1 -0
  107. package/modules/utility/shallowEqual.js +1 -1
  108. package/modules/utility/shallowEqual.js.map +1 -1
  109. package/modules/utility/throttle.js.map +1 -1
  110. package/package.json +24 -22
  111. package/react/index.d.ts +27 -0
  112. package/source/BeforeResize.js +317 -0
  113. package/source/DOM/Engine.js +32 -0
  114. package/source/DOM/ItemsContainer.js +48 -0
  115. package/source/DOM/{WaitForStylesToLoad.js → ListTopOffsetWatcher.js} +48 -22
  116. package/source/DOM/ScrollableContainer.js +39 -56
  117. package/source/DOM/VirtualScroller.js +6 -7
  118. package/source/ItemHeights.js +10 -15
  119. package/source/Layout.js +626 -252
  120. package/source/Layout.test.js +171 -0
  121. package/source/ListHeightChangeWatcher.js +94 -0
  122. package/source/Resize.js +23 -15
  123. package/source/Scroll.js +139 -78
  124. package/source/VirtualScroller.js +1240 -286
  125. package/source/getVerticalSpacing.js +7 -7
  126. package/source/react/VirtualScroller.js +2 -18
  127. package/source/utility/debounce.js +20 -3
  128. package/source/utility/debug.js +34 -3
  129. package/source/utility/getStateSnapshot.js +36 -0
  130. package/source/utility/px.js +1 -1
  131. package/source/utility/px.test.js +9 -0
  132. package/website/index-bypass.html +195 -0
  133. package/website/index-grid.html +0 -1
  134. package/website/index-scrollableContainer.html +208 -0
  135. package/website/index-tbody-scrollableContainer.html +68 -0
  136. package/website/index-tbody.html +55 -0
  137. package/commonjs/DOM/RenderingEngine.js +0 -33
  138. package/commonjs/DOM/RenderingEngine.js.map +0 -1
  139. package/commonjs/DOM/Screen.js +0 -87
  140. package/commonjs/DOM/Screen.js.map +0 -1
  141. package/commonjs/DOM/WaitForStylesToLoad.js.map +0 -1
  142. package/commonjs/RestoreScroll.js +0 -118
  143. package/commonjs/RestoreScroll.js.map +0 -1
  144. package/modules/DOM/RenderingEngine.js +0 -19
  145. package/modules/DOM/RenderingEngine.js.map +0 -1
  146. package/modules/DOM/Screen.js +0 -80
  147. package/modules/DOM/Screen.js.map +0 -1
  148. package/modules/DOM/WaitForStylesToLoad.js.map +0 -1
  149. package/modules/RestoreScroll.js +0 -111
  150. package/modules/RestoreScroll.js.map +0 -1
  151. package/source/DOM/RenderingEngine.js +0 -22
  152. package/source/DOM/Screen.js +0 -51
  153. package/source/RestoreScroll.js +0 -86
@@ -11,42 +11,36 @@ import {
11
11
  setTbodyPadding
12
12
  } from './DOM/tbody'
13
13
 
14
- import DOMRenderingEngine from './DOM/RenderingEngine'
15
- import WaitForStylesToLoad from './DOM/WaitForStylesToLoad'
14
+ import DOMEngine from './DOM/Engine'
16
15
 
17
16
  import Layout, { LAYOUT_REASON } from './Layout'
18
17
  import Resize from './Resize'
18
+ import BeforeResize from './BeforeResize'
19
19
  import Scroll from './Scroll'
20
- import RestoreScroll from './RestoreScroll'
20
+ import ListHeightChangeWatcher from './ListHeightChangeWatcher'
21
21
  import ItemHeights from './ItemHeights'
22
22
  import getItemsDiff from './getItemsDiff'
23
23
  import getVerticalSpacing from './getVerticalSpacing'
24
- // import getItemCoordinates from './getItemCoordinates'
25
24
 
26
25
  import log, { warn, isDebug, reportError } from './utility/debug'
27
26
  import shallowEqual from './utility/shallowEqual'
27
+ import getStateSnapshot from './utility/getStateSnapshot'
28
28
 
29
29
  export default class VirtualScroller {
30
30
  /**
31
- * @param {function} getContainerElement — Returns the container DOM `Element`.
31
+ * @param {function} getItemsContainerElement — Returns the container DOM `Element`.
32
32
  * @param {any[]} items — The list of items.
33
33
  * @param {Object} [options] — See README.md.
34
34
  * @return {VirtualScroller}
35
35
  */
36
36
  constructor(
37
- getContainerElement,
37
+ getItemsContainerElement,
38
38
  items,
39
39
  options = {}
40
40
  ) {
41
41
  const {
42
- getState,
43
- setState,
44
42
  onStateChange,
45
43
  customState,
46
- // `preserveScrollPositionAtBottomOnMount` option name is deprecated,
47
- // use `preserveScrollPositionOfTheBottomOfTheListOnMount` option instead.
48
- preserveScrollPositionAtBottomOnMount,
49
- preserveScrollPositionOfTheBottomOfTheListOnMount,
50
44
  initialScrollPosition,
51
45
  onScrollPositionChange,
52
46
  measureItemsBatchSize,
@@ -57,12 +51,18 @@ export default class VirtualScroller {
57
51
  getItemId,
58
52
  tbody,
59
53
  _useTimeoutInRenderLoop,
54
+ _waitForScrollingToStop,
60
55
  // bypassBatchSize
61
56
  } = options
62
57
 
58
+ let {
59
+ getState,
60
+ setState
61
+ } = options
62
+
63
63
  let {
64
64
  bypass,
65
- // margin,
65
+ // prerenderMargin,
66
66
  estimatedItemHeight,
67
67
  // getItemState,
68
68
  onItemInitialRender,
@@ -70,7 +70,7 @@ export default class VirtualScroller {
70
70
  onItemFirstRender,
71
71
  scrollableContainer,
72
72
  state,
73
- renderingEngine
73
+ engine
74
74
  } = options
75
75
 
76
76
  log('~ Initialize ~')
@@ -89,25 +89,48 @@ export default class VirtualScroller {
89
89
 
90
90
  // Could support non-DOM rendering engines.
91
91
  // For example, React Native, `<canvas/>`, etc.
92
- if (!renderingEngine) {
93
- renderingEngine = DOMRenderingEngine
92
+ if (!engine) {
93
+ engine = DOMEngine
94
+ }
95
+
96
+ // Sometimes, when `new VirtualScroller()` instance is created,
97
+ // `getItemsContainerElement()` might not be ready to return the "container" DOM Element yet
98
+ // (for example, because it's not rendered yet). That's the reason why it's a getter function.
99
+ // For example, in React `<VirtualScroller/>` component, a `VirtualScroller`
100
+ // instance is created in the React component's `constructor()`, and at that time
101
+ // the container Element is not yet available. The container Element is available
102
+ // in `componentDidMount()`, but `componentDidMount()` is not executed on server,
103
+ // which would mean that React `<VirtualScroller/>` wouldn't render at all
104
+ // on server side, while with the `getItemsContainerElement()` approach, on server side,
105
+ // it still "renders" a list with a predefined amount of items in it by default.
106
+ // (`initiallyRenderedItemsCount`, or `1`).
107
+ this.getItemsContainerElement = getItemsContainerElement
108
+ this.itemsContainer = engine.createItemsContainer(getItemsContainerElement)
109
+
110
+ // Remove any accidental text nodes from container (like whitespace).
111
+ // Also guards against cases when someone accidentally tries
112
+ // using `VirtualScroller` on a non-empty element.
113
+ if (getItemsContainerElement()) {
114
+ this.itemsContainer.clear()
94
115
  }
95
116
 
96
- this.screen = renderingEngine.createScreen()
97
- this.scrollableContainer = renderingEngine.createScrollableContainer(scrollableContainer)
117
+ this.scrollableContainer = engine.createScrollableContainer(
118
+ scrollableContainer,
119
+ getItemsContainerElement
120
+ )
98
121
 
99
- // if (margin === undefined) {
100
- // // Renders items which are outside of the screen by this "margin".
122
+ // if (prerenderMargin === undefined) {
123
+ // // Renders items which are outside of the screen by this "prerender margin".
101
124
  // // Is the screen height by default: seems to be the optimal value
102
125
  // // for "Page Up" / "Page Down" navigation and optimized mouse wheel scrolling.
103
- // margin = this.scrollableContainer ? this.scrollableContainer.getHeight() : 0
126
+ // prerenderMargin = this.scrollableContainer ? this.scrollableContainer.getHeight() : 0
104
127
  // }
105
128
 
106
129
  // Work around `<tbody/>` not being able to have `padding`.
107
130
  // https://gitlab.com/catamphetamine/virtual-scroller/-/issues/1
108
131
  if (tbody) {
109
- if (renderingEngine.name !== 'DOM') {
110
- throw new Error('`tbody` option is only supported for DOM rendering engine')
132
+ if (engine !== DOMEngine) {
133
+ throw new Error('[virtual-scroller] `tbody` option is only supported for DOM rendering engine')
111
134
  }
112
135
  log('~ <tbody/> detected ~')
113
136
  this.tbody = true
@@ -150,7 +173,7 @@ export default class VirtualScroller {
150
173
  }
151
174
 
152
175
  this.initialItems = items
153
- // this.margin = margin
176
+ // this.prerenderMargin = prerenderMargin
154
177
 
155
178
  this.onStateChange = onStateChange
156
179
 
@@ -186,20 +209,33 @@ export default class VirtualScroller {
186
209
  log('Estimated item height', estimatedItemHeight)
187
210
  }
188
211
 
189
- if (setState) {
190
- this.getState = getState
191
- this.setState = (state) => {
192
- log('Set state', state)
193
- setState(state, {
194
- willUpdateState: this.willUpdateState,
195
- didUpdateState: this.didUpdateState
196
- })
197
- }
198
- } else {
199
- this.getState = () => this.state
200
- this.setState = (state) => {
201
- log('Set state', state)
202
- const prevState = this.getState()
212
+ // There're three main places where state is updated:
213
+ //
214
+ // * On scroll.
215
+ // * On window resize.
216
+ // * On set new items.
217
+ //
218
+ // State updates may be "asynchronous" (like in React), in which case the
219
+ // corresponding operation is "pending" until the state update is applied.
220
+ //
221
+ // If there's a "pending" window resize or a "pending" update of the set of items,
222
+ // then "on scroll" updates aren't dispatched.
223
+ //
224
+ // If there's a "pending" on scroll update and the window is resize or a new set
225
+ // of items is set, then that "pending" on scroll update gets overwritten.
226
+ //
227
+ // If there's a "pending" update of the set of items, then window resize handler
228
+ // sees that "pending" update and dispatches its own state update so that the
229
+ // "pending" state update originating from `setItems()` is not lost.
230
+ //
231
+ // If there's a "pending" window resize, and a new set of items is set,
232
+ // then the state update of the window resize handler gets overwritten.
233
+
234
+ // Create default `getState()`/`setState()` functions.
235
+ if (!getState) {
236
+ getState = () => this.state
237
+ setState = (stateUpdate, { willUpdateState, didUpdateState }) => {
238
+ const prevState = getState()
203
239
  // Because this variant of `.setState()` is "synchronous" (immediate),
204
240
  // it can be written like `...prevState`, and no state updates would be lost.
205
241
  // But if it was "asynchronous" (not immediate), then `...prevState`
@@ -208,40 +244,82 @@ export default class VirtualScroller {
208
244
  // the state actually updates, making `prevState` stale.
209
245
  const newState = {
210
246
  ...prevState,
211
- ...state
247
+ ...stateUpdate
212
248
  }
213
- this.willUpdateState(newState, prevState)
249
+ willUpdateState(newState, prevState)
214
250
  this.state = newState
215
- this.didUpdateState(prevState)
251
+ // // Is only used in tests.
252
+ // if (this._onStateUpdate) {
253
+ // this._onStateUpdate(stateUpdate)
254
+ // }
255
+ didUpdateState(prevState)
216
256
  }
217
257
  }
218
258
 
259
+ this.getState = getState
260
+ this.setState = (stateUpdate) => {
261
+ if (isDebug()) {
262
+ log('Set state', getStateSnapshot(stateUpdate))
263
+ }
264
+ setState(stateUpdate, {
265
+ willUpdateState: this.willUpdateState,
266
+ didUpdateState: this.didUpdateState
267
+ })
268
+ }
269
+
219
270
  if (state) {
220
- log('Initial state (passed)', state)
271
+ if (isDebug()) {
272
+ log('Initial state (passed)', getStateSnapshot(state))
273
+ }
221
274
  }
222
275
 
223
- // Sometimes, when `new VirtualScroller()` instance is created,
224
- // `getContainerElement()` might not be ready to return the "container" DOM Element yet
225
- // (for example, because it's not rendered yet). That's the reason why it's a getter function.
226
- // For example, in React `<VirtualScroller/>` component, a `VirtualScroller`
227
- // instance is created in the React component's `constructor()`, and at that time
228
- // the container Element is not yet available. The container Element is available
229
- // in `componentDidMount()`, but `componentDidMount()` is not executed on server,
230
- // which would mean that React `<VirtualScroller/>` wouldn't render at all
231
- // on server side, while with the `getContainerElement()` approach, on server side,
232
- // it still "renders" a list with a predefined amount of items in it by default.
233
- // (`initiallyRenderedItemsCount`, or `1`).
234
- this.getContainerElement = getContainerElement
235
- // Remove any accidental text nodes from container (like whitespace).
236
- // Also guards against cases when someone accidentally tries
237
- // using `VirtualScroller` on a non-empty element.
238
- if (getContainerElement()) {
239
- this.screen.clearElement(getContainerElement())
276
+ // Check if the current `columnsCount` matches the one from state.
277
+ // For example, a developer might snapshot `VirtualScroller` state
278
+ // when the user navigates from the page containing the list
279
+ // in order to later restore the list's state when the user goes "Back".
280
+ // But, the user might have also resized the window while being on that
281
+ // "other" page, and when they come "Back", their snapshotted state
282
+ // no longer qualifies. Well, it does qualify, but only partially.
283
+ // For example, `itemStates` are still valid, but first and last shown
284
+ // item indexes aren't.
285
+ if (state) {
286
+ let shouldResetLayout
287
+ const columnsCountForState = this.getActualColumnsCountForState()
288
+ if (columnsCountForState !== state.columnsCount) {
289
+ warn('~ Columns Count changed from', state.columnsCount || 1, 'to', columnsCountForState || 1, '~')
290
+ shouldResetLayout = true
291
+ }
292
+ const columnsCount = this.getActualColumnsCount()
293
+ const firstShownItemIndex = Math.floor(state.firstShownItemIndex / columnsCount) * columnsCount
294
+ if (firstShownItemIndex !== state.firstShownItemIndex) {
295
+ warn('~ First Shown Item Index', state.firstShownItemIndex, 'is not divisible by Columns Count', columnsCount, '~')
296
+ shouldResetLayout = true
297
+ }
298
+ if (shouldResetLayout) {
299
+ warn('Reset Layout')
300
+ state = {
301
+ ...state,
302
+ ...this.getInitialLayoutState(state.items)
303
+ }
304
+ }
240
305
  }
241
306
 
307
+ // Reset `verticalSpacing` so that it re-measures it after the list
308
+ // has been rendered initially. The rationale is that the `state`
309
+ // can't be "trusted" in a sense that the user might have resized
310
+ // their window after the `state` has been snapshotted, and changing
311
+ // window width might have activated different CSS `@media()` "queries"
312
+ // resulting in a potentially different vertical spacing.
313
+ if (state) {
314
+ state = {
315
+ ...state,
316
+ verticalSpacing: undefined
317
+ }
318
+ }
319
+
320
+ // Create `ItemHeights` instance.
242
321
  this.itemHeights = new ItemHeights(
243
- this.screen,
244
- this.getContainerElement,
322
+ this.itemsContainer,
245
323
  (i) => this.getState().itemHeights[i],
246
324
  (i, height) => this.getState().itemHeights[i] = height
247
325
  )
@@ -255,62 +333,113 @@ export default class VirtualScroller {
255
333
  bypass,
256
334
  estimatedItemHeight,
257
335
  measureItemsBatchSize: measureItemsBatchSize === undefined ? 50 : measureItemsBatchSize,
336
+ getPrerenderMargin: () => this.getPrerenderMargin(),
258
337
  getVerticalSpacing: () => this.getVerticalSpacing(),
338
+ getVerticalSpacingBeforeResize: () => this.getVerticalSpacingBeforeResize(),
259
339
  getColumnsCount: () => this.getColumnsCount(),
340
+ getColumnsCountBeforeResize: () => this.getState().beforeResize && this.getState().beforeResize.columnsCount,
260
341
  getItemHeight: (i) => this.getState().itemHeights[i],
261
- getAverageItemHeight: () => this.itemHeights.getAverage()
342
+ getItemHeightBeforeResize: (i) => this.getState().beforeResize && this.getState().beforeResize.itemHeights[i],
343
+ getBeforeResizeItemsCount: () => this.getState().beforeResize ? this.getState().beforeResize.itemHeights.length : 0,
344
+ getAverageItemHeight: () => this.itemHeights.getAverage(),
345
+ getMaxVisibleAreaHeight: () => this.scrollableContainer && this.scrollableContainer.getHeight(),
346
+ //
347
+ // The "previously calculated layout" feature is not currently used.
348
+ //
349
+ // The current layout snapshot could be stored as a "previously calculated layout" variable
350
+ // so that it could theoretically be used when calculating new layout incrementally
351
+ // rather than from scratch, which would be an optimization.
352
+ //
353
+ getPreviouslyCalculatedLayout: () => this.previouslyCalculatedLayout
262
354
  })
263
355
 
264
356
  this.resize = new Resize({
265
357
  bypass,
266
358
  scrollableContainer: this.scrollableContainer,
267
- getContainerElement: this.getContainerElement,
268
- updateLayout: ({ reason }) => this.onUpdateShownItemIndexes({ reason }),
269
- resetStateAndLayout: () => {
270
- // Reset item heights, because if scrollable container's width (or height)
271
- // has changed, then the list width (or height) most likely also has changed,
272
- // and also some CSS `@media()` rules might have been added or removed.
273
- // So re-render the list entirely.
274
- log('~ Scrollable container size changed, re-measure item heights. ~')
275
- this.redoLayoutReason = LAYOUT_REASON.RESIZE
276
- // `this.layoutResetPending` flag will be cleared in `didUpdateState()`.
277
- this.layoutResetPending = true
278
- log('Reset state')
279
- // Calling `this.setState(state)` will trigger `didUpdateState()`.
280
- // `didUpdateState()` will detect `this.redoLayoutReason`.
281
- this.setState(this.getInitialLayoutState(this.newItemsPending || this.getState().items))
359
+ onStart: () => {
360
+ log('~ Scrollable container resize started ~')
361
+ this.isResizing = true
362
+ },
363
+ onStop: () => {
364
+ log('~ Scrollable container resize finished ~')
365
+ this.isResizing = undefined
366
+ },
367
+ onNoChange: () => {
368
+ // There might have been some missed `this.onUpdateShownItemIndexes()` calls
369
+ // due to setting `this.isResizing` flag to `true` during the resize.
370
+ // So, update shown item indexes just in case.
371
+ this.onUpdateShownItemIndexes({
372
+ reason: LAYOUT_REASON.VIEWPORT_SIZE_UNCHANGED
373
+ })
374
+ },
375
+ onHeightChange: () => this.onUpdateShownItemIndexes({
376
+ reason: LAYOUT_REASON.VIEWPORT_HEIGHT_CHANGED
377
+ }),
378
+ onWidthChange: (prevWidth, newWidth) => {
379
+ log('~ Scrollable container width changed from', prevWidth, 'to', newWidth, '~')
380
+ this.onResize()
282
381
  }
283
382
  })
284
383
 
285
- if (preserveScrollPositionAtBottomOnMount) {
286
- warn('`preserveScrollPositionAtBottomOnMount` option/property has been renamed to `preserveScrollPositionOfTheBottomOfTheListOnMount`')
287
- }
288
-
289
- this.preserveScrollPositionOfTheBottomOfTheListOnMount = preserveScrollPositionOfTheBottomOfTheListOnMount || preserveScrollPositionAtBottomOnMount
290
-
291
384
  this.scroll = new Scroll({
292
385
  bypass: this.bypass,
293
386
  scrollableContainer: this.scrollableContainer,
294
- updateLayout: ({ reason }) => this.onUpdateShownItemIndexes({ reason }),
387
+ itemsContainer: this.itemsContainer,
388
+ waitForScrollingToStop: _waitForScrollingToStop,
389
+ onScroll: ({ delayed } = {}) => {
390
+ this.onUpdateShownItemIndexes({
391
+ reason: delayed ? LAYOUT_REASON.STOPPED_SCROLLING : LAYOUT_REASON.SCROLL
392
+ })
393
+ },
295
394
  initialScrollPosition,
296
395
  onScrollPositionChange,
297
396
  isImmediateLayoutScheduled: () => this.layoutTimer,
298
397
  hasNonRenderedItemsAtTheTop: () => this.getState().firstShownItemIndex > 0,
299
398
  hasNonRenderedItemsAtTheBottom: () => this.getState().lastShownItemIndex < this.getItemsCount() - 1,
300
- getLatestLayoutVisibleAreaIncludingMargins: () => this.latestLayoutVisibleAreaIncludingMargins,
301
- preserveScrollPositionOfTheBottomOfTheListOnMount: this.preserveScrollPositionOfTheBottomOfTheListOnMount
399
+ getLatestLayoutVisibleArea: () => this.latestLayoutVisibleArea,
400
+ getListTopOffset: this.getListTopOffsetInsideScrollableContainer,
401
+ getPrerenderMargin: () => this.getPrerenderMargin()
302
402
  })
303
403
 
304
- this.restoreScroll = new RestoreScroll({
305
- screen: this.screen,
306
- getContainerElement: this.getContainerElement
404
+ this.listHeightChangeWatcher = new ListHeightChangeWatcher({
405
+ itemsContainer: this.itemsContainer,
406
+ getListTopOffset: this.getListTopOffsetInsideScrollableContainer
307
407
  })
308
408
 
309
- this.waitForStylesToLoad = new WaitForStylesToLoad({
310
- updateLayout: ({ reason }) => this.onUpdateShownItemIndexes({ reason }),
311
- getListTopOffsetInsideScrollableContainer: this.getListTopOffsetInsideScrollableContainer
409
+ if (engine.watchListTopOffset) {
410
+ this.listTopOffsetWatcher = engine.watchListTopOffset({
411
+ getListTopOffset: this.getListTopOffsetInsideScrollableContainer,
412
+ onListTopOffsetChange: ({ reason }) => this.onUpdateShownItemIndexes({
413
+ reason: LAYOUT_REASON.TOP_OFFSET_CHANGED
414
+ })
415
+ })
416
+ }
417
+
418
+ this.beforeResize = new BeforeResize({
419
+ getState: this.getState,
420
+ getVerticalSpacing: this.getVerticalSpacing,
421
+ getColumnsCount: this.getColumnsCount
312
422
  })
313
423
 
424
+ // Possibly clean up "before resize" property in state.
425
+ // "Before resize" state property is cleaned up when all "before resize" item heights
426
+ // have been re-measured in an asynchronous `this.setState({ beforeResize: undefined })` call.
427
+ // If `VirtualScroller` state was snapshotted externally before that `this.setState()` call
428
+ // has been applied, then "before resize" property might have not been cleaned up properly.
429
+ this.beforeResize.onInitialState(state)
430
+
431
+ // `this.verticalSpacing` acts as a "true" source for vertical spacing value.
432
+ // Vertical spacing is also stored in `state` but `state` updates could be
433
+ // "asynchronous" (not applied immediately) and `this.onUpdateShownItemIndexes()`
434
+ // requires vertical spacing to be correct at any time, without any delays.
435
+ // So, vertical spacing is also duplicated in `state`, but the "true" source
436
+ // is still `this.verticalSpacing`.
437
+ //
438
+ // `this.verticalSpacing` must be initialized before calling `this.getInitialState()`.
439
+ //
440
+ this.verticalSpacing = state ? state.verticalSpacing : undefined
441
+
442
+ // Set initial `state`.
314
443
  this.setState(state || this.getInitialState(customState))
315
444
  }
316
445
 
@@ -327,20 +456,14 @@ export default class VirtualScroller {
327
456
  items,
328
457
  itemStates: new Array(items.length)
329
458
  }
330
- log('Initial state (autogenerated)', state)
459
+ if (isDebug()) {
460
+ log('Initial state (autogenerated)', getStateSnapshot(state))
461
+ }
331
462
  log('First shown item index', state.firstShownItemIndex)
332
463
  log('Last shown item index', state.lastShownItemIndex)
333
464
  return state
334
465
  }
335
466
 
336
- getInitialLayoutValues({ itemsCount, bypass }) {
337
- return this.layout.getInitialLayoutValues({
338
- bypass,
339
- itemsCount,
340
- visibleAreaHeightIncludingMargins: this.scrollableContainer && (2 * this.getMargin() + this.scrollableContainer.getHeight())
341
- })
342
- }
343
-
344
467
  getInitialLayoutState(items) {
345
468
  const itemsCount = items.length
346
469
  const {
@@ -348,9 +471,9 @@ export default class VirtualScroller {
348
471
  lastShownItemIndex,
349
472
  beforeItemsHeight,
350
473
  afterItemsHeight
351
- } = this.getInitialLayoutValues({
474
+ } = this.layout.getInitialLayoutValues({
352
475
  itemsCount,
353
- bypass: this.preserveScrollPositionOfTheBottomOfTheListOnMount
476
+ columnsCount: this.getColumnsCount()
354
477
  })
355
478
  const itemHeights = new Array(itemsCount)
356
479
  // Optionally preload items to be rendered.
@@ -360,15 +483,10 @@ export default class VirtualScroller {
360
483
  firstShownItemIndex,
361
484
  lastShownItemIndex
362
485
  )
363
- // This "initial" state object must include all possible state properties
364
- // because `this.setState()` gets called with this state on window resize,
365
- // when `VirtualScroller` gets reset.
366
- // Item states aren't included here because the state of all items should be
367
- // preserved on window resize.
368
486
  return {
369
487
  itemHeights,
370
- columnsCount: this._getColumnsCount ? this._getColumnsCount(this.scrollableContainer) : undefined,
371
- verticalSpacing: undefined,
488
+ columnsCount: this.getActualColumnsCountForState(),
489
+ verticalSpacing: this.verticalSpacing,
372
490
  firstShownItemIndex,
373
491
  lastShownItemIndex,
374
492
  beforeItemsHeight,
@@ -376,8 +494,29 @@ export default class VirtualScroller {
376
494
  }
377
495
  }
378
496
 
379
- getVerticalSpacing() {
380
- return this.getState() && this.getState().verticalSpacing || 0
497
+ // Bind to `this` in order to prevent bugs when this function is passed by reference
498
+ // and then called with its `this` being unintentionally `window` resulting in
499
+ // the `if` condition being "falsy".
500
+ getActualColumnsCountForState = () => {
501
+ return this._getColumnsCount ? this._getColumnsCount(this.scrollableContainer) : undefined
502
+ }
503
+
504
+ getActualColumnsCount() {
505
+ return this.getActualColumnsCountForState() || 1
506
+ }
507
+
508
+ // Bind to `this` in order to prevent bugs when this function is passed by reference
509
+ // and then called with its `this` being unintentionally `window` resulting in
510
+ // the `if` condition being "falsy".
511
+ getVerticalSpacing = () => {
512
+ return this.verticalSpacing || 0
513
+ }
514
+
515
+ getVerticalSpacingBeforeResize() {
516
+ // `beforeResize.verticalSpacing` can be `undefined`.
517
+ // For example, if `this.setState({ verticalSpacing })` call hasn't been applied
518
+ // before the resize happened (in case of an "asynchronous" state update).
519
+ return this.getState().beforeResize && this.getState().beforeResize.verticalSpacing || 0
381
520
  }
382
521
 
383
522
  getColumnsCount() {
@@ -388,14 +527,18 @@ export default class VirtualScroller {
388
527
  return this.getState().items.length
389
528
  }
390
529
 
391
- getMargin() {
392
- // `VirtualScroller` also items that are outside of the screen
393
- // by the amount of this "render ahead margin" (both on top and bottom).
394
- // The default "render ahead margin" is equal to the screen height:
530
+ getPrerenderMargin() {
531
+ // The list component renders not only the items that're currently visible
532
+ // but also the items that lie within some extra vertical margin (called
533
+ // "prerender margin") on top and bottom for future scrolling: this way,
534
+ // there'll be significantly less layout recalculations as the user scrolls,
535
+ // because now it doesn't have to recalculate layout on each scroll event.
536
+ // By default, the "prerender margin" is equal to the screen height:
395
537
  // this seems to be the optimal value for "Page Up" / "Page Down" navigation
396
538
  // and optimized mouse wheel scrolling (a user is unlikely to continuously
397
- // scroll past the height of a screen, and when they stop scrolling,
398
- // the list is re-rendered).
539
+ // scroll past the screen height, because they'd stop to read through
540
+ // the newly visible items first, and when they do stop scrolling, that's
541
+ // when layout gets recalculated).
399
542
  const renderAheadMarginRatio = 1 // in scrollable container heights.
400
543
  return this.scrollableContainer.getHeight() * renderAheadMarginRatio
401
544
  }
@@ -442,52 +585,69 @@ export default class VirtualScroller {
442
585
  if (this.isRendered === false) {
443
586
  throw new Error('[virtual-scroller] Can\'t restart a `VirtualScroller` after it has been stopped')
444
587
  }
588
+
445
589
  log('~ Rendered (initial) ~')
446
590
  // `this.isRendered = true` should be the first statement in this function,
447
591
  // otherwise `DOMVirtualScroller` would enter an infinite re-render loop.
448
592
  this.isRendered = true
449
- this.onRenderedNewLayout()
593
+
594
+ const stateUpdate = this.measureItemHeightsAndSpacingAndUpdateTablePadding()
595
+
450
596
  this.resize.listen()
451
597
  this.scroll.listen()
598
+
452
599
  // Work around `<tbody/>` not being able to have `padding`.
453
600
  // https://gitlab.com/catamphetamine/virtual-scroller/-/issues/1
454
601
  if (this.tbody) {
455
- addTbodyStyles(this.getContainerElement())
456
- }
457
- if (this.preserveScrollPositionOfTheBottomOfTheListOnMount) {
458
- // In this case, all items are shown, so there's no need to call
459
- // `this.onUpdateShownItemIndexes()` after the initial render.
460
- } else {
461
- this.onUpdateShownItemIndexes({ reason: LAYOUT_REASON.MOUNT })
602
+ addTbodyStyles(this.getItemsContainerElement())
462
603
  }
604
+
605
+ // Re-calculate layout and re-render the list.
606
+ // Do that even if when an initial `state` parameter, containing layout values,
607
+ // has been passed. The reason is that the `state` parameter can't be "trusted"
608
+ // in a way that it could have been snapshotted for another window width and
609
+ // the user might have resized their window since then.
610
+ this.onUpdateShownItemIndexes({ reason: LAYOUT_REASON.MOUNTED, stateUpdate })
463
611
  }
464
612
 
465
- onRenderedNewLayout() {
466
- // Update item vertical spacing.
467
- this.measureVerticalSpacing()
613
+ measureItemHeightsAndSpacingAndUpdateTablePadding() {
468
614
  // Measure "newly shown" item heights.
469
615
  // Also re-validate already measured items' heights.
470
616
  this.itemHeights.measureItemHeights(
471
617
  this.getState().firstShownItemIndex,
472
618
  this.getState().lastShownItemIndex
473
619
  )
620
+
621
+ // Update item vertical spacing.
622
+ const verticalSpacing = this.measureVerticalSpacing()
623
+
474
624
  // Update `<tbody/>` `padding`.
475
625
  // (`<tbody/>` is different in a way that it can't have `margin`, only `padding`).
476
626
  // https://gitlab.com/catamphetamine/virtual-scroller/-/issues/1
477
627
  if (this.tbody) {
478
628
  setTbodyPadding(
479
- this.getContainerElement(),
629
+ this.getItemsContainerElement(),
480
630
  this.getState().beforeItemsHeight,
481
631
  this.getState().afterItemsHeight
482
632
  )
483
633
  }
634
+
635
+ // Return a state update.
636
+ if (verticalSpacing !== undefined) {
637
+ return { verticalSpacing }
638
+ }
484
639
  }
485
640
 
486
- getVisibleAreaBoundsIncludingMargins() {
641
+ getVisibleArea() {
487
642
  const visibleArea = this.scroll.getVisibleAreaBounds()
488
- visibleArea.top -= this.getMargin()
489
- visibleArea.bottom += this.getMargin()
490
- return visibleArea
643
+ this.latestLayoutVisibleArea = visibleArea
644
+
645
+ // Subtract the top offset of the list inside the scrollable container.
646
+ const listTopOffsetInsideScrollableContainer = this.getListTopOffsetInsideScrollableContainer()
647
+ return {
648
+ top: visibleArea.top - listTopOffsetInsideScrollableContainer,
649
+ bottom: visibleArea.bottom - listTopOffsetInsideScrollableContainer
650
+ }
491
651
  }
492
652
 
493
653
  /**
@@ -495,11 +655,26 @@ export default class VirtualScroller {
495
655
  * @return {number}
496
656
  */
497
657
  getListTopOffsetInsideScrollableContainer = () => {
498
- const listTopOffset = this.scrollableContainer.getTopOffset(this.getContainerElement())
499
- this.waitForStylesToLoad.onGotListTopOffset(listTopOffset)
658
+ const listTopOffset = this.scrollableContainer.getItemsContainerTopOffset()
659
+ if (this.listTopOffsetWatcher) {
660
+ this.listTopOffsetWatcher.onListTopOffset(listTopOffset)
661
+ }
500
662
  return listTopOffset
501
663
  }
502
664
 
665
+ /**
666
+ * Returns the items's top offset relative to the scrollable container's top edge.
667
+ * @param {number} i — Item index
668
+ * @return {[number]} Returns the item's scroll Y position. Returns `undefined` if any of the previous items haven't been rendered yet.
669
+ */
670
+ getItemScrollPosition(i) {
671
+ const itemTopOffsetInList = this.layout.getItemTopOffset(i)
672
+ if (itemTopOffsetInList === undefined) {
673
+ return
674
+ }
675
+ return this.getListTopOffsetInsideScrollableContainer() + itemTopOffsetInList
676
+ }
677
+
503
678
  onUnmount() {
504
679
  warn('`.onUnmount()` instance method name is deprecated, use `.stop()` instance method name instead.')
505
680
  this.stop()
@@ -510,17 +685,46 @@ export default class VirtualScroller {
510
685
  this.stop()
511
686
  }
512
687
 
513
- stop() {
688
+ stop = () => {
514
689
  this.isRendered = false
515
690
  this.resize.stop()
516
691
  this.scroll.stop()
517
- this.waitForStylesToLoad.stop()
692
+ if (this.listTopOffsetWatcher) {
693
+ this.listTopOffsetWatcher.stop()
694
+ }
695
+ this.cancelLayoutTimer({})
696
+ }
697
+
698
+ cancelLayoutTimer({ stateUpdate }) {
518
699
  if (this.layoutTimer) {
519
700
  clearTimeout(this.layoutTimer)
520
701
  this.layoutTimer = undefined
702
+ // Merge state updates.
703
+ if (stateUpdate || this.layoutTimerStateUpdate) {
704
+ stateUpdate = {
705
+ ...this.layoutTimerStateUpdate,
706
+ ...stateUpdate
707
+ }
708
+ this.layoutTimerStateUpdate = undefined
709
+ return stateUpdate
710
+ }
711
+ } else {
712
+ return stateUpdate
521
713
  }
522
714
  }
523
715
 
716
+ scheduleLayoutTimer({ reason, stateUpdate }) {
717
+ this.layoutTimerStateUpdate = stateUpdate
718
+ this.layoutTimer = setTimeout(() => {
719
+ this.layoutTimerStateUpdate = undefined
720
+ this.layoutTimer = undefined
721
+ this.onUpdateShownItemIndexes({
722
+ reason,
723
+ stateUpdate
724
+ })
725
+ }, 0)
726
+ }
727
+
524
728
  /**
525
729
  * Should be called right before `state` is updated.
526
730
  * @param {object} prevState
@@ -542,73 +746,277 @@ export default class VirtualScroller {
542
746
  */
543
747
  didUpdateState = (prevState) => {
544
748
  const newState = this.getState()
749
+
545
750
  if (this.onStateChange) {
546
751
  if (!shallowEqual(newState, prevState)) {
547
752
  this.onStateChange(newState, prevState)
548
753
  }
549
754
  }
755
+
550
756
  // Ignore setting initial state.
551
757
  if (!prevState) {
552
758
  return
553
759
  }
760
+
554
761
  if (!this.isRendered) {
555
762
  return
556
763
  }
764
+
557
765
  log('~ Rendered ~')
558
- this.newItemsPending = undefined
559
- this.layoutResetPending = undefined
560
- let redoLayoutReason = this.redoLayoutReason
561
- this.redoLayoutReason = undefined
766
+ if (isDebug()) {
767
+ log('State', getStateSnapshot(newState))
768
+ }
769
+
770
+ let layoutUpdateReason
771
+
772
+ if (this.firstNonMeasuredItemIndex !== undefined) {
773
+ layoutUpdateReason = LAYOUT_REASON.ACTUAL_ITEM_HEIGHTS_HAVE_BEEN_MEASURED
774
+ }
775
+
776
+ if (this.resetLayoutAfterResize) {
777
+ layoutUpdateReason = LAYOUT_REASON.VIEWPORT_WIDTH_CHANGED
778
+ }
779
+
780
+ // If `this.resetLayoutAfterResize` flag was reset after calling
781
+ // `this.measureItemHeightsAndSpacingAndUpdateTablePadding()`
782
+ // then there would be a bug because
783
+ // `this.measureItemHeightsAndSpacingAndUpdateTablePadding()`
784
+ // calls `this.setState({ verticalSpacing })` which calls
785
+ // `this.didUpdateState()` immediately, so `this.resetLayoutAfterResize`
786
+ // flag wouldn't be reset by that time and would trigger things
787
+ // like `this.itemHeights.reset()` a second time.
788
+ //
789
+ // So, instead read the value of `this.resetLayoutAfterResize` flag
790
+ // and reset it right away to prevent any such potential bugs.
791
+ //
792
+ const resetLayoutAfterResize = this.resetLayoutAfterResize
793
+
794
+ // Reset `this.firstNonMeasuredItemIndex`.
795
+ this.firstNonMeasuredItemIndex = undefined
796
+
797
+ // Reset `this.resetLayoutAfterResize` flag.
798
+ this.resetLayoutAfterResize = undefined
799
+
800
+ // Reset `this.newItemsWillBeRendered` flag.
801
+ this.newItemsWillBeRendered = undefined
802
+
803
+ // Reset `this.itemHeightsThatChangedWhileNewItemsWereBeingRendered`.
804
+ this.itemHeightsThatChangedWhileNewItemsWereBeingRendered = undefined
805
+
806
+ // Reset `this.itemStatesThatChangedWhileNewItemsWereBeingRendered`.
807
+ this.itemStatesThatChangedWhileNewItemsWereBeingRendered = undefined
808
+
809
+ if (resetLayoutAfterResize) {
810
+ // Reset measured item heights on viewport width change.
811
+ this.itemHeights.reset()
812
+
813
+ // Reset `verticalSpacing` (will be re-measured).
814
+ this.verticalSpacing = undefined
815
+ }
816
+
562
817
  const { items: previousItems } = prevState
563
818
  const { items: newItems } = newState
819
+ // Even if `this.newItemsWillBeRendered` flag is `true`,
820
+ // `newItems` could still be equal to `previousItems`.
821
+ // For example, when `setState()` calls don't update `state` immediately
822
+ // and a developer first calls `setItems(newItems)` and then calls `setItems(oldItems)`:
823
+ // in that case, `this.newItemsWillBeRendered` flag will be `true` but the actual `items`
824
+ // in state wouldn't have changed due to the first `setState()` call being overwritten
825
+ // by the second `setState()` call (that's called "batching state updates" in React).
564
826
  if (newItems !== previousItems) {
565
- let layoutNeedsReCalculating = true
566
827
  const itemsDiff = this.getItemsDiff(previousItems, newItems)
567
- // If it's an "incremental" update.
568
828
  if (itemsDiff) {
569
- const {
570
- prependedItemsCount,
571
- appendedItemsCount
572
- } = itemsDiff
573
- if (prependedItemsCount > 0) {
574
- // The call to `.onPrepend()` must precede
575
- // the call to `.measureItemHeights()`
576
- // which is called in `.onRendered()`.
577
- this.itemHeights.onPrepend(prependedItemsCount)
578
- if (this.restoreScroll.shouldRestoreScrollAfterRender()) {
579
- layoutNeedsReCalculating = false
580
- log('~ Restore Scroll Position ~')
581
- const scrollByY = this.restoreScroll.getScrollDifference()
582
- if (scrollByY) {
583
- log('Scroll down by', scrollByY)
584
- this.scroll.scrollByY(scrollByY)
585
- } else {
586
- log('Scroll position hasn\'t changed')
587
- }
588
- }
589
- }
829
+ // The call to `.onPrepend()` must precede the call to `.measureItemHeights()`
830
+ // which is called in `.onRendered()`.
831
+ // `this.itemHeights.onPrepend()` updates `firstMeasuredItemIndex`
832
+ // and `lastMeasuredItemIndex` of `this.itemHeights`.
833
+ const { prependedItemsCount } = itemsDiff
834
+ this.itemHeights.onPrepend(prependedItemsCount)
590
835
  } else {
591
836
  this.itemHeights.reset()
592
- this.itemHeights.initialize(this.getState().itemHeights)
837
+ // `newState.itemHeights` is an array of `undefined`s.
838
+ this.itemHeights.initialize(newState.itemHeights)
593
839
  }
594
- if (layoutNeedsReCalculating) {
595
- redoLayoutReason = LAYOUT_REASON.ITEMS_CHANGED
840
+
841
+ if (!resetLayoutAfterResize) {
842
+ // The call to `this.onNewItemsRendered()` must precede the call to
843
+ // `.measureItemHeights()` which is called in `.onRendered()` because
844
+ // `this.onNewItemsRendered()` updates `firstMeasuredItemIndex` and
845
+ // `lastMeasuredItemIndex` of `this.itemHeights` in case of a prepend.
846
+ //
847
+ // If after prepending items the scroll position
848
+ // should be "restored" so that there's no "jump" of content
849
+ // then it means that all previous items have just been rendered
850
+ // in a single pass, and there's no need to update layout again.
851
+ //
852
+ if (this.onNewItemsRendered(itemsDiff, newState) !== 'SEAMLESS_PREPEND') {
853
+ layoutUpdateReason = LAYOUT_REASON.ITEMS_CHANGED
854
+ }
596
855
  }
597
856
  }
598
- // Call `.onRendered()` if shown items configuration changed.
599
- if (newState.firstShownItemIndex !== prevState.firstShownItemIndex ||
857
+
858
+ let stateUpdate
859
+
860
+ // Re-measure item heights.
861
+ // Also, measure vertical spacing (if not measured) and fix `<table/>` padding.
862
+ //
863
+ // This block should go after `if (newItems !== previousItems) {}`
864
+ // because `this.itemHeights` can get `.reset()` there, which would
865
+ // discard all the measurements done here, and having currently shown
866
+ // item height measurements is required.
867
+ //
868
+ if (
869
+ newState.firstShownItemIndex !== prevState.firstShownItemIndex ||
600
870
  newState.lastShownItemIndex !== prevState.lastShownItemIndex ||
601
- newState.items !== prevState.items) {
602
- this.onRenderedNewLayout()
871
+ newState.items !== prevState.items ||
872
+ resetLayoutAfterResize
873
+ ) {
874
+ const verticalSpacingStateUpdate = this.measureItemHeightsAndSpacingAndUpdateTablePadding()
875
+ if (verticalSpacingStateUpdate) {
876
+ stateUpdate = {
877
+ ...stateUpdate,
878
+ ...verticalSpacingStateUpdate
879
+ }
880
+ }
881
+ }
882
+
883
+ // Clean up "before resize" item heights and adjust the scroll position accordingly.
884
+ // Calling `this.beforeResize.cleanUpBeforeResizeItemHeights()` might trigger
885
+ // a `this.setState()` call but that wouldn't matter because `beforeResize`
886
+ // properties have already been modified directly in `state` (a hacky technique)
887
+ const cleanedUpBeforeResize = this.beforeResize.cleanUpBeforeResizeItemHeights(prevState)
888
+ if (cleanedUpBeforeResize !== undefined) {
889
+ const { scrollBy, beforeResize } = cleanedUpBeforeResize
890
+ log('Correct scroll position by', scrollBy)
891
+ this.scroll.scrollByY(scrollBy)
892
+ stateUpdate = {
893
+ ...stateUpdate,
894
+ beforeResize
895
+ }
603
896
  }
604
- if (redoLayoutReason) {
605
- return this.redoLayoutRightAfterRender({
606
- reason: redoLayoutReason
897
+
898
+ if (layoutUpdateReason) {
899
+ this.updateStateRightAfterRender({
900
+ stateUpdate,
901
+ reason: layoutUpdateReason
607
902
  })
903
+ } else if (stateUpdate) {
904
+ this.setState(stateUpdate)
608
905
  }
609
906
  }
610
907
 
611
- redoLayoutRightAfterRender({ reason }) {
908
+ // After a new set of items has been rendered:
909
+ //
910
+ // * Restores scroll position when using `preserveScrollPositionOnPrependItems`
911
+ // and items have been prepended.
912
+ //
913
+ // * Applies any "pending" `itemHeights` updates — those ones that happened
914
+ // while an asynchronous `setState()` call in `setItems()` was pending.
915
+ //
916
+ // * Either creates or resets the snapshot of the current layout.
917
+ //
918
+ // The current layout snapshot could be stored as a "previously calculated layout" variable
919
+ // so that it could theoretically be used when calculating new layout incrementally
920
+ // rather than from scratch, which would be an optimization.
921
+ //
922
+ // The "previously calculated layout" feature is not currently used.
923
+ //
924
+ onNewItemsRendered(itemsDiff, newLayout) {
925
+ // If it's an "incremental" update.
926
+ if (itemsDiff) {
927
+ const {
928
+ prependedItemsCount,
929
+ appendedItemsCount
930
+ } = itemsDiff
931
+
932
+ const {
933
+ itemHeights,
934
+ itemStates
935
+ } = this.getState()
936
+
937
+ // See if any items' heights changed while new items were being rendered.
938
+ if (this.itemHeightsThatChangedWhileNewItemsWereBeingRendered) {
939
+ for (const i of Object.keys(this.itemHeightsThatChangedWhileNewItemsWereBeingRendered)) {
940
+ itemHeights[prependedItemsCount + parseInt(i)] = this.itemHeightsThatChangedWhileNewItemsWereBeingRendered[i]
941
+ }
942
+ }
943
+
944
+ // See if any items' states changed while new items were being rendered.
945
+ if (this.itemStatesThatChangedWhileNewItemsWereBeingRendered) {
946
+ for (const i of Object.keys(this.itemStatesThatChangedWhileNewItemsWereBeingRendered)) {
947
+ itemStates[prependedItemsCount + parseInt(i)] = this.itemStatesThatChangedWhileNewItemsWereBeingRendered[i]
948
+ }
949
+ }
950
+
951
+ if (prependedItemsCount === 0) {
952
+ // Adjust `this.previouslyCalculatedLayout`.
953
+ if (this.previouslyCalculatedLayout) {
954
+ if (
955
+ this.previouslyCalculatedLayout.firstShownItemIndex === newLayout.firstShownItemIndex &&
956
+ this.previouslyCalculatedLayout.lastShownItemIndex === newLayout.lastShownItemIndex
957
+ ) {
958
+ // `this.previouslyCalculatedLayout` stays the same.
959
+ // `firstShownItemIndex` / `lastShownItemIndex` didn't get changed in `setItems()`,
960
+ // so `beforeItemsHeight` and `shownItemsHeight` also stayed the same.
961
+ } else {
962
+ warn('Unexpected (non-matching) "firstShownItemIndex" or "lastShownItemIndex" encountered in "didUpdateState()" after appending items')
963
+ warn('Previously calculated layout', this.previouslyCalculatedLayout)
964
+ warn('New layout', newLayout)
965
+ this.previouslyCalculatedLayout = undefined
966
+ }
967
+ }
968
+ return 'SEAMLESS_APPEND'
969
+ } else {
970
+ if (this.listHeightChangeWatcher.hasSnapshot()) {
971
+ if (newLayout.firstShownItemIndex === 0) {
972
+ // Restore (adjust) scroll position.
973
+ log('~ Restore Scroll Position ~')
974
+ const listBottomOffsetChange = this.listHeightChangeWatcher.getListBottomOffsetChange({
975
+ beforeItemsHeight: newLayout.beforeItemsHeight
976
+ })
977
+ this.listHeightChangeWatcher.reset()
978
+ if (listBottomOffsetChange) {
979
+ log('Scroll down by', listBottomOffsetChange)
980
+ this.scroll.scrollByY(listBottomOffsetChange)
981
+ } else {
982
+ log('Scroll position hasn\'t changed')
983
+ }
984
+ // Create new `this.previouslyCalculatedLayout`.
985
+ if (this.previouslyCalculatedLayout) {
986
+ if (
987
+ this.previouslyCalculatedLayout.firstShownItemIndex === 0 &&
988
+ this.previouslyCalculatedLayout.lastShownItemIndex === newLayout.lastShownItemIndex - prependedItemsCount
989
+ ) {
990
+ this.previouslyCalculatedLayout = {
991
+ beforeItemsHeight: 0,
992
+ shownItemsHeight: this.previouslyCalculatedLayout.shownItemsHeight + listBottomOffsetChange,
993
+ firstShownItemIndex: 0,
994
+ lastShownItemIndex: newLayout.lastShownItemIndex
995
+ }
996
+ } else {
997
+ warn('Unexpected (non-matching) "firstShownItemIndex" or "lastShownItemIndex" encountered in "didUpdateState()" after prepending items')
998
+ warn('Previously calculated layout', this.previouslyCalculatedLayout)
999
+ warn('New layout', newLayout)
1000
+ this.previouslyCalculatedLayout = undefined
1001
+ }
1002
+ }
1003
+ return 'SEAMLESS_PREPEND'
1004
+ } else {
1005
+ warn(`Unexpected "firstShownItemIndex" ${newLayout.firstShownItemIndex} encountered in "didUpdateState()" after prepending items. Expected 0.`)
1006
+ }
1007
+ }
1008
+ }
1009
+ }
1010
+
1011
+ // Reset `this.previouslyCalculatedLayout` in any case other than
1012
+ // SEAMLESS_PREPEND or SEAMLESS_APPEND.
1013
+ this.previouslyCalculatedLayout = undefined
1014
+ }
1015
+
1016
+ updateStateRightAfterRender({
1017
+ reason,
1018
+ stateUpdate
1019
+ }) {
612
1020
  // In React, `setTimeout()` is used to prevent a React error:
613
1021
  // "Maximum update depth exceeded.
614
1022
  // This can happen when a component repeatedly calls
@@ -616,31 +1024,36 @@ export default class VirtualScroller {
616
1024
  // React limits the number of nested updates to prevent infinite loops."
617
1025
  if (this._useTimeoutInRenderLoop) {
618
1026
  // Cancel a previously scheduled re-layout.
619
- if (this.layoutTimer) {
620
- clearTimeout(this.layoutTimer)
621
- }
1027
+ stateUpdate = this.cancelLayoutTimer({ stateUpdate })
622
1028
  // Schedule a new re-layout.
623
- this.layoutTimer = setTimeout(() => {
624
- this.layoutTimer = undefined
625
- this.onUpdateShownItemIndexes({ reason })
626
- }, 0)
1029
+ this.scheduleLayoutTimer({
1030
+ reason,
1031
+ stateUpdate
1032
+ })
627
1033
  } else {
628
- this.onUpdateShownItemIndexes({ reason })
1034
+ this.onUpdateShownItemIndexes({
1035
+ reason,
1036
+ stateUpdate
1037
+ })
629
1038
  }
630
1039
  }
631
1040
 
632
1041
  measureVerticalSpacing() {
633
- if (this.getState().verticalSpacing === undefined) {
1042
+ if (this.verticalSpacing === undefined) {
1043
+ const { firstShownItemIndex, lastShownItemIndex } = this.getState()
634
1044
  log('~ Measure item vertical spacing ~')
635
1045
  const verticalSpacing = getVerticalSpacing({
636
- container: this.getContainerElement(),
637
- screen: this.screen
1046
+ itemsContainer: this.itemsContainer,
1047
+ renderedItemsCount: lastShownItemIndex - firstShownItemIndex + 1
638
1048
  })
639
1049
  if (verticalSpacing === undefined) {
640
1050
  log('Not enough items rendered to measure vertical spacing')
641
1051
  } else {
642
1052
  log('Item vertical spacing', verticalSpacing)
643
- this.setState({ verticalSpacing })
1053
+ this.verticalSpacing = verticalSpacing
1054
+ if (verticalSpacing !== 0) {
1055
+ return verticalSpacing
1056
+ }
644
1057
  }
645
1058
  }
646
1059
  }
@@ -650,27 +1063,42 @@ export default class VirtualScroller {
650
1063
  return this.itemHeights.remeasureItemHeight(i, firstShownItemIndex)
651
1064
  }
652
1065
 
653
- onItemStateChange(i, itemState) {
1066
+ onItemStateChange(i, newItemState) {
654
1067
  if (isDebug()) {
655
1068
  log('~ Item state changed ~')
656
1069
  log('Item', i)
1070
+ // Uses `JSON.stringify()` here instead of just outputting the JSON objects as is
1071
+ // because outputting JSON objects as is would show different results later when
1072
+ // the developer inspects those in the web browser console if those state objects
1073
+ // get modified in between they've been output to the console and the developer
1074
+ // decided to inspect them.
657
1075
  log('Previous state' + '\n' + JSON.stringify(this.getState().itemStates[i], null, 2))
658
- log('New state' + '\n' + JSON.stringify(itemState, null, 2))
1076
+ log('New state' + '\n' + JSON.stringify(newItemState, null, 2))
1077
+ }
1078
+
1079
+ this.getState().itemStates[i] = newItemState
1080
+
1081
+ // Schedule the item state update for after the new items have been rendered.
1082
+ if (this.newItemsWillBeRendered) {
1083
+ if (!this.itemStatesThatChangedWhileNewItemsWereBeingRendered) {
1084
+ this.itemStatesThatChangedWhileNewItemsWereBeingRendered = {}
1085
+ }
1086
+ this.itemStatesThatChangedWhileNewItemsWereBeingRendered[String(i)] = newItemState
659
1087
  }
660
- this.getState().itemStates[i] = itemState
661
1088
  }
662
1089
 
663
1090
  onItemHeightChange(i) {
664
1091
  log('~ Re-measure item height ~')
665
1092
  log('Item', i)
666
- const { itemHeights } = this.getState()
667
- const previousHeight = itemHeights[i]
668
- if (previousHeight === undefined) {
669
- return reportError(`"onItemHeightChange()" has been called for item ${i}, but that item hasn't been rendered before.`)
670
- }
671
- const newHeight = this.remeasureItemHeight(i)
1093
+
1094
+ const {
1095
+ itemHeights,
1096
+ firstShownItemIndex,
1097
+ lastShownItemIndex
1098
+ } = this.getState()
1099
+
672
1100
  // Check if the item is still rendered.
673
- if (newHeight === undefined) {
1101
+ if (!(i >= firstShownItemIndex && i <= lastShownItemIndex)) {
674
1102
  // There could be valid cases when an item is no longer rendered
675
1103
  // by the time `.onItemHeightChange(i)` gets called.
676
1104
  // For example, suppose there's a list of several items on a page,
@@ -698,12 +1126,60 @@ export default class VirtualScroller {
698
1126
  // `.onItemHeightChange(i)` gets called.
699
1127
  return warn('The item is no longer rendered. This is not necessarily a bug, and could happen, for example, when there\'re several `onItemHeightChange(i)` calls issued at the same time.')
700
1128
  }
1129
+
1130
+ const previousHeight = itemHeights[i]
1131
+ if (previousHeight === undefined) {
1132
+ return reportError(`"onItemHeightChange()" has been called for item ${i}, but that item hasn't been rendered before.`)
1133
+ }
1134
+
1135
+ const newHeight = this.remeasureItemHeight(i)
1136
+
701
1137
  log('Previous height', previousHeight)
702
1138
  log('New height', newHeight)
1139
+
703
1140
  if (previousHeight !== newHeight) {
704
1141
  log('~ Item height has changed ~')
705
- // log('Item', i)
1142
+
1143
+ // Update or reset previously calculated layout.
1144
+ this.updatePreviouslyCalculatedLayoutOnItemHeightChange(i, previousHeight, newHeight)
1145
+
1146
+ // Recalculate layout.
706
1147
  this.onUpdateShownItemIndexes({ reason: LAYOUT_REASON.ITEM_HEIGHT_CHANGED })
1148
+
1149
+ // Schedule the item height update for after the new items have been rendered.
1150
+ if (this.newItemsWillBeRendered) {
1151
+ if (!this.itemHeightsThatChangedWhileNewItemsWereBeingRendered) {
1152
+ this.itemHeightsThatChangedWhileNewItemsWereBeingRendered = {}
1153
+ }
1154
+ this.itemHeightsThatChangedWhileNewItemsWereBeingRendered[String(i)] = newHeight
1155
+ }
1156
+ }
1157
+ }
1158
+
1159
+ // Updates the snapshot of the current layout when an item's height changes.
1160
+ //
1161
+ // The "previously calculated layout" feature is not currently used.
1162
+ //
1163
+ // The current layout snapshot could be stored as a "previously calculated layout" variable
1164
+ // so that it could theoretically be used when calculating new layout incrementally
1165
+ // rather than from scratch, which would be an optimization.
1166
+ //
1167
+ updatePreviouslyCalculatedLayoutOnItemHeightChange(i, previousHeight, newHeight) {
1168
+ if (this.previouslyCalculatedLayout) {
1169
+ const heightDifference = newHeight - previousHeight
1170
+ if (i < this.previouslyCalculatedLayout.firstShownItemIndex) {
1171
+ // Patch `this.previouslyCalculatedLayout`'s `.beforeItemsHeight`.
1172
+ this.previouslyCalculatedLayout.beforeItemsHeight += heightDifference
1173
+ } else if (i > this.previouslyCalculatedLayout.lastShownItemIndex) {
1174
+ // Could patch `.afterItemsHeight` of `this.previouslyCalculatedLayout` here,
1175
+ // if `.afterItemsHeight` property existed in `this.previouslyCalculatedLayout`.
1176
+ if (this.previouslyCalculatedLayout.afterItemsHeight !== undefined) {
1177
+ this.previouslyCalculatedLayout.afterItemsHeight += heightDifference
1178
+ }
1179
+ } else {
1180
+ // Patch `this.previouslyCalculatedLayout`'s shown items height.
1181
+ this.previouslyCalculatedLayout.shownItemsHeight += newHeight - previousHeight
1182
+ }
707
1183
  }
708
1184
  }
709
1185
 
@@ -756,8 +1232,13 @@ export default class VirtualScroller {
756
1232
  const previouslyMeasuredItemHeight = this.getState().itemHeights[i]
757
1233
  const actualItemHeight = this.remeasureItemHeight(i)
758
1234
  if (actualItemHeight !== previouslyMeasuredItemHeight) {
1235
+ if (isValid) {
1236
+ log('~ Validate will-be-hidden item heights. ~')
1237
+ // Update or reset previously calculated layout.
1238
+ this.updatePreviouslyCalculatedLayoutOnItemHeightChange(i, previouslyMeasuredItemHeight, actualItemHeight)
1239
+ }
759
1240
  isValid = false
760
- warn('Item', i, 'will be unmounted at next render. Its height has changed from', previouslyMeasuredItemHeight, 'to', actualItemHeight, 'since it was last measured. This is not necessarily a bug, and could happen, for example, when there\'re several `onItemHeightChange(i)` calls issued at the same time, and the first one triggers a re-layout before the rest of them have had a chance to be executed.')
1241
+ warn('Item index', i, 'is no longer visible and will be unmounted. Its height has changed from', previouslyMeasuredItemHeight, 'to', actualItemHeight, 'since it was last measured. This is not necessarily a bug, and could happen, for example, on screen width change, or when there\'re several `onItemHeightChange(i)` calls issued at the same time, and the first one triggers a re-layout before the rest of them have had a chance to be executed.')
761
1242
  }
762
1243
  }
763
1244
  i++
@@ -765,44 +1246,80 @@ export default class VirtualScroller {
765
1246
  return isValid
766
1247
  }
767
1248
 
1249
+ getShownItemIndexes() {
1250
+ const itemsCount = this.getItemsCount()
1251
+
1252
+ const {
1253
+ top: visibleAreaTop,
1254
+ bottom: visibleAreaBottom
1255
+ } = this.getVisibleArea()
1256
+
1257
+ if (this.bypass) {
1258
+ return {
1259
+ firstShownItemIndex: 0,
1260
+ lastShownItemIndex: itemsCount - 1,
1261
+ // shownItemsHeight: this.getState().itemHeights.reduce((sum, itemHeight) => sum + itemHeight, 0)
1262
+ }
1263
+ }
1264
+
1265
+ // Find the indexes of the items that are currently visible
1266
+ // (or close to being visible) in the scrollable container.
1267
+ // For scrollable containers other than the main screen, it could also
1268
+ // check the visibility of such scrollable container itself, because it
1269
+ // might be not visible.
1270
+ // If such kind of an optimization would hypothetically be implemented,
1271
+ // then it would also require listening for "scroll" events on the screen.
1272
+ // Overall, I suppose that such "actual visibility" feature would be
1273
+ // a very minor optimization and not something I'd deal with.
1274
+ const isVisible = visibleAreaTop < this.itemsContainer.getHeight() && visibleAreaBottom > 0
1275
+ if (!isVisible) {
1276
+ log('The entire list is off-screen. No items are visible.')
1277
+ return this.layout.getNonVisibleListShownItemIndexes()
1278
+ }
1279
+
1280
+ // Get shown item indexes.
1281
+ return this.layout.getShownItemIndexes({
1282
+ itemsCount: this.getItemsCount(),
1283
+ visibleAreaTop,
1284
+ visibleAreaBottom
1285
+ })
1286
+ }
1287
+
768
1288
  /**
769
1289
  * Updates the "from" and "to" shown item indexes.
770
1290
  * If the list is visible and some of the items being shown are new
771
1291
  * and are required to be measured first, then
772
- * `redoLayoutAfterMeasuringItemHeights` is `true`.
1292
+ * `firstNonMeasuredItemIndex` is defined.
773
1293
  * If the list is visible and all items being shown have been encountered
774
- * (and measured) before, then `redoLayoutAfterMeasuringItemHeights` is `false`.
1294
+ * (and measured) before, then `firstNonMeasuredItemIndex` is `undefined`.
1295
+ *
1296
+ * The `stateUpdate` parameter is just an optional "additional" state update.
775
1297
  */
776
- updateShownItemIndexes = () => {
777
- log('~ Layout results ' + (this.bypass ? '(bypass) ' : '') + '~')
778
- const visibleAreaIncludingMargins = this.getVisibleAreaBoundsIncludingMargins()
779
- this.latestLayoutVisibleAreaIncludingMargins = visibleAreaIncludingMargins
780
- const listTopOffsetInsideScrollableContainer = this.getListTopOffsetInsideScrollableContainer()
1298
+ updateShownItemIndexes = ({ stateUpdate }) => {
1299
+ const startedAt = Date.now()
1300
+
781
1301
  // Get shown item indexes.
782
1302
  let {
783
1303
  firstShownItemIndex,
784
1304
  lastShownItemIndex,
785
- redoLayoutAfterMeasuringItemHeights
786
- } = this.layout.getShownItemIndexes({
787
- listHeight: this.screen.getElementHeight(this.getContainerElement()),
788
- itemsCount: this.getItemsCount(),
789
- visibleAreaIncludingMargins,
790
- listTopOffsetInsideScrollableContainer
791
- })
1305
+ shownItemsHeight,
1306
+ firstNonMeasuredItemIndex
1307
+ } = this.getShownItemIndexes()
1308
+
792
1309
  // If scroll position is scheduled to be restored after render,
793
1310
  // then the "anchor" item must be rendered, and all of the prepended
794
1311
  // items before it, all in a single pass. This way, all of the
795
1312
  // prepended items' heights could be measured right after the render
796
1313
  // has finished, and the scroll position can then be immediately restored.
797
- if (this.restoreScroll.shouldRestoreScrollAfterRender()) {
798
- if (lastShownItemIndex < this.restoreScroll.getAnchorItemIndex()) {
799
- lastShownItemIndex = this.restoreScroll.getAnchorItemIndex()
1314
+ if (this.listHeightChangeWatcher.hasSnapshot()) {
1315
+ if (lastShownItemIndex < this.listHeightChangeWatcher.getAnchorItemIndex()) {
1316
+ lastShownItemIndex = this.listHeightChangeWatcher.getAnchorItemIndex()
800
1317
  }
801
1318
  // `firstShownItemIndex` is always `0` when prepending items.
802
1319
  // And `lastShownItemIndex` always covers all prepended items in this case.
803
1320
  // None of the prepended items have been rendered before,
804
1321
  // so their heights are unknown. The code at the start of this function
805
- // did therefore set `redoLayoutAfterMeasuringItemHeights` to `true`
1322
+ // did therefore set `firstNonMeasuredItemIndex` to non-`undefined`
806
1323
  // in order to render just the first prepended item in order to
807
1324
  // measure it, and only then make a decision on how many other
808
1325
  // prepended items to render. But since we've instructed the code
@@ -812,8 +1329,9 @@ export default class VirtualScroller {
812
1329
  // "jitter" due to the scroll position not being restored because it'd
813
1330
  // wait for the second layout to finish instead of being restored
814
1331
  // right after the first one.
815
- redoLayoutAfterMeasuringItemHeights = false
1332
+ firstNonMeasuredItemIndex = undefined
816
1333
  }
1334
+
817
1335
  // Validate the heights of items to be hidden on next render.
818
1336
  // For example, a user could click a "Show more" button,
819
1337
  // or an "Expand YouTube video" button, which would result
@@ -821,22 +1339,31 @@ export default class VirtualScroller {
821
1339
  // from what has been initially measured in `this.itemHeights[i]`,
822
1340
  // if the developer didn't call `.onItemStateChange()` and `.onItemHeightChange(i)`.
823
1341
  if (!this.validateWillBeHiddenItemHeightsAreAccurate(firstShownItemIndex, lastShownItemIndex)) {
1342
+ log('~ Because some of the will-be-hidden item heights (listed above) have changed since they\'ve last been measured, redo layout. ~')
824
1343
  // Redo layout, now with the correct item heights.
825
- log('~ Some of the will-be-hidden item heights have changed since they\'ve last been measured. Redo layout. ~')
826
- return this.updateShownItemIndexes();
1344
+ return this.updateShownItemIndexes({ stateUpdate });
827
1345
  }
1346
+
828
1347
  // Measure "before" items height.
829
1348
  const beforeItemsHeight = this.layout.getBeforeItemsHeight(
830
- firstShownItemIndex,
831
- lastShownItemIndex
1349
+ firstShownItemIndex
832
1350
  )
1351
+
833
1352
  // Measure "after" items height.
834
1353
  const afterItemsHeight = this.layout.getAfterItemsHeight(
835
- firstShownItemIndex,
836
1354
  lastShownItemIndex,
837
1355
  this.getItemsCount()
838
1356
  )
1357
+
1358
+ const layoutDuration = Date.now() - startedAt
1359
+
839
1360
  // Debugging.
1361
+ log('~ Layout values ' + (this.bypass ? '(bypass) ' : '') + '~')
1362
+ if (layoutDuration < SLOW_LAYOUT_DURATION) {
1363
+ // log('Calculated in', layoutDuration, 'ms')
1364
+ } else {
1365
+ warn('Layout calculated in', layoutDuration, 'ms')
1366
+ }
840
1367
  if (this._getColumnsCount) {
841
1368
  log('Columns count', this.getColumnsCount())
842
1369
  }
@@ -844,16 +1371,12 @@ export default class VirtualScroller {
844
1371
  log('Last shown item index', lastShownItemIndex)
845
1372
  log('Before items height', beforeItemsHeight)
846
1373
  log('After items height (actual or estimated)', afterItemsHeight)
847
- log('Average item height (calculated on previous render)', this.itemHeights.getAverage())
1374
+ log('Average item height (used for estimated after items height calculation)', this.itemHeights.getAverage())
848
1375
  if (isDebug()) {
849
1376
  log('Item heights', this.getState().itemHeights.slice())
850
1377
  log('Item states', this.getState().itemStates.slice())
851
1378
  }
852
- if (redoLayoutAfterMeasuringItemHeights) {
853
- // `this.redoLayoutReason` will be detected in `didUpdateState()`.
854
- // `didUpdateState()` is triggered by `this.setState()` below.
855
- this.redoLayoutReason = LAYOUT_REASON.ITEM_HEIGHT_NOT_MEASURED
856
- }
1379
+
857
1380
  // Optionally preload items to be rendered.
858
1381
  this.onBeforeShowItems(
859
1382
  this.getState().items,
@@ -861,34 +1384,109 @@ export default class VirtualScroller {
861
1384
  firstShownItemIndex,
862
1385
  lastShownItemIndex
863
1386
  )
864
- // Render.
1387
+
1388
+ // Set `this.firstNonMeasuredItemIndex`.
1389
+ this.firstNonMeasuredItemIndex = firstNonMeasuredItemIndex
1390
+
1391
+ // Set "previously calculated layout".
1392
+ //
1393
+ // The "previously calculated layout" feature is not currently used.
1394
+ //
1395
+ // The current layout snapshot could be stored as a "previously calculated layout" variable
1396
+ // so that it could theoretically be used when calculating new layout incrementally
1397
+ // rather than from scratch, which would be an optimization.
1398
+ //
1399
+ // Currently, this feature is not used, and `shownItemsHeight` property
1400
+ // is not returned at all, so don't set any "previously calculated layout".
1401
+ //
1402
+ if (shownItemsHeight === undefined) {
1403
+ this.previouslyCalculatedLayout = undefined
1404
+ } else {
1405
+ // If "previously calculated layout" feature would be implmeneted,
1406
+ // then this code would set "previously calculate layout" instance variable.
1407
+ //
1408
+ // What for would this instance variable be used?
1409
+ //
1410
+ // Instead of using a `this.previouslyCalculatedLayout` instance variable,
1411
+ // this code could use `this.getState()` because it reflects what's currently on screen,
1412
+ // but there's a single edge case when it could go out of sync —
1413
+ // updating item heights externally via `.onItemHeightChange(i)`.
1414
+ //
1415
+ // If, for example, an item height was updated externally via `.onItemHeightChange(i)`
1416
+ // then `this.getState().itemHeights` would get updated immediately but
1417
+ // `this.getState().beforeItemsHeight` or `this.getState().afterItemsHeight`
1418
+ // would still correspond to the previous item height, so those would be "stale".
1419
+ // On the other hand, same values in `this.previouslyCalculatedLayout` instance variable
1420
+ // can also be updated immediately, so they won't go out of sync with the updated item height.
1421
+ // That seems the only edge case when using a separate `this.previouslyCalculatedLayout`
1422
+ // instance variable instead of using `this.getState()` would theoretically be justified.
1423
+ //
1424
+ this.previouslyCalculatedLayout = {
1425
+ firstShownItemIndex,
1426
+ lastShownItemIndex,
1427
+ beforeItemsHeight,
1428
+ shownItemsHeight
1429
+ }
1430
+ }
1431
+
1432
+ // Update `VirtualScroller` state.
1433
+ // `VirtualScroller` automatically re-renders on state updates.
1434
+ //
1435
+ // All `state` properties updated here should be overwritten in
1436
+ // the implementation of `setItems()` and `onResize()` methods
1437
+ // so that the `state` is not left in an inconsistent state
1438
+ // whenever there're concurrent `setState()` updates that could
1439
+ // possibly conflict with one another — instead, those state updates
1440
+ // should overwrite each other in terms of priority.
1441
+ // These "on scroll" updates have the lowest priority compared to
1442
+ // the state updates originating from `setItems()` and `onResize()` methods.
1443
+ //
865
1444
  this.setState({
866
1445
  firstShownItemIndex,
867
1446
  lastShownItemIndex,
868
1447
  beforeItemsHeight,
869
1448
  afterItemsHeight,
870
- // // Average item height is stored in state to differentiate between
871
- // // the initial state and "anything has been measured already" state.
872
- // averageItemHeight: this.itemHeights.getAverage()
1449
+ ...stateUpdate
873
1450
  })
874
1451
  }
875
1452
 
876
- onUpdateShownItemIndexes = ({ reason }) => {
1453
+ onUpdateShownItemIndexes = ({ reason, stateUpdate }) => {
1454
+ // In case of "don't do anything".
1455
+ const skip = () => {
1456
+ if (stateUpdate) {
1457
+ this.setState(stateUpdate)
1458
+ }
1459
+ }
1460
+
1461
+ // If new `items` have been set and are waiting to be applied,
1462
+ // or if the viewport width has changed requiring a re-layout,
1463
+ // then temporarily stop all other updates like "on scroll" updates.
1464
+ // This prevents `state` being inconsistent, because, for example,
1465
+ // both `setItems()` and this function could update `VirtualScroller` state
1466
+ // and having them operate in parallel could result in incorrectly calculated
1467
+ // `beforeItemsHeight` / `afterItemsHeight` / `firstShownItemIndex` /
1468
+ // `lastShownItemIndex`, because, when operating in parallel, this function
1469
+ // would have different `items` than the `setItems()` function, so their
1470
+ // results could diverge.
1471
+ if (this.newItemsWillBeRendered || this.resetLayoutAfterResize || this.isResizing) {
1472
+ return skip()
1473
+ }
1474
+
877
1475
  // If there're no items then there's no need to re-layout anything.
878
1476
  if (this.getItemsCount() === 0) {
879
- return
1477
+ return skip()
880
1478
  }
1479
+
881
1480
  // Cancel a "re-layout when user stops scrolling" timer.
882
- this.scroll.onLayout()
1481
+ this.scroll.cancelScheduledLayout()
1482
+
883
1483
  // Cancel a re-layout that is scheduled to run at the next "frame",
884
1484
  // because a re-layout will be performed right now.
885
- if (this.layoutTimer) {
886
- clearTimeout(this.layoutTimer)
887
- this.layoutTimer = undefined
888
- }
1485
+ stateUpdate = this.cancelLayoutTimer({ stateUpdate })
1486
+
889
1487
  // Perform a re-layout.
890
- log(`~ Calculate Layout (on ${reason}) ~`)
891
- this.updateShownItemIndexes()
1488
+ log(`~ Update Layout (on ${reason}) ~`)
1489
+ this.updateShownItemIndexes({ stateUpdate })
892
1490
  }
893
1491
 
894
1492
  updateLayout = () => this.onUpdateShownItemIndexes({ reason: LAYOUT_REASON.MANUAL })
@@ -914,38 +1512,103 @@ export default class VirtualScroller {
914
1512
  const {
915
1513
  items: previousItems
916
1514
  } = this.getState()
917
- let {
918
- itemStates,
919
- itemHeights
920
- } = this.getState()
1515
+
1516
+ // Even if `newItems` are equal to `this.state.items`,
1517
+ // still perform a `setState()` call, because, if `setState()` calls
1518
+ // were "asynchronous", there could be a situation when a developer
1519
+ // first calls `setItems(newItems)` and then `setItems(oldItems)`:
1520
+ // if this function did `return` `if (newItems === this.state.items)`
1521
+ // then `setState({ items: newItems })` would be scheduled as part of
1522
+ // `setItems(newItems)` call, but the subsequent `setItems(oldItems)` call
1523
+ // wouldn't do anything resulting in `newItems` being set as a result,
1524
+ // and that wouldn't be what the developer intended.
1525
+
1526
+ let { itemStates } = this.getState()
1527
+ let { itemHeights } = this.resetLayoutAfterResize
1528
+ ? this.resetLayoutAfterResize.stateUpdate
1529
+ : this.getState()
1530
+
921
1531
  log('~ Update items ~')
922
- let layout
1532
+
1533
+ let layoutUpdate
1534
+ let itemsUpdateInfo
1535
+
1536
+ // Compare the new items to the current items.
923
1537
  const itemsDiff = this.getItemsDiff(previousItems, newItems)
924
- // If it's an "incremental" update.
925
- if (itemsDiff && !this.layoutResetPending) {
1538
+
1539
+ // See if it's an "incremental" items update.
1540
+ if (itemsDiff) {
926
1541
  const {
927
1542
  firstShownItemIndex,
928
1543
  lastShownItemIndex,
929
1544
  beforeItemsHeight,
930
1545
  afterItemsHeight
931
- } = this.getState()
932
- layout = {
1546
+ } = this.resetLayoutAfterResize
1547
+ ? this.resetLayoutAfterResize.stateUpdate
1548
+ : this.getState()
1549
+
1550
+ const shouldRestoreScrollPosition = firstShownItemIndex === 0 &&
1551
+ // `preserveScrollPosition` option name is deprecated,
1552
+ // use `preserveScrollPositionOnPrependItems` instead.
1553
+ (options.preserveScrollPositionOnPrependItems || options.preserveScrollPosition)
1554
+
1555
+ const {
1556
+ prependedItemsCount,
1557
+ appendedItemsCount
1558
+ } = itemsDiff
1559
+
1560
+ layoutUpdate = this.layout.getLayoutUpdateForItemsDiff({
933
1561
  firstShownItemIndex,
934
1562
  lastShownItemIndex,
935
1563
  beforeItemsHeight,
936
1564
  afterItemsHeight
937
- }
938
- const {
1565
+ }, {
939
1566
  prependedItemsCount,
940
1567
  appendedItemsCount
941
- } = itemsDiff
1568
+ }, {
1569
+ itemsCount: newItems.length,
1570
+ columnsCount: this.getActualColumnsCount(),
1571
+ shouldRestoreScrollPosition
1572
+ })
1573
+
942
1574
  if (prependedItemsCount > 0) {
943
1575
  log('Prepend', prependedItemsCount, 'items')
1576
+
944
1577
  itemHeights = new Array(prependedItemsCount).concat(itemHeights)
1578
+
945
1579
  if (itemStates) {
946
1580
  itemStates = new Array(prependedItemsCount).concat(itemStates)
947
1581
  }
1582
+
1583
+ // Restore scroll position after prepending items (if requested).
1584
+ if (shouldRestoreScrollPosition) {
1585
+ log('Will restore scroll position')
1586
+ this.listHeightChangeWatcher.snapshot({
1587
+ previousItems,
1588
+ newItems,
1589
+ prependedItemsCount
1590
+ })
1591
+ // "Seamless prepend" scenario doesn't result in a re-layout,
1592
+ // so if any "non measured item" is currently pending,
1593
+ // it doesn't get reset and will be handled after `state` is updated.
1594
+ if (this.firstNonMeasuredItemIndex !== undefined) {
1595
+ this.firstNonMeasuredItemIndex += prependedItemsCount
1596
+ }
1597
+ } else {
1598
+ log('Reset layout')
1599
+ // Reset layout because none of the prepended items have been measured.
1600
+ layoutUpdate = this.layout.getInitialLayoutValues({
1601
+ itemsCount: newItems.length,
1602
+ columnsCount: this.getActualColumnsCount()
1603
+ })
1604
+ // Unschedule a potentially scheduled layout update
1605
+ // after measuring a previously non-measured item
1606
+ // because the list will be re-layout anyway
1607
+ // due to the new items being set.
1608
+ this.firstNonMeasuredItemIndex = undefined
1609
+ }
948
1610
  }
1611
+
949
1612
  if (appendedItemsCount > 0) {
950
1613
  log('Append', appendedItemsCount, 'items')
951
1614
  itemHeights = itemHeights.concat(new Array(appendedItemsCount))
@@ -953,58 +1616,349 @@ export default class VirtualScroller {
953
1616
  itemStates = itemStates.concat(new Array(appendedItemsCount))
954
1617
  }
955
1618
  }
956
- this.layout.updateLayoutForItemsDiff(layout, itemsDiff, {
957
- itemsCount: newItems.length
958
- })
959
- if (prependedItemsCount > 0) {
960
- // `preserveScrollPosition` option name is deprecated,
961
- // use `preserveScrollPositionOnPrependItems` instead.
962
- if (options.preserveScrollPositionOnPrependItems || options.preserveScrollPosition) {
963
- if (this.getState().firstShownItemIndex === 0) {
964
- this.restoreScroll.captureScroll({
965
- previousItems,
966
- newItems,
967
- prependedItemsCount
968
- })
969
- this.layout.showItemsFromTheStart(layout)
970
- }
971
- }
1619
+
1620
+ itemsUpdateInfo = {
1621
+ prepend: prependedItemsCount > 0,
1622
+ append: appendedItemsCount > 0
972
1623
  }
973
1624
  } else {
974
1625
  log('Items have changed, and', (itemsDiff ? 'a re-layout from scratch has been requested.' : 'it\'s not a simple append and/or prepend.'), 'Rerender the entire list from scratch.')
975
1626
  log('Previous items', previousItems)
976
1627
  log('New items', newItems)
1628
+
1629
+ // Reset item heights and item states.
977
1630
  itemHeights = new Array(newItems.length)
978
1631
  itemStates = new Array(newItems.length)
979
- layout = this.getInitialLayoutValues({
980
- itemsCount: newItems.length
1632
+
1633
+ layoutUpdate = this.layout.getInitialLayoutValues({
1634
+ itemsCount: newItems.length,
1635
+ columnsCount: this.getActualColumnsCount()
981
1636
  })
1637
+
1638
+ // Unschedule a potentially scheduled layout update
1639
+ // after measuring a previously non-measured item
1640
+ // because the list will be re-layout from scratch
1641
+ // due to the new items being set.
1642
+ this.firstNonMeasuredItemIndex = undefined
1643
+
1644
+ // Also reset any potential pending scroll position restoration.
1645
+ // For example, imagine a developer first called `.setItems(incrementalItemsUpdate)`
1646
+ // and then called `.setItems(differentItems)` and there was no state update
1647
+ // in between those two calls. This could happen because state updates aren't
1648
+ // required to be "synchronous". On other words, calling `this.setState()`
1649
+ // doesn't necessarily mean that the state is applied immediately.
1650
+ // Imagine also that such "delayed" state updates could be batched,
1651
+ // like they do in React inside event handlers (though that doesn't apply to this case):
1652
+ // https://github.com/facebook/react/issues/10231#issuecomment-316644950
1653
+ // If `this.listHeightChangeWatcher` wasn't reset on `.setItems(differentItems)`
1654
+ // and if the second `this.setState()` call overwrites the first one
1655
+ // then it would attempt to restore scroll position in a situation when
1656
+ // it should no longer do that. Hence the reset here.
1657
+ this.listHeightChangeWatcher.reset()
1658
+
1659
+ itemsUpdateInfo = {
1660
+ replace: true
1661
+ }
982
1662
  }
1663
+
983
1664
  log('~ Update state ~')
984
- log('First shown item index', layout.firstShownItemIndex)
985
- log('Last shown item index', layout.lastShownItemIndex)
986
- log('Before items height', layout.beforeItemsHeight)
987
- log('After items height (actual or estimated)', layout.afterItemsHeight)
1665
+
1666
+ // const layoutValuesAfterUpdate = {
1667
+ // ...this.getState(),
1668
+ // ...layoutUpdate
1669
+ // }
1670
+
1671
+ // `layoutUpdate` is equivalent to `layoutValuesAfterUpdate` because
1672
+ // `layoutUpdate` contains all the relevant properties.
1673
+ log('First shown item index', layoutUpdate.firstShownItemIndex)
1674
+ log('Last shown item index', layoutUpdate.lastShownItemIndex)
1675
+ log('Before items height', layoutUpdate.beforeItemsHeight)
1676
+ log('After items height (actual or estimated)', layoutUpdate.afterItemsHeight)
1677
+
988
1678
  // Optionally preload items to be rendered.
1679
+ //
1680
+ // `layoutUpdate` is equivalent to `layoutValuesAfterUpdate` because
1681
+ // `layoutUpdate` contains all the relevant properties.
1682
+ //
989
1683
  this.onBeforeShowItems(
990
1684
  newItems,
991
1685
  itemHeights,
992
- layout.firstShownItemIndex,
993
- layout.lastShownItemIndex
1686
+ layoutUpdate.firstShownItemIndex,
1687
+ layoutUpdate.lastShownItemIndex
994
1688
  )
995
- // `this.newItemsPending` will be cleared in `didUpdateState()`.
996
- this.newItemsPending = newItems
997
- // Update state.
998
- this.setState({
1689
+
1690
+ // `this.newItemsWillBeRendered` signals that new `items` are being rendered,
1691
+ // and that `VirtualScroller` should temporarily stop all other updates.
1692
+ //
1693
+ // `this.newItemsWillBeRendered` is cleared in `didUpdateState()`.
1694
+ //
1695
+ // The values in `this.newItemsWillBeRendered` are used, for example,
1696
+ // in `.onResize()` handler in order to not break state consistency when
1697
+ // state updates are "asynchronous" (delayed) and there's a window resize event
1698
+ // in between calling `setState()` below and that call actually being applied.
1699
+ //
1700
+ this.newItemsWillBeRendered = {
1701
+ ...itemsUpdateInfo,
1702
+ count: newItems.length,
1703
+ // `layoutUpdate` now contains all layout-related properties, even if those that
1704
+ // didn't change. So `firstShownItemIndex` is always in `this.newItemsWillBeRendered`.
1705
+ layout: layoutUpdate
1706
+ }
1707
+
1708
+ // `layoutUpdate` now contains all layout-related properties, even if those that
1709
+ // didn't change. So this part is no longer relevant.
1710
+ //
1711
+ // // If `firstShownItemIndex` is gonna be modified as a result of setting new items
1712
+ // // then keep that "new" `firstShownItemIndex` in order for it to be used by
1713
+ // // `onResize()` handler when it calculates "new" `firstShownItemIndex`
1714
+ // // based on the new columns count (corresponding to the new window width).
1715
+ // if (layoutUpdate.firstShownItemIndex !== undefined) {
1716
+ // this.newItemsWillBeRendered = {
1717
+ // ...this.newItemsWillBeRendered,
1718
+ // firstShownItemIndex: layoutUpdate.firstShownItemIndex
1719
+ // }
1720
+ // }
1721
+
1722
+ // Update `VirtualScroller` state.
1723
+ //
1724
+ // This state update should overwrite all the `state` properties
1725
+ // that are also updated in the "on scroll" handler (`getShownItemIndexes()`):
1726
+ //
1727
+ // * `firstShownItemIndex`
1728
+ // * `lastShownItemIndex`
1729
+ // * `beforeItemsHeight`
1730
+ // * `afterItemsHeight`
1731
+ //
1732
+ // That's because this `setState()` update has a higher priority
1733
+ // than that of the "on scroll" handler, so it should overwrite
1734
+ // any potential state changes dispatched by the "on scroll" handler.
1735
+ //
1736
+ const newState = {
999
1737
  // ...customState,
1000
- ...layout,
1738
+ ...layoutUpdate,
1001
1739
  items: newItems,
1002
1740
  itemStates,
1003
1741
  itemHeights
1004
- })
1742
+ }
1743
+
1744
+ // Introduced `shouldIncludeBeforeResizeValuesInState()` getter just to prevent
1745
+ // cluttering `state` with `beforeResize: undefined` property if `beforeResize`
1746
+ // hasn't ever been set in `state` previously.
1747
+ if (this.beforeResize.shouldIncludeBeforeResizeValuesInState()) {
1748
+ if (this.shouldDiscardBeforeResizeItemHeights()) {
1749
+ // Reset "before resize" item heights because now there're new items prepended
1750
+ // with unknown heights, or completely new items with unknown heights, so
1751
+ // `beforeItemsHeight` value won't be preserved anyway.
1752
+ newState.beforeResize = undefined
1753
+ }
1754
+ else {
1755
+ // Overwrite `beforeResize` property in `state` even if it wasn't modified
1756
+ // because state updates could be "asynchronous" and in that case there could be
1757
+ // some previous `setState()` call from some previous `setItems()` call that
1758
+ // hasn't yet been applied, and that previous call might have scheduled setting
1759
+ // `state.beforeResize` property to `undefined` in order to reset it, but this
1760
+ // next `setState()` call might not require resetting `state.beforeResize` property
1761
+ // so it should undo resetting it by simply overwriting it with its normal value.
1762
+ newState.beforeResize = this.resetLayoutAfterResize
1763
+ ? this.resetLayoutAfterResize.stateUpdate.beforeResize
1764
+ : this.getState().beforeResize
1765
+ }
1766
+ }
1767
+
1768
+ // `newState` should also overwrite all `state` properties that're updated in `onResize()`
1769
+ // because `setItems()`'s state updates always overwrite `onResize()`'s state updates.
1770
+ // (The least-priority ones are `onScroll()` state updates, but those're simply skipped
1771
+ // if there's a pending `setItems()` or `onResize()` update).
1772
+ //
1773
+ // `state` property exceptions:
1774
+ //
1775
+ // `verticalSpacing` property is not updated here because it's fine setting it to
1776
+ // `undefined` in `onResize()` — it will simply be re-measured after the component re-renders.
1777
+ //
1778
+ // `columnsCount` property is also not updated here because by definition it's only
1779
+ // updated in `onResize()`.
1780
+
1781
+ // Render.
1782
+ this.setState(newState)
1005
1783
  }
1006
1784
 
1007
1785
  getItemsDiff(previousItems, newItems) {
1008
1786
  return getItemsDiff(previousItems, newItems, this.isItemEqual)
1009
1787
  }
1010
- }
1788
+
1789
+ // Returns whether "before resize" item heights should be discarded
1790
+ // as a result of calling `setItems()` with a new set of items
1791
+ // when an asynchronous `setState()` call inside that function
1792
+ // hasn't been applied yet.
1793
+ //
1794
+ // If `setItems()` update was an "incremental" one and no items
1795
+ // have been prepended, then `firstShownItemIndex` is preserved,
1796
+ // and all items' heights before it should be kept in order to
1797
+ // preserve the top offset of the first shown item so that there's
1798
+ // no "content jumping".
1799
+ //
1800
+ // If `setItems()` update was an "incremental" one but there're
1801
+ // some prepended items, then it means that now there're new items
1802
+ // with unknown heights at the top, so the top offset of the first
1803
+ // shown item won't be preserved because there're no "before resize"
1804
+ // heights of those items.
1805
+ //
1806
+ // If `setItems()` update was not an "incremental" one, then don't
1807
+ // attempt to restore previous item heights after a potential window
1808
+ // width change because all item heights have been reset.
1809
+ //
1810
+ shouldDiscardBeforeResizeItemHeights() {
1811
+ if (this.newItemsWillBeRendered) {
1812
+ const { prepend, replace } = this.newItemsWillBeRendered
1813
+ return prepend || replace
1814
+ }
1815
+ }
1816
+
1817
+ onResize() {
1818
+ // Reset "previously calculated layout".
1819
+ //
1820
+ // The "previously calculated layout" feature is not currently used.
1821
+ //
1822
+ // The current layout snapshot could be stored as a "previously calculated layout" variable
1823
+ // so that it could theoretically be used when calculating new layout incrementally
1824
+ // rather than from scratch, which would be an optimization.
1825
+ //
1826
+ this.previouslyCalculatedLayout = undefined
1827
+
1828
+ // Cancel any potential scheduled scroll position restoration.
1829
+ this.listHeightChangeWatcher.reset()
1830
+
1831
+ // Get the most recent items count.
1832
+ // If there're a "pending" `setItems()` call then use the items count from that call
1833
+ // instead of using the count of currently shown `items` from `state`.
1834
+ // A `setItems()` call is "pending" when `setState()` operation is "asynchronous", that is
1835
+ // when `setState()` calls aren't applied immediately, like in React.
1836
+ const itemsCount = this.newItemsWillBeRendered
1837
+ ? this.newItemsWillBeRendered.count
1838
+ : this.getState().itemHeights.length
1839
+
1840
+ // If layout values have been calculated as a result of a "pending" `setItems()` call,
1841
+ // then don't discard those new layout values and use them instead of the ones from `state`.
1842
+ //
1843
+ // A `setItems()` call is "pending" when `setState()` operation is "asynchronous", that is
1844
+ // when `setState()` calls aren't applied immediately, like in React.
1845
+ //
1846
+ const layout = this.newItemsWillBeRendered
1847
+ ? this.newItemsWillBeRendered.layout
1848
+ : this.getState()
1849
+
1850
+ // Update `VirtualScroller` state.
1851
+ const newState = {
1852
+ // This state update should also overwrite all the `state` properties
1853
+ // that are also updated in the "on scroll" handler (`getShownItemIndexes()`):
1854
+ //
1855
+ // * `firstShownItemIndex`
1856
+ // * `lastShownItemIndex`
1857
+ // * `beforeItemsHeight`
1858
+ // * `afterItemsHeight`
1859
+ //
1860
+ // That's because this `setState()` update has a higher priority
1861
+ // than that of the "on scroll" handler, so it should overwrite
1862
+ // any potential state changes dispatched by the "on scroll" handler.
1863
+ //
1864
+ // All these properties might have changed, but they're not
1865
+ // recalculated here becase they'll be recalculated after
1866
+ // this new state is applied (rendered).
1867
+ //
1868
+ firstShownItemIndex: layout.firstShownItemIndex,
1869
+ lastShownItemIndex: layout.lastShownItemIndex,
1870
+ beforeItemsHeight: layout.beforeItemsHeight,
1871
+ afterItemsHeight: layout.afterItemsHeight,
1872
+
1873
+ // Reset item heights, because if scrollable container's width (or height)
1874
+ // has changed, then the list width (or height) most likely also has changed,
1875
+ // and also some CSS `@media()` rules might have been added or removed.
1876
+ // So re-render the list entirely.
1877
+ itemHeights: new Array(itemsCount),
1878
+
1879
+ columnsCount: this.getActualColumnsCountForState(),
1880
+
1881
+ // Re-measure vertical spacing after render because new CSS styles
1882
+ // might be applied for the new window width.
1883
+ verticalSpacing: undefined
1884
+ }
1885
+
1886
+ const { firstShownItemIndex, lastShownItemIndex } = layout
1887
+
1888
+ // Get the `columnsCount` for the new window width.
1889
+ const newColumnsCount = this.getActualColumnsCount()
1890
+
1891
+ // Re-calculate `firstShownItemIndex` and `lastShownItemIndex`
1892
+ // based on the new `columnsCount` so that the whole row is visible.
1893
+ const newFirstShownItemIndex = Math.floor(firstShownItemIndex / newColumnsCount) * newColumnsCount
1894
+ const newLastShownItemIndex = Math.ceil((lastShownItemIndex + 1) / newColumnsCount) * newColumnsCount - 1
1895
+
1896
+ // Potentially update `firstShownItemIndex` if it needs to be adjusted in order to
1897
+ // correspond to the new `columnsCount`.
1898
+ if (newFirstShownItemIndex !== firstShownItemIndex) {
1899
+ log('Columns Count changed from', this.getState().columnsCount || 1, 'to', newColumnsCount)
1900
+ log('First Shown Item Index needs to change from', firstShownItemIndex, 'to', newFirstShownItemIndex)
1901
+ }
1902
+
1903
+ // Always rewrite `firstShownItemIndex` and `lastShownItemIndex`
1904
+ // as part of the `state` update, even if it hasn't been modified.
1905
+ //
1906
+ // The reason is that there could be two subsequent `onResize()` calls:
1907
+ // the first one could be user resizing the window to half of its width,
1908
+ // resulting in an "asynchronous" `setState()` call, and then, before that
1909
+ // `setState()` call is applied, a second resize event happens when the user
1910
+ // has resized the window back to its original width, meaning that the
1911
+ // `columnsCount` is back to its original value.
1912
+ // In that case, the final `newFirstShownItemIndex` will be equal to the
1913
+ // original `firstShownItemIndex` that was in `state` before the user
1914
+ // has started resizing the window, so, in the end, `state.firstShownItemIndex`
1915
+ // property wouldn't have changed, but it still has to be part of the final
1916
+ // state update in order to overwrite the previous update of `firstShownItemIndex`
1917
+ // property that has been scheduled to be applied in state after the first resize
1918
+ // happened.
1919
+ //
1920
+ newState.firstShownItemIndex = newFirstShownItemIndex
1921
+ newState.lastShownItemIndex = newLastShownItemIndex
1922
+
1923
+ const verticalSpacing = this.getVerticalSpacing()
1924
+ const columnsCount = this.getColumnsCount()
1925
+
1926
+ // `beforeResize` is always overwritten in `state` here.
1927
+ // (once it has started being tracked in `state`)
1928
+ if (this.shouldDiscardBeforeResizeItemHeights() || newFirstShownItemIndex === 0) {
1929
+ if (this.beforeResize.shouldIncludeBeforeResizeValuesInState()) {
1930
+ newState.beforeResize = undefined
1931
+ }
1932
+ }
1933
+ // Snapshot "before resize" values in order to preserve the currently
1934
+ // shown items' vertical position on screen so that there's no "content jumping".
1935
+ else {
1936
+ // Keep "before resize" values in order to preserve the currently
1937
+ // shown items' vertical position on screen so that there's no
1938
+ // "content jumping". These "before resize" values will be discarded
1939
+ // when (if) the user scrolls back to the top of the list.
1940
+ newState.beforeResize = {
1941
+ verticalSpacing,
1942
+ columnsCount,
1943
+ itemHeights: this.beforeResize.snapshotBeforeResizeItemHeights({
1944
+ firstShownItemIndex,
1945
+ newFirstShownItemIndex,
1946
+ newColumnsCount
1947
+ })
1948
+ }
1949
+ }
1950
+
1951
+ // `this.resetLayoutAfterResize` tells `VirtualScroller` that it should
1952
+ // temporarily stop other updates (like "on scroll" updates) and wait
1953
+ // for the new `state` to be applied, after which the `didUpdateState()`
1954
+ // function will clear this flag and perform a re-layout.
1955
+ this.resetLayoutAfterResize = {
1956
+ stateUpdate: newState
1957
+ }
1958
+
1959
+ // Rerender.
1960
+ this.setState(newState)
1961
+ }
1962
+ }
1963
+
1964
+ const SLOW_LAYOUT_DURATION = 15 // in milliseconds.