virtual-scroller 1.8.0 → 1.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (248) hide show
  1. package/.gitlab-ci.yml +1 -1
  2. package/CHANGELOG.md +57 -0
  3. package/README.md +337 -160
  4. package/bundle/virtual-scroller-dom.js +1 -1
  5. package/bundle/virtual-scroller-dom.js.map +1 -1
  6. package/bundle/virtual-scroller-react.js +1 -1
  7. package/bundle/virtual-scroller-react.js.map +1 -1
  8. package/bundle/virtual-scroller.js +1 -1
  9. package/bundle/virtual-scroller.js.map +1 -1
  10. package/commonjs/BeforeResize.js +23 -27
  11. package/commonjs/BeforeResize.js.map +1 -1
  12. package/commonjs/DOM/Engine.js +7 -7
  13. package/commonjs/DOM/Engine.js.map +1 -1
  14. package/commonjs/DOM/ItemsContainer.js +1 -1
  15. package/commonjs/DOM/ItemsContainer.js.map +1 -1
  16. package/commonjs/DOM/ListTopOffsetWatcher.js +15 -9
  17. package/commonjs/DOM/ListTopOffsetWatcher.js.map +1 -1
  18. package/commonjs/DOM/ScrollableContainer.js +28 -28
  19. package/commonjs/DOM/ScrollableContainer.js.map +1 -1
  20. package/commonjs/DOM/VirtualScroller.js +20 -17
  21. package/commonjs/DOM/VirtualScroller.js.map +1 -1
  22. package/commonjs/DOM/tbody.js +16 -10
  23. package/commonjs/DOM/tbody.js.map +1 -1
  24. package/commonjs/ItemHeights.js +23 -17
  25. package/commonjs/ItemHeights.js.map +1 -1
  26. package/commonjs/Layout.js +15 -13
  27. package/commonjs/Layout.js.map +1 -1
  28. package/commonjs/Layout.test.js +8 -3
  29. package/commonjs/Layout.test.js.map +1 -1
  30. package/commonjs/{ListHeightChangeWatcher.js → ListHeightMeasurement.js} +26 -28
  31. package/commonjs/ListHeightMeasurement.js.map +1 -0
  32. package/commonjs/Resize.js +38 -28
  33. package/commonjs/Resize.js.map +1 -1
  34. package/commonjs/Scroll.js +28 -44
  35. package/commonjs/Scroll.js.map +1 -1
  36. package/commonjs/VirtualScroller.columns.js +43 -0
  37. package/commonjs/VirtualScroller.columns.js.map +1 -0
  38. package/commonjs/VirtualScroller.constructor.js +408 -0
  39. package/commonjs/VirtualScroller.constructor.js.map +1 -0
  40. package/commonjs/VirtualScroller.items.js +305 -0
  41. package/commonjs/VirtualScroller.items.js.map +1 -0
  42. package/commonjs/VirtualScroller.js +132 -1872
  43. package/commonjs/VirtualScroller.js.map +1 -1
  44. package/commonjs/VirtualScroller.layout.js +562 -0
  45. package/commonjs/VirtualScroller.layout.js.map +1 -0
  46. package/commonjs/VirtualScroller.onRender.js +357 -0
  47. package/commonjs/VirtualScroller.onRender.js.map +1 -0
  48. package/commonjs/VirtualScroller.resize.js +186 -0
  49. package/commonjs/VirtualScroller.resize.js.map +1 -0
  50. package/commonjs/VirtualScroller.state.js +301 -0
  51. package/commonjs/VirtualScroller.state.js.map +1 -0
  52. package/commonjs/VirtualScroller.verticalSpacing.js +65 -0
  53. package/commonjs/VirtualScroller.verticalSpacing.js.map +1 -0
  54. package/commonjs/getItemCoordinates.js.map +1 -1
  55. package/commonjs/getItemsDiff.js.map +1 -1
  56. package/commonjs/getVerticalSpacing.js.map +1 -1
  57. package/commonjs/package.json +5 -0
  58. package/commonjs/react/VirtualScroller.js +184 -618
  59. package/commonjs/react/VirtualScroller.js.map +1 -1
  60. package/commonjs/react/useClassName.js +26 -0
  61. package/commonjs/react/useClassName.js.map +1 -0
  62. package/commonjs/react/useHandleItemsChange.js +116 -0
  63. package/commonjs/react/useHandleItemsChange.js.map +1 -0
  64. package/commonjs/react/useInstanceMethods.js +37 -0
  65. package/commonjs/react/useInstanceMethods.js.map +1 -0
  66. package/commonjs/react/useItemKeys.js +60 -0
  67. package/commonjs/react/useItemKeys.js.map +1 -0
  68. package/commonjs/react/useOnItemHeightChange.js +32 -0
  69. package/commonjs/react/useOnItemHeightChange.js.map +1 -0
  70. package/commonjs/react/useOnItemStateChange.js +32 -0
  71. package/commonjs/react/useOnItemStateChange.js.map +1 -0
  72. package/commonjs/react/useState.js +140 -0
  73. package/commonjs/react/useState.js.map +1 -0
  74. package/commonjs/react/useStyle.js +29 -0
  75. package/commonjs/react/useStyle.js.map +1 -0
  76. package/commonjs/react/useVirtualScroller.js +62 -0
  77. package/commonjs/react/useVirtualScroller.js.map +1 -0
  78. package/commonjs/react/useVirtualScrollerStartStop.js +20 -0
  79. package/commonjs/react/useVirtualScrollerStartStop.js.map +1 -0
  80. package/commonjs/test/Engine.js +23 -0
  81. package/commonjs/test/Engine.js.map +1 -0
  82. package/commonjs/test/ItemsContainer.js +127 -0
  83. package/commonjs/test/ItemsContainer.js.map +1 -0
  84. package/commonjs/test/ScrollableContainer.js +130 -0
  85. package/commonjs/test/ScrollableContainer.js.map +1 -0
  86. package/commonjs/test/VirtualScroller.js +281 -0
  87. package/commonjs/test/VirtualScroller.js.map +1 -0
  88. package/commonjs/utility/debounce.js +2 -2
  89. package/commonjs/utility/debounce.js.map +1 -1
  90. package/commonjs/utility/debug.js.map +1 -1
  91. package/commonjs/utility/getStateSnapshot.js +2 -2
  92. package/commonjs/utility/getStateSnapshot.js.map +1 -1
  93. package/commonjs/utility/px.js.map +1 -1
  94. package/commonjs/utility/px.test.js +1 -1
  95. package/commonjs/utility/px.test.js.map +1 -1
  96. package/commonjs/utility/shallowEqual.js +1 -1
  97. package/commonjs/utility/shallowEqual.js.map +1 -1
  98. package/commonjs/utility/throttle.js.map +1 -1
  99. package/dom/index.cjs +4 -0
  100. package/dom/index.cjs.js +9 -0
  101. package/dom/index.d.ts +6 -4
  102. package/dom/index.js +1 -1
  103. package/dom/package.json +10 -4
  104. package/index.cjs +4 -0
  105. package/index.cjs.js +9 -0
  106. package/index.d.ts +30 -15
  107. package/index.js +1 -1
  108. package/modules/BeforeResize.js +22 -27
  109. package/modules/BeforeResize.js.map +1 -1
  110. package/modules/DOM/Engine.js +6 -6
  111. package/modules/DOM/Engine.js.map +1 -1
  112. package/modules/DOM/ItemsContainer.js +1 -1
  113. package/modules/DOM/ItemsContainer.js.map +1 -1
  114. package/modules/DOM/ListTopOffsetWatcher.js +15 -9
  115. package/modules/DOM/ListTopOffsetWatcher.js.map +1 -1
  116. package/modules/DOM/ScrollableContainer.js +28 -28
  117. package/modules/DOM/ScrollableContainer.js.map +1 -1
  118. package/modules/DOM/VirtualScroller.js +19 -16
  119. package/modules/DOM/VirtualScroller.js.map +1 -1
  120. package/modules/DOM/tbody.js +11 -9
  121. package/modules/DOM/tbody.js.map +1 -1
  122. package/modules/ItemHeights.js +22 -16
  123. package/modules/ItemHeights.js.map +1 -1
  124. package/modules/Layout.js +14 -12
  125. package/modules/Layout.js.map +1 -1
  126. package/modules/Layout.test.js +8 -3
  127. package/modules/Layout.test.js.map +1 -1
  128. package/modules/{ListHeightChangeWatcher.js → ListHeightMeasurement.js} +25 -27
  129. package/modules/ListHeightMeasurement.js.map +1 -0
  130. package/modules/Resize.js +38 -28
  131. package/modules/Resize.js.map +1 -1
  132. package/modules/Scroll.js +28 -44
  133. package/modules/Scroll.js.map +1 -1
  134. package/modules/VirtualScroller.columns.js +36 -0
  135. package/modules/VirtualScroller.columns.js.map +1 -0
  136. package/modules/VirtualScroller.constructor.js +371 -0
  137. package/modules/VirtualScroller.constructor.js.map +1 -0
  138. package/modules/VirtualScroller.items.js +288 -0
  139. package/modules/VirtualScroller.items.js.map +1 -0
  140. package/modules/VirtualScroller.js +132 -1860
  141. package/modules/VirtualScroller.js.map +1 -1
  142. package/modules/VirtualScroller.layout.js +549 -0
  143. package/modules/VirtualScroller.layout.js.map +1 -0
  144. package/modules/VirtualScroller.onRender.js +337 -0
  145. package/modules/VirtualScroller.onRender.js.map +1 -0
  146. package/modules/VirtualScroller.resize.js +176 -0
  147. package/modules/VirtualScroller.resize.js.map +1 -0
  148. package/modules/VirtualScroller.state.js +283 -0
  149. package/modules/VirtualScroller.state.js.map +1 -0
  150. package/modules/VirtualScroller.verticalSpacing.js +54 -0
  151. package/modules/VirtualScroller.verticalSpacing.js.map +1 -0
  152. package/modules/getItemCoordinates.js.map +1 -1
  153. package/modules/getItemsDiff.js.map +1 -1
  154. package/modules/getVerticalSpacing.js.map +1 -1
  155. package/modules/react/VirtualScroller.js +176 -625
  156. package/modules/react/VirtualScroller.js.map +1 -1
  157. package/modules/react/useClassName.js +18 -0
  158. package/modules/react/useClassName.js.map +1 -0
  159. package/modules/react/useHandleItemsChange.js +108 -0
  160. package/modules/react/useHandleItemsChange.js.map +1 -0
  161. package/modules/react/useInstanceMethods.js +28 -0
  162. package/modules/react/useInstanceMethods.js.map +1 -0
  163. package/modules/react/useItemKeys.js +52 -0
  164. package/modules/react/useItemKeys.js.map +1 -0
  165. package/modules/react/useOnItemHeightChange.js +24 -0
  166. package/modules/react/useOnItemHeightChange.js.map +1 -0
  167. package/modules/react/useOnItemStateChange.js +24 -0
  168. package/modules/react/useOnItemStateChange.js.map +1 -0
  169. package/modules/react/useState.js +132 -0
  170. package/modules/react/useState.js.map +1 -0
  171. package/modules/react/useStyle.js +19 -0
  172. package/modules/react/useStyle.js.map +1 -0
  173. package/modules/react/useVirtualScroller.js +51 -0
  174. package/modules/react/useVirtualScroller.js.map +1 -0
  175. package/modules/react/useVirtualScrollerStartStop.js +12 -0
  176. package/modules/react/useVirtualScrollerStartStop.js.map +1 -0
  177. package/modules/test/Engine.js +11 -0
  178. package/modules/test/Engine.js.map +1 -0
  179. package/modules/test/ItemsContainer.js +120 -0
  180. package/modules/test/ItemsContainer.js.map +1 -0
  181. package/modules/test/ScrollableContainer.js +123 -0
  182. package/modules/test/ScrollableContainer.js.map +1 -0
  183. package/modules/test/VirtualScroller.js +270 -0
  184. package/modules/test/VirtualScroller.js.map +1 -0
  185. package/modules/utility/debounce.js +2 -2
  186. package/modules/utility/debounce.js.map +1 -1
  187. package/modules/utility/debug.js.map +1 -1
  188. package/modules/utility/getStateSnapshot.js +2 -2
  189. package/modules/utility/getStateSnapshot.js.map +1 -1
  190. package/modules/utility/px.js.map +1 -1
  191. package/modules/utility/px.test.js +1 -1
  192. package/modules/utility/px.test.js.map +1 -1
  193. package/modules/utility/shallowEqual.js +1 -1
  194. package/modules/utility/shallowEqual.js.map +1 -1
  195. package/modules/utility/throttle.js.map +1 -1
  196. package/package.json +46 -23
  197. package/react/index.cjs +4 -0
  198. package/react/index.cjs.js +9 -0
  199. package/react/index.d.ts +10 -9
  200. package/react/index.js +1 -1
  201. package/react/package.json +10 -4
  202. package/rollup.config.mjs +62 -0
  203. package/runnable/create-commonjs-package-json.js +11 -0
  204. package/source/BeforeResize.js +16 -21
  205. package/source/DOM/Engine.js +8 -10
  206. package/source/DOM/ListTopOffsetWatcher.js +13 -8
  207. package/source/DOM/ScrollableContainer.js +23 -21
  208. package/source/DOM/VirtualScroller.js +27 -11
  209. package/source/DOM/tbody.js +30 -21
  210. package/source/ItemHeights.js +19 -14
  211. package/source/Layout.js +12 -9
  212. package/source/Layout.test.js +8 -3
  213. package/source/{ListHeightChangeWatcher.js → ListHeightMeasurement.js} +21 -20
  214. package/source/Resize.js +41 -25
  215. package/source/Scroll.js +27 -35
  216. package/source/VirtualScroller.columns.js +26 -0
  217. package/source/VirtualScroller.constructor.js +336 -0
  218. package/source/VirtualScroller.items.js +302 -0
  219. package/source/VirtualScroller.js +144 -1872
  220. package/source/VirtualScroller.layout.js +539 -0
  221. package/source/VirtualScroller.onRender.js +345 -0
  222. package/source/VirtualScroller.resize.js +189 -0
  223. package/source/VirtualScroller.state.js +284 -0
  224. package/source/VirtualScroller.verticalSpacing.js +51 -0
  225. package/source/react/VirtualScroller.js +243 -587
  226. package/source/react/useClassName.js +14 -0
  227. package/source/react/useHandleItemsChange.js +115 -0
  228. package/source/react/useInstanceMethods.js +25 -0
  229. package/source/react/useItemKeys.js +59 -0
  230. package/source/react/useOnItemHeightChange.js +28 -0
  231. package/source/react/useOnItemStateChange.js +28 -0
  232. package/source/react/useState.js +114 -0
  233. package/source/react/useStyle.js +20 -0
  234. package/source/react/useVirtualScroller.js +59 -0
  235. package/source/react/useVirtualScrollerStartStop.js +12 -0
  236. package/source/test/Engine.js +11 -0
  237. package/source/test/ItemsContainer.js +87 -0
  238. package/source/test/ScrollableContainer.js +88 -0
  239. package/source/test/VirtualScroller.js +232 -0
  240. package/source/utility/debounce.js +2 -2
  241. package/source/utility/px.test.js +1 -1
  242. package/babel.config.js +0 -25
  243. package/babel.js +0 -5
  244. package/commonjs/ListHeightChangeWatcher.js.map +0 -1
  245. package/dom/index.commonjs.js +0 -4
  246. package/index.commonjs.js +0 -4
  247. package/modules/ListHeightChangeWatcher.js.map +0 -1
  248. package/react/index.commonjs.js +0 -4
