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
@@ -1,30 +1,7 @@
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 {
8
- supportsTbody,
9
- BROWSER_NOT_SUPPORTED_ERROR,
10
- addTbodyStyles,
11
- setTbodyPadding
12
- } from './DOM/tbody'
13
-
14
- import DOMEngine from './DOM/Engine'
15
-
16
- import Layout, { LAYOUT_REASON } from './Layout'
17
- import Resize from './Resize'
18
- import BeforeResize from './BeforeResize'
19
- import Scroll from './Scroll'
20
- import ListHeightChangeWatcher from './ListHeightChangeWatcher'
21
- import ItemHeights from './ItemHeights'
22
- import getItemsDiff from './getItemsDiff'
23
- import getVerticalSpacing from './getVerticalSpacing'
24
-
25
- import log, { warn, isDebug, reportError } from './utility/debug'
26
- import shallowEqual from './utility/shallowEqual'
27
- import getStateSnapshot from './utility/getStateSnapshot'
1
+ import VirtualScrollerConstructor from './VirtualScroller.constructor.js'
2
+ import { hasTbodyStyles, addTbodyStyles } from './DOM/tbody.js'
3
+ import { LAYOUT_REASON } from './Layout.js'
4
+ import log from './utility/debug.js'
28
5
 
29
6
  export default class VirtualScroller {
30
7
  /**
@@ -38,628 +15,181 @@ export default class VirtualScroller {
38
15
  items,
39
16
  options = {}
40
17
  ) {
41
- const {
42
- onStateChange,
43
- customState,
44
- initialScrollPosition,
45
- onScrollPositionChange,
46
- measureItemsBatchSize,
47
- // `getScrollableContainer` option is deprecated.
48
- // Use `scrollableContainer` instead.
49
- getScrollableContainer,
50
- getColumnsCount,
51
- getItemId,
52
- tbody,
53
- _useTimeoutInRenderLoop,
54
- _waitForScrollingToStop,
55
- // bypassBatchSize
56
- } = options
57
-
58
- let {
59
- getState,
60
- setState
61
- } = options
62
-
63
- let {
64
- bypass,
65
- // prerenderMargin,
66
- estimatedItemHeight,
67
- // getItemState,
68
- onItemInitialRender,
69
- // `onItemFirstRender(i)` is deprecated, use `onItemInitialRender(item)` instead.
70
- onItemFirstRender,
71
- scrollableContainer,
72
- state,
73
- engine
74
- } = options
75
-
76
- log('~ Initialize ~')
77
-
78
- // If `state` is passed then use `items` from `state`
79
- // instead of the `items` argument.
80
- if (state) {
81
- items = state.items
82
- }
83
-
84
- // `getScrollableContainer` option is deprecated.
85
- // Use `scrollableContainer` instead.
86
- if (!scrollableContainer && getScrollableContainer) {
87
- scrollableContainer = getScrollableContainer()
88
- }
89
-
90
- // Could support non-DOM rendering engines.
91
- // For example, React Native, `<canvas/>`, etc.
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)
18
+ VirtualScrollerConstructor.call(
19
+ this,
20
+ getItemsContainerElement,
21
+ items,
22
+ options
23
+ )
24
+ }
109
25
 
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()
26
+ /**
27
+ * Should be invoked after a "container" DOM Element is mounted (inserted into the DOM tree).
28
+ */
29
+ start() {
30
+ if (this._isActive) {
31
+ throw new Error('[virtual-scroller] `VirtualScroller` has already been started')
115
32
  }
116
33
 
117
- this.scrollableContainer = engine.createScrollableContainer(
118
- scrollableContainer,
119
- getItemsContainerElement
120
- )
121
-
122
- // if (prerenderMargin === undefined) {
123
- // // Renders items which are outside of the screen by this "prerender margin".
124
- // // Is the screen height by default: seems to be the optimal value
125
- // // for "Page Up" / "Page Down" navigation and optimized mouse wheel scrolling.
126
- // prerenderMargin = this.scrollableContainer ? this.scrollableContainer.getHeight() : 0
127
- // }
34
+ // If has been stopped previously.
35
+ const isRestart = this._isActive === false
128
36
 
129
- // Work around `<tbody/>` not being able to have `padding`.
130
- // https://gitlab.com/catamphetamine/virtual-scroller/-/issues/1
131
- if (tbody) {
132
- if (engine !== DOMEngine) {
133
- throw new Error('[virtual-scroller] `tbody` option is only supported for DOM rendering engine')
37
+ if (!isRestart) {
38
+ // If no custom one has been configured, uses the default one.
39
+ // Also sets the initial state.
40
+ if (!this._usesCustomStateStorage) {
41
+ this.useDefaultStateStorage()
134
42
  }
135
- log('~ <tbody/> detected ~')
136
- this.tbody = true
137
- if (!supportsTbody()) {
138
- log('~ <tbody/> not supported ~')
139
- reportError(BROWSER_NOT_SUPPORTED_ERROR)
140
- bypass = true
43
+ // If `render()` function parameter was passed,
44
+ // perform an initial render.
45
+ if (this._render) {
46
+ this._render(this.getState())
141
47
  }
142
48
  }
143
49
 
144
- if (bypass) {
145
- log('~ "bypass" mode ~')
146
- }
147
-
148
- // In `bypass` mode, `VirtualScroller` doesn't wait
149
- // for the user to scroll down to render all items:
150
- // instead, it renders all items right away, as if
151
- // the list is rendered without using `VirtualScroller`.
152
- // It was added just to measure how much is the
153
- // performance difference between using a `VirtualScroller`
154
- // and not using a `VirtualScroller`.
155
- // It turned out that unmounting large React component trees
156
- // is a very long process, so `VirtualScroller` does seem to
157
- // make sense when used in a React application.
158
- this.bypass = bypass
159
- // this.bypassBatchSize = bypassBatchSize || 10
50
+ log('~ Start ~')
160
51
 
161
- // Using `setTimeout()` in render loop is a workaround
162
- // for avoiding a React error message:
163
- // "Maximum update depth exceeded.
164
- // This can happen when a component repeatedly calls
165
- // `.setState()` inside `componentWillUpdate()` or `componentDidUpdate()`.
166
- // React limits the number of nested updates to prevent infinite loops."
167
- this._useTimeoutInRenderLoop = _useTimeoutInRenderLoop
52
+ // `this._isActive = true` should be placed somewhere at the start of this function.
53
+ this._isActive = true
168
54
 
169
- if (getItemId) {
170
- this.isItemEqual = (a, b) => getItemId(a) === getItemId(b)
171
- } else {
172
- this.isItemEqual = (a, b) => a === b
173
- }
55
+ // Reset `ListHeightMeasurement` just in case it has some "leftover" state.
56
+ this.listHeightMeasurement.reset()
174
57
 
175
- this.initialItems = items
176
- // this.prerenderMargin = prerenderMargin
58
+ // Reset `_isResizing` flag just in case it has some "leftover" value.
59
+ this._isResizing = undefined
177
60
 
178
- this.onStateChange = onStateChange
61
+ // Reset `_isSettingNewItems` flag just in case it has some "leftover" value.
62
+ this._isSettingNewItems = undefined
179
63
 
180
- this._getColumnsCount = getColumnsCount
181
-
182
- if (onItemInitialRender) {
183
- this.onItemInitialRender = onItemInitialRender
184
- }
185
- // `onItemFirstRender(i)` is deprecated, use `onItemInitialRender(item)` instead.
186
- else if (onItemFirstRender) {
187
- this.onItemInitialRender = (item) => {
188
- warn('`onItemFirstRender(i)` is deprecated, use `onItemInitialRender(item)` instead.')
189
- const { items } = this.getState()
190
- const i = items.indexOf(item)
191
- // The `item` could also be non-found due to the inconsistency bug:
192
- // The reason is that `i` can be non-consistent with the `items`
193
- // passed to `<VirtualScroller/>` in React due to `setState()` not being
194
- // instanteneous: when new `items` are passed to `<VirtualScroller/>`,
195
- // `VirtualScroller.setState({ items })` is called, and if `onItemFirstRender(i)`
196
- // is called after the aforementioned `setState()` is called but before it finishes,
197
- // `i` would point to an index in "previous" `items` while the application
198
- // would assume that `i` points to an index in the "new" `items`,
199
- // resulting in an incorrect item being assumed by the application
200
- // or even in an "array index out of bounds" error.
201
- if (i >= 0) {
202
- onItemFirstRender(i)
203
- }
64
+ // Work around `<tbody/>` not being able to have `padding`.
65
+ // https://gitlab.com/catamphetamine/virtual-scroller/-/issues/1
66
+ if (this.tbody) {
67
+ if (!hasTbodyStyles(this.getItemsContainerElement())) {
68
+ addTbodyStyles(this.getItemsContainerElement())
204
69
  }
205
70
  }
206
71
 
207
- log('Items count', items.length)
208
- if (estimatedItemHeight) {
209
- log('Estimated item height', estimatedItemHeight)
210
- }
211
-
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.
72
+ // If there was a pending state update that didn't get applied
73
+ // because of stopping the `VirtualScroller`, apply that state update now.
226
74
  //
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.
75
+ // The pending state update won't get applied if the scrollable container width
76
+ // has changed but that's ok because that state update currently could only contain:
77
+ // * `scrollableContainerWidth`
78
+ // * `verticalSpacing`
79
+ // * `beforeResize`
80
+ // All of those get rewritten in `onResize()` anyway.
230
81
  //
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.
82
+ let stateUpdate = this._stoppedStateUpdate
83
+ this._stoppedStateUpdate = undefined
233
84
 
234
- // Create default `getState()`/`setState()` functions.
235
- if (!getState) {
236
- getState = () => this.state
237
- setState = (stateUpdate, { willUpdateState, didUpdateState }) => {
238
- const prevState = getState()
239
- // Because this variant of `.setState()` is "synchronous" (immediate),
240
- // it can be written like `...prevState`, and no state updates would be lost.
241
- // But if it was "asynchronous" (not immediate), then `...prevState`
242
- // wouldn't work in all cases, because it could be stale in cases
243
- // when more than a single `setState()` call is made before
244
- // the state actually updates, making `prevState` stale.
245
- const newState = {
246
- ...prevState,
247
- ...stateUpdate
248
- }
249
- willUpdateState(newState, prevState)
250
- this.state = newState
251
- // // Is only used in tests.
252
- // if (this._onStateUpdate) {
253
- // this._onStateUpdate(stateUpdate)
254
- // }
255
- didUpdateState(prevState)
256
- }
257
- }
85
+ // Reset `this.verticalSpacing` so that it re-measures it in cases when
86
+ // the `VirtualScroller` was previously stopped and is now being restarted.
87
+ // The rationale is that a previously captured inter-item vertical spacing
88
+ // can't be "trusted" in a sense that the user might have resized the window
89
+ // after the previous `state` has been snapshotted.
90
+ // If the user has resized the window, then changing window width might have
91
+ // activated different CSS `@media()` "queries" resulting in a potentially different
92
+ // vertical spacing after the restart.
93
+ // If it's not a restart then `this.verticalSpacing` is `undefined` anyway.
94
+ this.verticalSpacing = undefined
258
95
 
259
- this.getState = getState
260
- this.setState = (stateUpdate) => {
261
- if (isDebug()) {
262
- log('Set state', getStateSnapshot(stateUpdate))
96
+ const verticalSpacingStateUpdate = this.measureItemHeightsAndSpacing()
97
+ if (verticalSpacingStateUpdate) {
98
+ stateUpdate = {
99
+ ...stateUpdate,
100
+ ...verticalSpacingStateUpdate
263
101
  }
264
- setState(stateUpdate, {
265
- willUpdateState: this.willUpdateState,
266
- didUpdateState: this.didUpdateState
267
- })
268
102
  }
269
103
 
270
- if (state) {
271
- if (isDebug()) {
272
- log('Initial state (passed)', getStateSnapshot(state))
273
- }
274
- }
104
+ this.resize.start()
105
+ this.scroll.start()
275
106
 
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
- }
305
- }
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
107
+ // If `scrollableContainerWidth` hasn't been measured yet,
108
+ // measure it and write it to state.
109
+ if (this.getState().scrollableContainerWidth === undefined) {
110
+ const scrollableContainerWidth = this.scrollableContainer.getWidth()
111
+ stateUpdate = {
112
+ ...stateUpdate,
113
+ scrollableContainerWidth
317
114
  }
318
- }
319
-
320
- // Create `ItemHeights` instance.
321
- this.itemHeights = new ItemHeights(
322
- this.itemsContainer,
323
- (i) => this.getState().itemHeights[i],
324
- (i, height) => this.getState().itemHeights[i] = height
325
- )
326
-
327
- // Initialize `ItemHeights` from the initially passed `state`.
328
- if (state) {
329
- this.itemHeights.initialize(state.itemHeights)
330
- }
331
-
332
- this.layout = new Layout({
333
- bypass,
334
- estimatedItemHeight,
335
- measureItemsBatchSize: measureItemsBatchSize === undefined ? 50 : measureItemsBatchSize,
336
- getPrerenderMargin: () => this.getPrerenderMargin(),
337
- getVerticalSpacing: () => this.getVerticalSpacing(),
338
- getVerticalSpacingBeforeResize: () => this.getVerticalSpacingBeforeResize(),
339
- getColumnsCount: () => this.getColumnsCount(),
340
- getColumnsCountBeforeResize: () => this.getState().beforeResize && this.getState().beforeResize.columnsCount,
341
- getItemHeight: (i) => this.getState().itemHeights[i],
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
354
- })
355
-
356
- this.resize = new Resize({
357
- bypass,
358
- scrollableContainer: this.scrollableContainer,
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) => {
115
+ } else {
116
+ // Reset layout:
117
+ // * If the scrollable container width has changed while stopped.
118
+ // * If the restored state was calculated for another scrollable container width.
119
+ const newWidth = this.scrollableContainer.getWidth()
120
+ const prevWidth = this.getState().scrollableContainerWidth
121
+ if (newWidth !== prevWidth) {
379
122
  log('~ Scrollable container width changed from', prevWidth, 'to', newWidth, '~')
380
- this.onResize()
123
+ // `stateUpdate` doesn't get passed to `this.onResize()`, and, therefore,
124
+ // won't be applied. But that's ok because currently it could only contain:
125
+ // * `scrollableContainerWidth`
126
+ // * `verticalSpacing`
127
+ // * `beforeResize`
128
+ // All of those get rewritten in `onResize()` anyway.
129
+ return this.onResize()
381
130
  }
382
- })
383
-
384
- this.scroll = new Scroll({
385
- bypass: this.bypass,
386
- scrollableContainer: this.scrollableContainer,
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
- },
394
- initialScrollPosition,
395
- onScrollPositionChange,
396
- isImmediateLayoutScheduled: () => this.layoutTimer,
397
- hasNonRenderedItemsAtTheTop: () => this.getState().firstShownItemIndex > 0,
398
- hasNonRenderedItemsAtTheBottom: () => this.getState().lastShownItemIndex < this.getItemsCount() - 1,
399
- getLatestLayoutVisibleArea: () => this.latestLayoutVisibleArea,
400
- getListTopOffset: this.getListTopOffsetInsideScrollableContainer,
401
- getPrerenderMargin: () => this.getPrerenderMargin()
402
- })
403
-
404
- this.listHeightChangeWatcher = new ListHeightChangeWatcher({
405
- itemsContainer: this.itemsContainer,
406
- getListTopOffset: this.getListTopOffsetInsideScrollableContainer
407
- })
408
-
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
422
- })
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`.
443
- this.setState(state || this.getInitialState(customState))
444
- }
445
-
446
- /**
447
- * Returns the initial state of the `VirtualScroller`.
448
- * @param {object} [customState] — Any additional "custom" state may be stored in `VirtualScroller`'s state. For example, React implementation stores item "refs" as "custom" state.
449
- * @return {object}
450
- */
451
- getInitialState(customState) {
452
- const items = this.initialItems
453
- const state = {
454
- ...customState,
455
- ...this.getInitialLayoutState(items),
456
- items,
457
- itemStates: new Array(items.length)
458
- }
459
- if (isDebug()) {
460
- log('Initial state (autogenerated)', getStateSnapshot(state))
461
131
  }
462
- log('First shown item index', state.firstShownItemIndex)
463
- log('Last shown item index', state.lastShownItemIndex)
464
- return state
465
- }
466
132
 
467
- getInitialLayoutState(items) {
468
- const itemsCount = items.length
469
- const {
470
- firstShownItemIndex,
471
- lastShownItemIndex,
472
- beforeItemsHeight,
473
- afterItemsHeight
474
- } = this.layout.getInitialLayoutValues({
475
- itemsCount,
476
- columnsCount: this.getColumnsCount()
477
- })
478
- const itemHeights = new Array(itemsCount)
479
- // Optionally preload items to be rendered.
480
- this.onBeforeShowItems(
481
- items,
482
- itemHeights,
483
- firstShownItemIndex,
484
- lastShownItemIndex
485
- )
486
- return {
487
- itemHeights,
488
- columnsCount: this.getActualColumnsCountForState(),
489
- verticalSpacing: this.verticalSpacing,
490
- firstShownItemIndex,
491
- lastShownItemIndex,
492
- beforeItemsHeight,
493
- afterItemsHeight
494
- }
495
- }
496
-
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
520
- }
521
-
522
- getColumnsCount() {
523
- return this.getState() && this.getState().columnsCount || 1
524
- }
525
-
526
- getItemsCount() {
527
- return this.getState().items.length
528
- }
529
-
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:
537
- // this seems to be the optimal value for "Page Up" / "Page Down" navigation
538
- // and optimized mouse wheel scrolling (a user is unlikely to continuously
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).
542
- const renderAheadMarginRatio = 1 // in scrollable container heights.
543
- return this.scrollableContainer.getHeight() * renderAheadMarginRatio
544
- }
545
-
546
- /**
547
- * Calls `onItemFirstRender()` for items that haven't been
548
- * "seen" previously.
549
- * @param {any[]} items
550
- * @param {number[]} itemHeights
551
- * @param {number} firstShownItemIndex
552
- * @param {number} lastShownItemIndex
553
- */
554
- onBeforeShowItems(
555
- items,
556
- itemHeights,
557
- firstShownItemIndex,
558
- lastShownItemIndex
559
- ) {
560
- if (this.onItemInitialRender) {
561
- let i = firstShownItemIndex
562
- while (i <= lastShownItemIndex) {
563
- if (itemHeights[i] === undefined) {
564
- this.onItemInitialRender(items[i])
565
- }
566
- i++
133
+ // If the `VirtualScroller` uses custom (external) state storage, then
134
+ // check if the columns count has changed between calling `.getInitialState()`
135
+ // and `.start()`. If it has, perform a re-layout "from scratch".
136
+ if (this._usesCustomStateStorage) {
137
+ const columnsCount = this.getActualColumnsCount()
138
+ const columnsCountFromState = this.getState().columnsCount || 1
139
+ if (columnsCount !== columnsCountFromState) {
140
+ return this.onResize()
567
141
  }
568
142
  }
569
- }
570
-
571
- onMount() {
572
- warn('`.onMount()` instance method name is deprecated, use `.listen()` instance method name instead.')
573
- this.listen()
574
- }
575
-
576
- render() {
577
- warn('`.render()` instance method name is deprecated, use `.listen()` instance method name instead.')
578
- this.listen()
579
- }
580
-
581
- /**
582
- * Should be invoked after a "container" DOM Element is mounted (inserted into the DOM tree).
583
- */
584
- listen() {
585
- if (this.isRendered === false) {
586
- throw new Error('[virtual-scroller] Can\'t restart a `VirtualScroller` after it has been stopped')
587
- }
588
-
589
- log('~ Rendered (initial) ~')
590
- // `this.isRendered = true` should be the first statement in this function,
591
- // otherwise `DOMVirtualScroller` would enter an infinite re-render loop.
592
- this.isRendered = true
593
-
594
- const stateUpdate = this.measureItemHeightsAndSpacingAndUpdateTablePadding()
595
-
596
- this.resize.listen()
597
- this.scroll.listen()
598
-
599
- // Work around `<tbody/>` not being able to have `padding`.
600
- // https://gitlab.com/catamphetamine/virtual-scroller/-/issues/1
601
- if (this.tbody) {
602
- addTbodyStyles(this.getItemsContainerElement())
603
- }
604
143
 
605
144
  // Re-calculate layout and re-render the list.
606
145
  // Do that even if when an initial `state` parameter, containing layout values,
607
146
  // has been passed. The reason is that the `state` parameter can't be "trusted"
608
147
  // in a way that it could have been snapshotted for another window width and
609
148
  // the user might have resized their window since then.
610
- this.onUpdateShownItemIndexes({ reason: LAYOUT_REASON.MOUNTED, stateUpdate })
149
+ this.onUpdateShownItemIndexes({ reason: LAYOUT_REASON.STARTED, stateUpdate })
611
150
  }
612
151
 
613
- measureItemHeightsAndSpacingAndUpdateTablePadding() {
614
- // Measure "newly shown" item heights.
615
- // Also re-validate already measured items' heights.
616
- this.itemHeights.measureItemHeights(
617
- this.getState().firstShownItemIndex,
618
- this.getState().lastShownItemIndex
619
- )
152
+ // Could be passed as a "callback" parameter, so bind it to `this`.
153
+ stop = () => {
154
+ if (!this._isActive) {
155
+ throw new Error('[virtual-scroller] Can\'t stop a `VirtualScroller` that hasn\'t been started')
156
+ }
620
157
 
621
- // Update item vertical spacing.
622
- const verticalSpacing = this.measureVerticalSpacing()
158
+ this._isActive = false
623
159
 
624
- // Update `<tbody/>` `padding`.
625
- // (`<tbody/>` is different in a way that it can't have `margin`, only `padding`).
626
- // https://gitlab.com/catamphetamine/virtual-scroller/-/issues/1
627
- if (this.tbody) {
628
- setTbodyPadding(
629
- this.getItemsContainerElement(),
630
- this.getState().beforeItemsHeight,
631
- this.getState().afterItemsHeight
632
- )
633
- }
160
+ log('~ Stop ~')
161
+
162
+ this.resize.stop()
163
+ this.scroll.stop()
634
164
 
635
- // Return a state update.
636
- if (verticalSpacing !== undefined) {
637
- return { verticalSpacing }
165
+ // Stop `ListTopOffsetWatcher` if it has been started.
166
+ // There seems to be no need to restart `ListTopOffsetWatcher`.
167
+ // It's mainly a hacky workaround for development mode anyway.
168
+ if (this.listTopOffsetWatcher && this.listTopOffsetWatcher.isStarted()) {
169
+ this.listTopOffsetWatcher.stop()
638
170
  }
639
- }
640
171
 
641
- getVisibleArea() {
642
- const visibleArea = this.scroll.getVisibleAreaBounds()
643
- this.latestLayoutVisibleArea = visibleArea
172
+ // Cancel any scheduled layout.
173
+ this.cancelLayoutTimer({})
174
+ }
644
175
 
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
176
+ hasToBeStarted() {
177
+ if (!this._isActive) {
178
+ throw new Error('[virtual-scroller] `VirtualScroller` hasn\'t been started')
650
179
  }
651
180
  }
652
181
 
653
- /**
654
- * Returns the list's top offset relative to the scrollable container's top edge.
655
- * @return {number}
656
- */
657
- getListTopOffsetInsideScrollableContainer = () => {
658
- const listTopOffset = this.scrollableContainer.getItemsContainerTopOffset()
659
- if (this.listTopOffsetWatcher) {
660
- this.listTopOffsetWatcher.onListTopOffset(listTopOffset)
661
- }
662
- return listTopOffset
182
+ // Bind it to `this` because this function could hypothetically be passed
183
+ // as a "callback" parameter.
184
+ updateLayout = () => {
185
+ this.hasToBeStarted()
186
+ this.onUpdateShownItemIndexes({ reason: LAYOUT_REASON.MANUAL })
187
+ }
188
+
189
+ // Bind the function to `this` so that it could be passed as a callback
190
+ // in a random application's code.
191
+ onRender = () => {
192
+ this._onRender(this.getState(), this.previousState)
663
193
  }
664
194
 
665
195
  /**
@@ -675,831 +205,23 @@ export default class VirtualScroller {
675
205
  return this.getListTopOffsetInsideScrollableContainer() + itemTopOffsetInList
676
206
  }
677
207
 
678
- onUnmount() {
679
- warn('`.onUnmount()` instance method name is deprecated, use `.stop()` instance method name instead.')
680
- this.stop()
681
- }
682
-
683
- destroy() {
684
- warn('`.destroy()` instance method name is deprecated, use `.stop()` instance method name instead.')
685
- this.stop()
686
- }
687
-
688
- stop = () => {
689
- this.isRendered = false
690
- this.resize.stop()
691
- this.scroll.stop()
692
- if (this.listTopOffsetWatcher) {
693
- this.listTopOffsetWatcher.stop()
694
- }
695
- this.cancelLayoutTimer({})
696
- }
697
-
698
- cancelLayoutTimer({ stateUpdate }) {
699
- if (this.layoutTimer) {
700
- clearTimeout(this.layoutTimer)
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
713
- }
714
- }
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
-
728
208
  /**
729
- * Should be called right before `state` is updated.
730
- * @param {object} prevState
731
- * @param {object} newState
209
+ * Forces a re-measure of an item's height.
210
+ * @param {number} i — Item index
732
211
  */
733
- willUpdateState = (newState, prevState) => {
734
- // Ignore setting initial state.
735
- if (!prevState) {
736
- return
737
- }
738
- // This function isn't currently used.
739
- // Was previously used to capture scroll position in order to
740
- // restore it later after the new state is rendered.
741
- }
742
-
743
- /**
744
- * Should be called right after `state` is updated.
745
- * @param {object} prevState
746
- */
747
- didUpdateState = (prevState) => {
748
- const newState = this.getState()
749
-
750
- if (this.onStateChange) {
751
- if (!shallowEqual(newState, prevState)) {
752
- this.onStateChange(newState, prevState)
753
- }
754
- }
755
-
756
- // Ignore setting initial state.
757
- if (!prevState) {
758
- return
759
- }
760
-
761
- if (!this.isRendered) {
762
- return
763
- }
764
-
765
- log('~ Rendered ~')
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
-
817
- const { items: previousItems } = prevState
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).
826
- if (newItems !== previousItems) {
827
- const itemsDiff = this.getItemsDiff(previousItems, newItems)
828
- if (itemsDiff) {
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)
835
- } else {
836
- this.itemHeights.reset()
837
- // `newState.itemHeights` is an array of `undefined`s.
838
- this.itemHeights.initialize(newState.itemHeights)
839
- }
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
- }
855
- }
856
- }
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 ||
870
- newState.lastShownItemIndex !== prevState.lastShownItemIndex ||
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
- }
896
- }
897
-
898
- if (layoutUpdateReason) {
899
- this.updateStateRightAfterRender({
900
- stateUpdate,
901
- reason: layoutUpdateReason
902
- })
903
- } else if (stateUpdate) {
904
- this.setState(stateUpdate)
905
- }
906
- }
907
-
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
- }) {
1020
- // In React, `setTimeout()` is used to prevent a React error:
1021
- // "Maximum update depth exceeded.
1022
- // This can happen when a component repeatedly calls
1023
- // `.setState()` inside `componentWillUpdate()` or `componentDidUpdate()`.
1024
- // React limits the number of nested updates to prevent infinite loops."
1025
- if (this._useTimeoutInRenderLoop) {
1026
- // Cancel a previously scheduled re-layout.
1027
- stateUpdate = this.cancelLayoutTimer({ stateUpdate })
1028
- // Schedule a new re-layout.
1029
- this.scheduleLayoutTimer({
1030
- reason,
1031
- stateUpdate
1032
- })
1033
- } else {
1034
- this.onUpdateShownItemIndexes({
1035
- reason,
1036
- stateUpdate
1037
- })
1038
- }
1039
- }
1040
-
1041
- measureVerticalSpacing() {
1042
- if (this.verticalSpacing === undefined) {
1043
- const { firstShownItemIndex, lastShownItemIndex } = this.getState()
1044
- log('~ Measure item vertical spacing ~')
1045
- const verticalSpacing = getVerticalSpacing({
1046
- itemsContainer: this.itemsContainer,
1047
- renderedItemsCount: lastShownItemIndex - firstShownItemIndex + 1
1048
- })
1049
- if (verticalSpacing === undefined) {
1050
- log('Not enough items rendered to measure vertical spacing')
1051
- } else {
1052
- log('Item vertical spacing', verticalSpacing)
1053
- this.verticalSpacing = verticalSpacing
1054
- if (verticalSpacing !== 0) {
1055
- return verticalSpacing
1056
- }
1057
- }
1058
- }
1059
- }
1060
-
1061
- remeasureItemHeight(i) {
1062
- const { firstShownItemIndex } = this.getState()
1063
- return this.itemHeights.remeasureItemHeight(i, firstShownItemIndex)
1064
- }
1065
-
1066
- onItemStateChange(i, newItemState) {
1067
- if (isDebug()) {
1068
- log('~ Item state changed ~')
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.
1075
- log('Previous state' + '\n' + JSON.stringify(this.getState().itemStates[i], 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
1087
- }
1088
- }
1089
-
1090
212
  onItemHeightChange(i) {
1091
- log('~ Re-measure item height ~')
1092
- log('Item', i)
1093
-
1094
- const {
1095
- itemHeights,
1096
- firstShownItemIndex,
1097
- lastShownItemIndex
1098
- } = this.getState()
1099
-
1100
- // Check if the item is still rendered.
1101
- if (!(i >= firstShownItemIndex && i <= lastShownItemIndex)) {
1102
- // There could be valid cases when an item is no longer rendered
1103
- // by the time `.onItemHeightChange(i)` gets called.
1104
- // For example, suppose there's a list of several items on a page,
1105
- // and those items are in "minimized" state (having height 100px).
1106
- // Then, a user clicks an "Expand all items" button, and all items
1107
- // in the list are expanded (expanded item height is gonna be 700px).
1108
- // `VirtualScroller` demands that `.onItemHeightChange(i)` is called
1109
- // in such cases, and the developer has properly added the code to do that.
1110
- // So, if there were 10 "minimized" items visible on a page, then there
1111
- // will be 10 individual `.onItemHeightChange(i)` calls. No issues so far.
1112
- // But, as the first `.onItemHeightChange(i)` call executes, it immediately
1113
- // ("synchronously") triggers a re-layout, and that re-layout finds out
1114
- // that now, because the first item is big, it occupies most of the screen
1115
- // space, and only the first 3 items are visible on screen instead of 10,
1116
- // and so it leaves the first 3 items mounted and unmounts the rest 7.
1117
- // Then, after `VirtualScroller` has rerendered, the code returns to
1118
- // where it was executing, and calls `.onItemHeightChange(i)` for the
1119
- // second item. It also triggers an immediate re-layout that finds out
1120
- // that only the first 2 items are visible on screen, and it unmounts
1121
- // the third one too. After that, it calls `.onItemHeightChange(i)`
1122
- // for the third item, but that item is no longer rendered, so its height
1123
- // can't be measured, and the same's for all the rest of the original 10 items.
1124
- // So, even though the developer has written their code properly, there're
1125
- // still situations when the item could be no longer rendered by the time
1126
- // `.onItemHeightChange(i)` gets called.
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.')
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
-
1137
- log('Previous height', previousHeight)
1138
- log('New height', newHeight)
1139
-
1140
- if (previousHeight !== newHeight) {
1141
- log('~ Item height has changed ~')
1142
-
1143
- // Update or reset previously calculated layout.
1144
- this.updatePreviouslyCalculatedLayoutOnItemHeightChange(i, previousHeight, newHeight)
1145
-
1146
- // Recalculate layout.
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
- }
1183
- }
1184
- }
1185
-
1186
- /**
1187
- * Validates the heights of items to be hidden on next render.
1188
- * For example, a user could click a "Show more" button,
1189
- * or an "Expand YouTube video" button, which would result
1190
- * in the actual height of the list item being different
1191
- * from what has been initially measured in `this.itemHeights[i]`,
1192
- * if the developer didn't call `.onItemStateChange()` and `.onItemHeightChange(i)`.
1193
- */
1194
- validateWillBeHiddenItemHeightsAreAccurate(firstShownItemIndex, lastShownItemIndex) {
1195
- let isValid = true
1196
- let i = this.getState().firstShownItemIndex
1197
- while (i <= this.getState().lastShownItemIndex) {
1198
- if (i >= firstShownItemIndex && i <= lastShownItemIndex) {
1199
- // The item's still visible.
1200
- } else {
1201
- // The item will be hidden. Re-measure its height.
1202
- // The rationale is that there could be a situation when an item's
1203
- // height has changed, and the developer has properly added an
1204
- // `.onItemHeightChange(i)` call to notify `VirtualScroller`
1205
- // about that change, but at the same time that wouldn't work.
1206
- // For example, suppose there's a list of several items on a page,
1207
- // and those items are in "minimized" state (having height 100px).
1208
- // Then, a user clicks an "Expand all items" button, and all items
1209
- // in the list are expanded (expanded item height is gonna be 700px).
1210
- // `VirtualScroller` demands that `.onItemHeightChange(i)` is called
1211
- // in such cases, and the developer has properly added the code to do that.
1212
- // So, if there were 10 "minimized" items visible on a page, then there
1213
- // will be 10 individual `.onItemHeightChange(i)` calls. No issues so far.
1214
- // But, as the first `.onItemHeightChange(i)` call executes, it immediately
1215
- // ("synchronously") triggers a re-layout, and that re-layout finds out
1216
- // that now, because the first item is big, it occupies most of the screen
1217
- // space, and only the first 3 items are visible on screen instead of 10,
1218
- // and so it leaves the first 3 items mounted and unmounts the rest 7.
1219
- // Then, after `VirtualScroller` has rerendered, the code returns to
1220
- // where it was executing, and calls `.onItemHeightChange(i)` for the
1221
- // second item. It also triggers an immediate re-layout that finds out
1222
- // that only the first 2 items are visible on screen, and it unmounts
1223
- // the third one too. After that, it calls `.onItemHeightChange(i)`
1224
- // for the third item, but that item is no longer rendered, so its height
1225
- // can't be measured, and the same's for all the rest of the original 10 items.
1226
- // So, even though the developer has written their code properly, the
1227
- // `VirtualScroller` still ends up having incorrect `itemHeights[]`:
1228
- // `[700px, 700px, 100px, 100px, 100px, 100px, 100px, 100px, 100px, 100px]`
1229
- // while it should have been `700px` for all of them.
1230
- // To work around such issues, every item's height is re-measured before it
1231
- // gets hidden.
1232
- const previouslyMeasuredItemHeight = this.getState().itemHeights[i]
1233
- const actualItemHeight = this.remeasureItemHeight(i)
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
- }
1240
- isValid = false
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.')
1242
- }
1243
- }
1244
- i++
1245
- }
1246
- return isValid
1247
- }
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
-
1288
- /**
1289
- * Updates the "from" and "to" shown item indexes.
1290
- * If the list is visible and some of the items being shown are new
1291
- * and are required to be measured first, then
1292
- * `firstNonMeasuredItemIndex` is defined.
1293
- * If the list is visible and all items being shown have been encountered
1294
- * (and measured) before, then `firstNonMeasuredItemIndex` is `undefined`.
1295
- *
1296
- * The `stateUpdate` parameter is just an optional "additional" state update.
1297
- */
1298
- updateShownItemIndexes = ({ stateUpdate }) => {
1299
- const startedAt = Date.now()
1300
-
1301
- // Get shown item indexes.
1302
- let {
1303
- firstShownItemIndex,
1304
- lastShownItemIndex,
1305
- shownItemsHeight,
1306
- firstNonMeasuredItemIndex
1307
- } = this.getShownItemIndexes()
1308
-
1309
- // If scroll position is scheduled to be restored after render,
1310
- // then the "anchor" item must be rendered, and all of the prepended
1311
- // items before it, all in a single pass. This way, all of the
1312
- // prepended items' heights could be measured right after the render
1313
- // has finished, and the scroll position can then be immediately restored.
1314
- if (this.listHeightChangeWatcher.hasSnapshot()) {
1315
- if (lastShownItemIndex < this.listHeightChangeWatcher.getAnchorItemIndex()) {
1316
- lastShownItemIndex = this.listHeightChangeWatcher.getAnchorItemIndex()
1317
- }
1318
- // `firstShownItemIndex` is always `0` when prepending items.
1319
- // And `lastShownItemIndex` always covers all prepended items in this case.
1320
- // None of the prepended items have been rendered before,
1321
- // so their heights are unknown. The code at the start of this function
1322
- // did therefore set `firstNonMeasuredItemIndex` to non-`undefined`
1323
- // in order to render just the first prepended item in order to
1324
- // measure it, and only then make a decision on how many other
1325
- // prepended items to render. But since we've instructed the code
1326
- // to show all of the prepended items at once, there's no need to
1327
- // "redo layout after render". Additionally, if layout was re-done
1328
- // after render, then there would be a short interval of visual
1329
- // "jitter" due to the scroll position not being restored because it'd
1330
- // wait for the second layout to finish instead of being restored
1331
- // right after the first one.
1332
- firstNonMeasuredItemIndex = undefined
1333
- }
1334
-
1335
- // Validate the heights of items to be hidden on next render.
1336
- // For example, a user could click a "Show more" button,
1337
- // or an "Expand YouTube video" button, which would result
1338
- // in the actual height of the list item being different
1339
- // from what has been initially measured in `this.itemHeights[i]`,
1340
- // if the developer didn't call `.onItemStateChange()` and `.onItemHeightChange(i)`.
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. ~')
1343
- // Redo layout, now with the correct item heights.
1344
- return this.updateShownItemIndexes({ stateUpdate });
1345
- }
1346
-
1347
- // Measure "before" items height.
1348
- const beforeItemsHeight = this.layout.getBeforeItemsHeight(
1349
- firstShownItemIndex
1350
- )
1351
-
1352
- // Measure "after" items height.
1353
- const afterItemsHeight = this.layout.getAfterItemsHeight(
1354
- lastShownItemIndex,
1355
- this.getItemsCount()
1356
- )
1357
-
1358
- const layoutDuration = Date.now() - startedAt
1359
-
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
- }
1367
- if (this._getColumnsCount) {
1368
- log('Columns count', this.getColumnsCount())
1369
- }
1370
- log('First shown item index', firstShownItemIndex)
1371
- log('Last shown item index', lastShownItemIndex)
1372
- log('Before items height', beforeItemsHeight)
1373
- log('After items height (actual or estimated)', afterItemsHeight)
1374
- log('Average item height (used for estimated after items height calculation)', this.itemHeights.getAverage())
1375
- if (isDebug()) {
1376
- log('Item heights', this.getState().itemHeights.slice())
1377
- log('Item states', this.getState().itemStates.slice())
1378
- }
1379
-
1380
- // Optionally preload items to be rendered.
1381
- this.onBeforeShowItems(
1382
- this.getState().items,
1383
- this.getState().itemHeights,
1384
- firstShownItemIndex,
1385
- lastShownItemIndex
1386
- )
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
- //
1444
- this.setState({
1445
- firstShownItemIndex,
1446
- lastShownItemIndex,
1447
- beforeItemsHeight,
1448
- afterItemsHeight,
1449
- ...stateUpdate
1450
- })
1451
- }
1452
-
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
-
1475
- // If there're no items then there's no need to re-layout anything.
1476
- if (this.getItemsCount() === 0) {
1477
- return skip()
1478
- }
1479
-
1480
- // Cancel a "re-layout when user stops scrolling" timer.
1481
- this.scroll.cancelScheduledLayout()
1482
-
1483
- // Cancel a re-layout that is scheduled to run at the next "frame",
1484
- // because a re-layout will be performed right now.
1485
- stateUpdate = this.cancelLayoutTimer({ stateUpdate })
1486
-
1487
- // Perform a re-layout.
1488
- log(`~ Update Layout (on ${reason}) ~`)
1489
- this.updateShownItemIndexes({ stateUpdate })
213
+ this.hasToBeStarted()
214
+ this._onItemHeightChange(i)
1490
215
  }
1491
216
 
1492
- updateLayout = () => this.onUpdateShownItemIndexes({ reason: LAYOUT_REASON.MANUAL })
1493
-
1494
- // `.layout()` method name is deprecated, use `.updateLayout()` instead.
1495
- layout = () => this.updateLayout()
1496
-
1497
217
  /**
1498
- * @deprecated
1499
- * `.updateItems()` has been renamed to `.setItems()`.
218
+ * Updates an item's state in `state.itemStates[]`.
219
+ * @param {number} i Item index
220
+ * @param {any} i — Item's new state
1500
221
  */
1501
- updateItems(newItems, options) {
1502
- return this.setItems(newItems, options)
222
+ onItemStateChange(i, newItemState) {
223
+ this.hasToBeStarted()
224
+ this._onItemStateChange(i, newItemState)
1503
225
  }
1504
226
 
1505
227
  /**
@@ -1508,457 +230,7 @@ export default class VirtualScroller {
1508
230
  * @param {boolean} [options.preserveScrollPositionOnPrependItems] — Set to `true` to enable "restore scroll position after prepending items" feature (could be useful when implementing "Show previous items" button).
1509
231
  */
1510
232
  setItems(newItems, options = {}) {
1511
- // * @param {object} [newCustomState] — If `customState` was passed to `getInitialState()`, this `newCustomState` updates it.
1512
- const {
1513
- items: previousItems
1514
- } = 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
-
1531
- log('~ Update items ~')
1532
-
1533
- let layoutUpdate
1534
- let itemsUpdateInfo
1535
-
1536
- // Compare the new items to the current items.
1537
- const itemsDiff = this.getItemsDiff(previousItems, newItems)
1538
-
1539
- // See if it's an "incremental" items update.
1540
- if (itemsDiff) {
1541
- const {
1542
- firstShownItemIndex,
1543
- lastShownItemIndex,
1544
- beforeItemsHeight,
1545
- afterItemsHeight
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({
1561
- firstShownItemIndex,
1562
- lastShownItemIndex,
1563
- beforeItemsHeight,
1564
- afterItemsHeight
1565
- }, {
1566
- prependedItemsCount,
1567
- appendedItemsCount
1568
- }, {
1569
- itemsCount: newItems.length,
1570
- columnsCount: this.getActualColumnsCount(),
1571
- shouldRestoreScrollPosition
1572
- })
1573
-
1574
- if (prependedItemsCount > 0) {
1575
- log('Prepend', prependedItemsCount, 'items')
1576
-
1577
- itemHeights = new Array(prependedItemsCount).concat(itemHeights)
1578
-
1579
- if (itemStates) {
1580
- itemStates = new Array(prependedItemsCount).concat(itemStates)
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
- }
1610
- }
1611
-
1612
- if (appendedItemsCount > 0) {
1613
- log('Append', appendedItemsCount, 'items')
1614
- itemHeights = itemHeights.concat(new Array(appendedItemsCount))
1615
- if (itemStates) {
1616
- itemStates = itemStates.concat(new Array(appendedItemsCount))
1617
- }
1618
- }
1619
-
1620
- itemsUpdateInfo = {
1621
- prepend: prependedItemsCount > 0,
1622
- append: appendedItemsCount > 0
1623
- }
1624
- } else {
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.')
1626
- log('Previous items', previousItems)
1627
- log('New items', newItems)
1628
-
1629
- // Reset item heights and item states.
1630
- itemHeights = new Array(newItems.length)
1631
- itemStates = new Array(newItems.length)
1632
-
1633
- layoutUpdate = this.layout.getInitialLayoutValues({
1634
- itemsCount: newItems.length,
1635
- columnsCount: this.getActualColumnsCount()
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
- }
1662
- }
1663
-
1664
- log('~ Update state ~')
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
-
1678
- // Optionally preload items to be rendered.
1679
- //
1680
- // `layoutUpdate` is equivalent to `layoutValuesAfterUpdate` because
1681
- // `layoutUpdate` contains all the relevant properties.
1682
- //
1683
- this.onBeforeShowItems(
1684
- newItems,
1685
- itemHeights,
1686
- layoutUpdate.firstShownItemIndex,
1687
- layoutUpdate.lastShownItemIndex
1688
- )
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 = {
1737
- // ...customState,
1738
- ...layoutUpdate,
1739
- items: newItems,
1740
- itemStates,
1741
- itemHeights
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)
233
+ this.hasToBeStarted()
234
+ return this._setItems(newItems, options)
1783
235
  }
1784
-
1785
- getItemsDiff(previousItems, newItems) {
1786
- return getItemsDiff(previousItems, newItems, this.isItemEqual)
1787
- }
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.
236
+ }