@@ -0,0 +1,539 @@
1
+ // For some weird reason, in Chrome, `setTimeout()` would lag up to a second (or more) behind.
2
+ // Turns out, Chrome developers have deprecated `setTimeout()` API entirely without asking anyone.
3
+ // Replacing `setTimeout()` with `requestAnimationFrame()` can work around that Chrome bug.
4
+ // https://github.com/bvaughn/react-virtualized/issues/722
5
+ import { setTimeout, clearTimeout } from 'request-animation-frame-timeout'
6
+
7
+ import log, { warn, isDebug, reportError } from './utility/debug.js'
8
+ import { LAYOUT_REASON } from './Layout.js'
9
+
10
+ export default function() {
11
+ this.onUpdateShownItemIndexes = ({ reason, stateUpdate }) => {
12
+ // In case of "don't do anything".
13
+ const skip = () => {
14
+ if (stateUpdate) {
15
+ this.updateState(stateUpdate)
16
+ }
17
+ }
18
+
19
+ // If new `items` have been set and are waiting to be applied,
20
+ // or if the viewport width has changed requiring a re-layout,
21
+ // then temporarily stop all other updates like "on scroll" updates.
22
+ // This prevents `state` being inconsistent, because, for example,
23
+ // both `setItems()` and this function could update `VirtualScroller` state
24
+ // and having them operate in parallel could result in incorrectly calculated
25
+ // `beforeItemsHeight` / `afterItemsHeight` / `firstShownItemIndex` /
26
+ // `lastShownItemIndex`, because, when operating in parallel, this function
27
+ // would have different `items` than the `setItems()` function, so their
28
+ // results could diverge.
29
+ if (this.newItemsWillBeRendered || this.widthHasChanged || this._isResizing) {
30
+ return skip()
31
+ }
32
+
33
+ // If there're no items then there's no need to re-layout anything.
34
+ if (this.getItemsCount() === 0) {
35
+ return skip()
36
+ }
37
+
38
+ // Cancel a "re-layout when user stops scrolling" timer.
39
+ this.scroll.cancelScheduledLayout()
40
+
41
+ // Cancel a re-layout that is scheduled to run at the next "frame",
42
+ // because a re-layout will be performed right now.
43
+ stateUpdate = this.cancelLayoutTimer({ stateUpdate })
44
+
45
+ // Perform a re-layout.
46
+ log(`~ Update Layout (on ${reason}) ~`)
47
+ updateShownItemIndexes.call(this, { stateUpdate })
48
+ }
49
+
50
+ /**
51
+ * Updates the "from" and "to" shown item indexes.
52
+ * If the list is visible and some of the items being shown are new
53
+ * and are required to be measured first, then
54
+ * `firstNonMeasuredItemIndex` is defined.
55
+ * If the list is visible and all items being shown have been encountered
56
+ * (and measured) before, then `firstNonMeasuredItemIndex` is `undefined`.
57
+ *
58
+ * The `stateUpdate` parameter is just an optional "additional" state update.
59
+ */
60
+ function updateShownItemIndexes({ stateUpdate }) {
61
+ const startedAt = Date.now()
62
+
63
+ // Get shown item indexes.
64
+ let {
65
+ firstShownItemIndex,
66
+ lastShownItemIndex,
67
+ shownItemsHeight,
68
+ firstNonMeasuredItemIndex
69
+ } = getShownItemIndexes.call(this)
70
+
71
+ // If scroll position is scheduled to be restored after render,
72
+ // then the "anchor" item must be rendered, and all of the prepended
73
+ // items before it, all in a single pass. This way, all of the
74
+ // prepended items' heights could be measured right after the render
75
+ // has finished, and the scroll position can then be immediately restored.
76
+ if (this.listHeightMeasurement.hasSnapshot()) {
77
+ if (lastShownItemIndex < this.listHeightMeasurement.getAnchorItemIndex()) {
78
+ lastShownItemIndex = this.listHeightMeasurement.getAnchorItemIndex()
79
+ }
80
+ // `firstShownItemIndex` is always `0` when prepending items.
81
+ // And `lastShownItemIndex` always covers all prepended items in this case.
82
+ // None of the prepended items have been rendered before,
83
+ // so their heights are unknown. The code at the start of this function
84
+ // did therefore set `firstNonMeasuredItemIndex` to non-`undefined`
85
+ // in order to render just the first prepended item in order to
86
+ // measure it, and only then make a decision on how many other
87
+ // prepended items to render. But since we've instructed the code
88
+ // to show all of the prepended items at once, there's no need to
89
+ // "redo layout after render". Additionally, if layout was re-done
90
+ // after render, then there would be a short interval of visual
91
+ // "jitter" due to the scroll position not being restored because it'd
92
+ // wait for the second layout to finish instead of being restored
93
+ // right after the first one.
94
+ firstNonMeasuredItemIndex = undefined
95
+ }
96
+
97
+ // Validate the heights of items to be hidden on next render.
98
+ // For example, a user could click a "Show more" button,
99
+ // or an "Expand YouTube video" button, which would result
100
+ // in the actual height of the list item being different
101
+ // from what has been initially measured in `this.itemHeights[i]`,
102
+ // if the developer didn't call `.onItemStateChange()` and `.onItemHeightChange(i)`.
103
+ if (!validateWillBeHiddenItemHeightsAreAccurate.call(this, firstShownItemIndex, lastShownItemIndex)) {
104
+ log('~ Because some of the will-be-hidden item heights (listed above) have changed since they\'ve last been measured, redo layout. ~')
105
+ // Redo layout, now with the correct item heights.
106
+ return updateShownItemIndexes.call(this, { stateUpdate });
107
+ }
108
+
109
+ // Measure "before" items height.
110
+ const beforeItemsHeight = this.layout.getBeforeItemsHeight(
111
+ firstShownItemIndex
112
+ )
113
+
114
+ // Measure "after" items height.
115
+ const afterItemsHeight = this.layout.getAfterItemsHeight(
116
+ lastShownItemIndex,
117
+ this.getItemsCount()
118
+ )
119
+
120
+ const layoutDuration = Date.now() - startedAt
121
+
122
+ // Debugging.
123
+ log('~ Calculated Layout' + (this.bypass ? ' (bypass)' : '') + ' ~')
124
+ if (layoutDuration < SLOW_LAYOUT_DURATION) {
125
+ // log('Calculated in', layoutDuration, 'ms')
126
+ } else {
127
+ warn('Layout calculated in', layoutDuration, 'ms')
128
+ }
129
+ if (this.getColumnsCount()) {
130
+ log('Columns count', this.getColumnsCount())
131
+ }
132
+ log('First shown item index', firstShownItemIndex)
133
+ log('Last shown item index', lastShownItemIndex)
134
+ log('Before items height', beforeItemsHeight)
135
+ log('After items height (actual or estimated)', afterItemsHeight)
136
+ log('Average item height (used for estimated after items height calculation)', this.itemHeights.getAverage())
137
+ if (isDebug()) {
138
+ log('Item heights', this.getState().itemHeights.slice())
139
+ log('Item states', this.getState().itemStates.slice())
140
+ }
141
+
142
+ // Optionally preload items to be rendered.
143
+ this.onBeforeShowItems(
144
+ this.getState().items,
145
+ this.getState().itemHeights,
146
+ firstShownItemIndex,
147
+ lastShownItemIndex
148
+ )
149
+
150
+ // Set `this.firstNonMeasuredItemIndex`.
151
+ this.firstNonMeasuredItemIndex = firstNonMeasuredItemIndex
152
+
153
+ // Set "previously calculated layout".
154
+ //
155
+ // The "previously calculated layout" feature is not currently used.
156
+ //
157
+ // The current layout snapshot could be stored as a "previously calculated layout" variable
158
+ // so that it could theoretically be used when calculating new layout incrementally
159
+ // rather than from scratch, which would be an optimization.
160
+ //
161
+ // Currently, this feature is not used, and `shownItemsHeight` property
162
+ // is not returned at all, so don't set any "previously calculated layout".
163
+ //
164
+ if (shownItemsHeight === undefined) {
165
+ this.previouslyCalculatedLayout = undefined
166
+ } else {
167
+ // If "previously calculated layout" feature would be implmeneted,
168
+ // then this code would set "previously calculate layout" instance variable.
169
+ //
170
+ // What for would this instance variable be used?
171
+ //
172
+ // Instead of using a `this.previouslyCalculatedLayout` instance variable,
173
+ // this code could use `this.getState()` because it reflects what's currently on screen,
174
+ // but there's a single edge case when it could go out of sync —
175
+ // updating item heights externally via `.onItemHeightChange(i)`.
176
+ //
177
+ // If, for example, an item height was updated externally via `.onItemHeightChange(i)`
178
+ // then `this.getState().itemHeights` would get updated immediately but
179
+ // `this.getState().beforeItemsHeight` or `this.getState().afterItemsHeight`
180
+ // would still correspond to the previous item height, so those would be "stale".
181
+ // On the other hand, same values in `this.previouslyCalculatedLayout` instance variable
182
+ // can also be updated immediately, so they won't go out of sync with the updated item height.
183
+ // That seems the only edge case when using a separate `this.previouslyCalculatedLayout`
184
+ // instance variable instead of using `this.getState()` would theoretically be justified.
185
+ //
186
+ this.previouslyCalculatedLayout = {
187
+ firstShownItemIndex,
188
+ lastShownItemIndex,
189
+ beforeItemsHeight,
190
+ shownItemsHeight
191
+ }
192
+ }
193
+
194
+ // Update `VirtualScroller` state.
195
+ // `VirtualScroller` automatically re-renders on state updates.
196
+ //
197
+ // All `state` properties updated here should be overwritten in
198
+ // the implementation of `setItems()` and `onResize()` methods
199
+ // so that the `state` is not left in an inconsistent state
200
+ // whenever there're concurrent `updateState()` updates that could
201
+ // possibly conflict with one another — instead, those state updates
202
+ // should overwrite each other in terms of priority.
203
+ // These "on scroll" updates have the lowest priority compared to
204
+ // the state updates originating from `setItems()` and `onResize()` methods.
205
+ //
206
+ this.updateState({
207
+ firstShownItemIndex,
208
+ lastShownItemIndex,
209
+ beforeItemsHeight,
210
+ afterItemsHeight,
211
+ ...stateUpdate
212
+ })
213
+ }
214
+
215
+ function getVisibleArea() {
216
+ const visibleArea = this.scroll.getVisibleAreaBounds()
217
+ this.latestLayoutVisibleArea = visibleArea
218
+
219
+ // Subtract the top offset of the list inside the scrollable container.
220
+ const listTopOffsetInsideScrollableContainer = this.getListTopOffsetInsideScrollableContainer()
221
+ return {
222
+ top: visibleArea.top - listTopOffsetInsideScrollableContainer,
223
+ bottom: visibleArea.bottom - listTopOffsetInsideScrollableContainer
224
+ }
225
+ }
226
+
227
+ function getShownItemIndexes() {
228
+ const itemsCount = this.getItemsCount()
229
+
230
+ const {
231
+ top: visibleAreaTop,
232
+ bottom: visibleAreaBottom
233
+ } = getVisibleArea.call(this)
234
+
235
+ if (this.bypass) {
236
+ return {
237
+ firstShownItemIndex: 0,
238
+ lastShownItemIndex: itemsCount - 1,
239
+ // shownItemsHeight: this.getState().itemHeights.reduce((sum, itemHeight) => sum + itemHeight, 0)
240
+ }
241
+ }
242
+
243
+ // Find the indexes of the items that are currently visible
244
+ // (or close to being visible) in the scrollable container.
245
+ // For scrollable containers other than the main screen, it could also
246
+ // check the visibility of such scrollable container itself, because it
247
+ // might be not visible.
248
+ // If such kind of an optimization would hypothetically be implemented,
249
+ // then it would also require listening for "scroll" events on the screen.
250
+ // Overall, I suppose that such "actual visibility" feature would be
251
+ // a very minor optimization and not something I'd deal with.
252
+ const isVisible = visibleAreaTop < this.itemsContainer.getHeight() && visibleAreaBottom > 0
253
+ if (!isVisible) {
254
+ log('The entire list is off-screen. No items are visible.')
255
+ return this.layout.getNonVisibleListShownItemIndexes()
256
+ }
257
+
258
+ // Get shown item indexes.
259
+ return this.layout.getShownItemIndexes({
260
+ itemsCount: this.getItemsCount(),
261
+ visibleAreaTop,
262
+ visibleAreaBottom
263
+ })
264
+ }
265
+
266
+ /**
267
+ * Validates the heights of items to be hidden on next render.
268
+ * For example, a user could click a "Show more" button,
269
+ * or an "Expand YouTube video" button, which would result
270
+ * in the actual height of the list item being different
271
+ * from what has been initially measured in `this.itemHeights[i]`,
272
+ * if the developer didn't call `.onItemStateChange()` and `.onItemHeightChange(i)`.
273
+ */
274
+ function validateWillBeHiddenItemHeightsAreAccurate(firstShownItemIndex, lastShownItemIndex) {
275
+ let isValid = true
276
+ let i = this.getState().firstShownItemIndex
277
+ while (i <= this.getState().lastShownItemIndex) {
278
+ if (i >= firstShownItemIndex && i <= lastShownItemIndex) {
279
+ // The item's still visible.
280
+ } else {
281
+ // The item will be hidden. Re-measure its height.
282
+ // The rationale is that there could be a situation when an item's
283
+ // height has changed, and the developer has properly added an
284
+ // `.onItemHeightChange(i)` call to notify `VirtualScroller`
285
+ // about that change, but at the same time that wouldn't work.
286
+ // For example, suppose there's a list of several items on a page,
287
+ // and those items are in "minimized" state (having height 100px).
288
+ // Then, a user clicks an "Expand all items" button, and all items
289
+ // in the list are expanded (expanded item height is gonna be 700px).
290
+ // `VirtualScroller` demands that `.onItemHeightChange(i)` is called
291
+ // in such cases, and the developer has properly added the code to do that.
292
+ // So, if there were 10 "minimized" items visible on a page, then there
293
+ // will be 10 individual `.onItemHeightChange(i)` calls. No issues so far.
294
+ // But, as the first `.onItemHeightChange(i)` call executes, it immediately
295
+ // ("synchronously") triggers a re-layout, and that re-layout finds out
296
+ // that now, because the first item is big, it occupies most of the screen
297
+ // space, and only the first 3 items are visible on screen instead of 10,
298
+ // and so it leaves the first 3 items mounted and unmounts the rest 7.
299
+ // Then, after `VirtualScroller` has rerendered, the code returns to
300
+ // where it was executing, and calls `.onItemHeightChange(i)` for the
301
+ // second item. It also triggers an immediate re-layout that finds out
302
+ // that only the first 2 items are visible on screen, and it unmounts
303
+ // the third one too. After that, it calls `.onItemHeightChange(i)`
304
+ // for the third item, but that item is no longer rendered, so its height
305
+ // can't be measured, and the same's for all the rest of the original 10 items.
306
+ // So, even though the developer has written their code properly, the
307
+ // `VirtualScroller` still ends up having incorrect `itemHeights[]`:
308
+ // `[700px, 700px, 100px, 100px, 100px, 100px, 100px, 100px, 100px, 100px]`
309
+ // while it should have been `700px` for all of them.
310
+ // To work around such issues, every item's height is re-measured before it
311
+ // gets hidden.
312
+ const previouslyMeasuredItemHeight = this.getState().itemHeights[i]
313
+ const actualItemHeight = remeasureItemHeight.call(this, i)
314
+ if (actualItemHeight !== previouslyMeasuredItemHeight) {
315
+ if (isValid) {
316
+ log('~ Validate will-be-hidden item heights. ~')
317
+ // Update or reset previously calculated layout.
318
+ updatePreviouslyCalculatedLayoutOnItemHeightChange.call(this, i, previouslyMeasuredItemHeight, actualItemHeight)
319
+ }
320
+ isValid = false
321
+ 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.')
322
+ }
323
+ }
324
+ i++
325
+ }
326
+ return isValid
327
+ }
328
+
329
+ function remeasureItemHeight(i) {
330
+ const { firstShownItemIndex } = this.getState()
331
+ return this.itemHeights.remeasureItemHeight(i, firstShownItemIndex)
332
+ }
333
+
334
+ // Updates the snapshot of the current layout when an item's height changes.
335
+ //
336
+ // The "previously calculated layout" feature is not currently used.
337
+ //
338
+ // The current layout snapshot could be stored as a "previously calculated layout" variable
339
+ // so that it could theoretically be used when calculating new layout incrementally
340
+ // rather than from scratch, which would be an optimization.
341
+ //
342
+ function updatePreviouslyCalculatedLayoutOnItemHeightChange(i, previousHeight, newHeight) {
343
+ if (this.previouslyCalculatedLayout) {
344
+ const heightDifference = newHeight - previousHeight
345
+ if (i < this.previouslyCalculatedLayout.firstShownItemIndex) {
346
+ // Patch `this.previouslyCalculatedLayout`'s `.beforeItemsHeight`.
347
+ this.previouslyCalculatedLayout.beforeItemsHeight += heightDifference
348
+ } else if (i > this.previouslyCalculatedLayout.lastShownItemIndex) {
349
+ // Could patch `.afterItemsHeight` of `this.previouslyCalculatedLayout` here,
350
+ // if `.afterItemsHeight` property existed in `this.previouslyCalculatedLayout`.
351
+ if (this.previouslyCalculatedLayout.afterItemsHeight !== undefined) {
352
+ this.previouslyCalculatedLayout.afterItemsHeight += heightDifference
353
+ }
354
+ } else {
355
+ // Patch `this.previouslyCalculatedLayout`'s shown items height.
356
+ this.previouslyCalculatedLayout.shownItemsHeight += newHeight - previousHeight
357
+ }
358
+ }
359
+ }
360
+
361
+ /**
362
+ * Returns the list's top offset relative to the scrollable container's top edge.
363
+ * @return {number}
364
+ */
365
+ this.getListTopOffsetInsideScrollableContainer = () => {
366
+ const listTopOffset = this.scrollableContainer.getItemsContainerTopOffset()
367
+ if (this.listTopOffsetWatcher) {
368
+ this.listTopOffsetWatcher.onListTopOffset(listTopOffset)
369
+ }
370
+ return listTopOffset
371
+ }
372
+
373
+ this._onItemHeightChange = (i) => {
374
+ log('~ Re-measure item height ~')
375
+ log('Item', i)
376
+
377
+ const {
378
+ itemHeights,
379
+ firstShownItemIndex,
380
+ lastShownItemIndex
381
+ } = this.getState()
382
+
383
+ // Check if the item is still rendered.
384
+ if (!(i >= firstShownItemIndex && i <= lastShownItemIndex)) {
385
+ // There could be valid cases when an item is no longer rendered
386
+ // by the time `.onItemHeightChange(i)` gets called.
387
+ // For example, suppose there's a list of several items on a page,
388
+ // and those items are in "minimized" state (having height 100px).
389
+ // Then, a user clicks an "Expand all items" button, and all items
390
+ // in the list are expanded (expanded item height is gonna be 700px).
391
+ // `VirtualScroller` demands that `.onItemHeightChange(i)` is called
392
+ // in such cases, and the developer has properly added the code to do that.
393
+ // So, if there were 10 "minimized" items visible on a page, then there
394
+ // will be 10 individual `.onItemHeightChange(i)` calls. No issues so far.
395
+ // But, as the first `.onItemHeightChange(i)` call executes, it immediately
396
+ // ("synchronously") triggers a re-layout, and that re-layout finds out
397
+ // that now, because the first item is big, it occupies most of the screen
398
+ // space, and only the first 3 items are visible on screen instead of 10,
399
+ // and so it leaves the first 3 items mounted and unmounts the rest 7.
400
+ // Then, after `VirtualScroller` has rerendered, the code returns to
401
+ // where it was executing, and calls `.onItemHeightChange(i)` for the
402
+ // second item. It also triggers an immediate re-layout that finds out
403
+ // that only the first 2 items are visible on screen, and it unmounts
404
+ // the third one too. After that, it calls `.onItemHeightChange(i)`
405
+ // for the third item, but that item is no longer rendered, so its height
406
+ // can't be measured, and the same's for all the rest of the original 10 items.
407
+ // So, even though the developer has written their code properly, there're
408
+ // still situations when the item could be no longer rendered by the time
409
+ // `.onItemHeightChange(i)` gets called.
410
+ 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.')
411
+ }
412
+
413
+ const previousHeight = itemHeights[i]
414
+ if (previousHeight === undefined) {
415
+ return reportError(`"onItemHeightChange()" has been called for item ${i}, but that item hasn't been rendered before.`)
416
+ }
417
+
418
+ const newHeight = remeasureItemHeight.call(this, i)
419
+
420
+ log('Previous height', previousHeight)
421
+ log('New height', newHeight)
422
+
423
+ if (previousHeight !== newHeight) {
424
+ log('~ Item height has changed ~')
425
+
426
+ // Update or reset previously calculated layout.
427
+ updatePreviouslyCalculatedLayoutOnItemHeightChange.call(this, i, previousHeight, newHeight)
428
+
429
+ // Recalculate layout.
430
+ this.onUpdateShownItemIndexes({ reason: LAYOUT_REASON.ITEM_HEIGHT_CHANGED })
431
+
432
+ // Schedule the item height update for after the new items have been rendered.
433
+ if (this.newItemsWillBeRendered) {
434
+ if (!this.itemHeightsThatChangedWhileNewItemsWereBeingRendered) {
435
+ this.itemHeightsThatChangedWhileNewItemsWereBeingRendered = {}
436
+ }
437
+ this.itemHeightsThatChangedWhileNewItemsWereBeingRendered[String(i)] = newHeight
438
+ }
439
+ }
440
+ }
441
+
442
+ this.getPrerenderMargin = () => {
443
+ // The list component renders not only the items that're currently visible
444
+ // but also the items that lie within some extra vertical margin (called
445
+ // "prerender margin") on top and bottom for future scrolling: this way,
446
+ // there'll be significantly less layout recalculations as the user scrolls,
447
+ // because now it doesn't have to recalculate layout on each scroll event.
448
+ // By default, the "prerender margin" is equal to the screen height:
449
+ // this seems to be the optimal value for "Page Up" / "Page Down" navigation
450
+ // and optimized mouse wheel scrolling (a user is unlikely to continuously
451
+ // scroll past the screen height, because they'd stop to read through
452
+ // the newly visible items first, and when they do stop scrolling, that's
453
+ // when layout gets recalculated).
454
+ const renderAheadMarginRatio = 1 // in scrollable container heights.
455
+ return this.scrollableContainer.getHeight() * renderAheadMarginRatio
456
+ }
457
+
458
+ /**
459
+ * Calls `onItemFirstRender()` for items that haven't been
460
+ * "seen" previously.
461
+ * @param {any[]} items
462
+ * @param {number[]} itemHeights
463
+ * @param {number} firstShownItemIndex
464
+ * @param {number} lastShownItemIndex
465
+ */
466
+ this.onBeforeShowItems = (
467
+ items,
468
+ itemHeights,
469
+ firstShownItemIndex,
470
+ lastShownItemIndex
471
+ ) => {
472
+ if (this.onItemInitialRender) {
473
+ let i = firstShownItemIndex
474
+ while (i <= lastShownItemIndex) {
475
+ if (itemHeights[i] === undefined) {
476
+ this.onItemInitialRender(items[i])
477
+ }
478
+ i++
479
+ }
480
+ }
481
+ }
482
+
483
+ this.measureItemHeightsAndSpacing = () => {
484
+ // Measure "newly shown" item heights.
485
+ // Also re-validate already measured items' heights.
486
+ this.itemHeights.measureItemHeights(
487
+ this.getState().firstShownItemIndex,
488
+ this.getState().lastShownItemIndex
489
+ )
490
+
491
+ // Measure item vertical spacing, if required.
492
+ const verticalSpacing = this.measureVerticalSpacingIfNotMeasured()
493
+
494
+ // Return a state update if vertical spacing has been measured.
495
+ // Doesn't set `verticalSpacing: 0` in `state` because it is effectively
496
+ // same as `verticalSpacing: undefined` in terms code behavior and calculations.
497
+ // Not having `verticalSpacing: 0` in `state` just makes the `state` object
498
+ // a bit more cleaner and a bit less cluttered (easier for inspection).
499
+ if (verticalSpacing && verticalSpacing !== 0) {
500
+ // Return a state update.
501
+ // Sets `verticalSpacing` property in `state`.
502
+ return {
503
+ verticalSpacing
504
+ }
505
+ }
506
+ }
507
+
508
+ this.cancelLayoutTimer = ({ stateUpdate }) => {
509
+ if (this.layoutTimer) {
510
+ clearTimeout(this.layoutTimer)
511
+ this.layoutTimer = undefined
512
+ // Merge state updates.
513
+ if (stateUpdate || this.layoutTimerStateUpdate) {
514
+ stateUpdate = {
515
+ ...this.layoutTimerStateUpdate,
516
+ ...stateUpdate
517
+ }
518
+ this.layoutTimerStateUpdate = undefined
519
+ return stateUpdate
520
+ }
521
+ } else {
522
+ return stateUpdate
523
+ }
524
+ }
525
+
526
+ this.scheduleLayoutTimer = ({ reason, stateUpdate }) => {
527
+ this.layoutTimerStateUpdate = stateUpdate
528
+ this.layoutTimer = setTimeout(() => {
529
+ this.layoutTimerStateUpdate = undefined
530
+ this.layoutTimer = undefined
531
+ this.onUpdateShownItemIndexes({
532
+ reason,
533
+ stateUpdate
534
+ })
535
+ }, 0)
536
+ }
537
+ }
538
+
539
+ const SLOW_LAYOUT_DURATION = 15 // in milliseconds.