virtual-scroller 1.11.2 → 1.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/README.md +13 -11
  3. package/bundle/virtual-scroller-dom.js +1 -1
  4. package/bundle/virtual-scroller-dom.js.map +1 -1
  5. package/bundle/virtual-scroller-react.js +1 -1
  6. package/bundle/virtual-scroller-react.js.map +1 -1
  7. package/bundle/virtual-scroller.js +1 -1
  8. package/bundle/virtual-scroller.js.map +1 -1
  9. package/commonjs/DOM/ItemsContainer.js +10 -3
  10. package/commonjs/DOM/ItemsContainer.js.map +1 -1
  11. package/commonjs/DOM/VirtualScroller.js +13 -1
  12. package/commonjs/DOM/VirtualScroller.js.map +1 -1
  13. package/commonjs/ItemHeights.js +5 -5
  14. package/commonjs/ItemHeights.js.map +1 -1
  15. package/commonjs/ItemNotRenderedError.js +64 -0
  16. package/commonjs/ItemNotRenderedError.js.map +1 -0
  17. package/commonjs/Layout.test.js +10 -0
  18. package/commonjs/Layout.test.js.map +1 -1
  19. package/commonjs/VirtualScroller.js +23 -5
  20. package/commonjs/VirtualScroller.js.map +1 -1
  21. package/commonjs/VirtualScroller.layout.js +81 -39
  22. package/commonjs/VirtualScroller.layout.js.map +1 -1
  23. package/commonjs/VirtualScroller.onRender.js +97 -45
  24. package/commonjs/VirtualScroller.onRender.js.map +1 -1
  25. package/commonjs/VirtualScroller.state.js +50 -18
  26. package/commonjs/VirtualScroller.state.js.map +1 -1
  27. package/commonjs/react/VirtualScroller.js +31 -46
  28. package/commonjs/react/VirtualScroller.js.map +1 -1
  29. package/commonjs/react/useItemKeys.js +11 -3
  30. package/commonjs/react/useItemKeys.js.map +1 -1
  31. package/commonjs/react/useOnChange.js +19 -0
  32. package/commonjs/react/useOnChange.js.map +1 -0
  33. package/commonjs/react/{useOnItemHeightChange.js → useOnItemHeightDidChange.js} +7 -7
  34. package/commonjs/react/useOnItemHeightDidChange.js.map +1 -0
  35. package/commonjs/react/{useHandleItemsPropertyChange.js → useSetNewItemsOnItemsPropertyChange.js} +15 -14
  36. package/commonjs/react/useSetNewItemsOnItemsPropertyChange.js.map +1 -0
  37. package/commonjs/react/useState.js +162 -69
  38. package/commonjs/react/useState.js.map +1 -1
  39. package/commonjs/react/useStyle.js +3 -5
  40. package/commonjs/react/useStyle.js.map +1 -1
  41. package/commonjs/react/useUpdateItemKeysOnItemsChange.js +61 -0
  42. package/commonjs/react/useUpdateItemKeysOnItemsChange.js.map +1 -0
  43. package/commonjs/test/ItemsContainer.js +22 -1
  44. package/commonjs/test/ItemsContainer.js.map +1 -1
  45. package/commonjs/utility/debug.js +30 -6
  46. package/commonjs/utility/debug.js.map +1 -1
  47. package/dom/index.d.ts +1 -1
  48. package/index.cjs +2 -0
  49. package/index.d.ts +7 -1
  50. package/index.js +1 -0
  51. package/modules/DOM/ItemsContainer.js +8 -3
  52. package/modules/DOM/ItemsContainer.js.map +1 -1
  53. package/modules/DOM/VirtualScroller.js +13 -1
  54. package/modules/DOM/VirtualScroller.js.map +1 -1
  55. package/modules/ItemHeights.js +5 -5
  56. package/modules/ItemHeights.js.map +1 -1
  57. package/modules/ItemNotRenderedError.js +57 -0
  58. package/modules/ItemNotRenderedError.js.map +1 -0
  59. package/modules/Layout.test.js +10 -0
  60. package/modules/Layout.test.js.map +1 -1
  61. package/modules/VirtualScroller.js +17 -5
  62. package/modules/VirtualScroller.js.map +1 -1
  63. package/modules/VirtualScroller.layout.js +78 -39
  64. package/modules/VirtualScroller.layout.js.map +1 -1
  65. package/modules/VirtualScroller.onRender.js +98 -46
  66. package/modules/VirtualScroller.onRender.js.map +1 -1
  67. package/modules/VirtualScroller.state.js +50 -18
  68. package/modules/VirtualScroller.state.js.map +1 -1
  69. package/modules/react/VirtualScroller.js +31 -46
  70. package/modules/react/VirtualScroller.js.map +1 -1
  71. package/modules/react/useItemKeys.js +8 -3
  72. package/modules/react/useItemKeys.js.map +1 -1
  73. package/modules/react/useOnChange.js +11 -0
  74. package/modules/react/useOnChange.js.map +1 -0
  75. package/modules/react/{useOnItemHeightChange.js → useOnItemHeightDidChange.js} +6 -6
  76. package/modules/react/useOnItemHeightDidChange.js.map +1 -0
  77. package/modules/react/{useHandleItemsPropertyChange.js → useSetNewItemsOnItemsPropertyChange.js} +11 -13
  78. package/modules/react/useSetNewItemsOnItemsPropertyChange.js.map +1 -0
  79. package/modules/react/useState.js +156 -73
  80. package/modules/react/useState.js.map +1 -1
  81. package/modules/react/useStyle.js +3 -5
  82. package/modules/react/useStyle.js.map +1 -1
  83. package/{commonjs/react/useHandleItemIndexesChange.js → modules/react/useUpdateItemKeysOnItemsChange.js} +18 -21
  84. package/modules/react/useUpdateItemKeysOnItemsChange.js.map +1 -0
  85. package/modules/test/ItemsContainer.js +20 -1
  86. package/modules/test/ItemsContainer.js.map +1 -1
  87. package/modules/utility/debug.js +31 -6
  88. package/modules/utility/debug.js.map +1 -1
  89. package/package.json +1 -1
  90. package/source/DOM/ItemsContainer.js +8 -3
  91. package/source/DOM/VirtualScroller.js +11 -1
  92. package/source/ItemHeights.js +5 -5
  93. package/source/ItemNotRenderedError.js +16 -0
  94. package/source/Layout.test.js +9 -0
  95. package/source/VirtualScroller.js +14 -3
  96. package/source/VirtualScroller.layout.js +77 -38
  97. package/source/VirtualScroller.onRender.js +95 -42
  98. package/source/VirtualScroller.state.js +57 -20
  99. package/source/react/VirtualScroller.js +28 -39
  100. package/source/react/useItemKeys.js +9 -2
  101. package/source/react/useOnChange.js +11 -0
  102. package/source/react/{useOnItemHeightChange.js → useOnItemHeightDidChange.js} +5 -5
  103. package/source/react/{useHandleItemsPropertyChange.js → useSetNewItemsOnItemsPropertyChange.js} +11 -11
  104. package/source/react/useState.js +159 -71
  105. package/source/react/useStyle.js +2 -2
  106. package/source/react/{useHandleItemIndexesChange.js → useUpdateItemKeysOnItemsChange.js} +17 -9
  107. package/source/test/ItemsContainer.js +22 -1
  108. package/source/utility/debug.js +18 -4
  109. package/commonjs/react/useHandleItemIndexesChange.js.map +0 -1
  110. package/commonjs/react/useHandleItemsPropertyChange.js.map +0 -1
  111. package/commonjs/react/useOnItemHeightChange.js.map +0 -1
  112. package/modules/react/useHandleItemIndexesChange.js +0 -45
  113. package/modules/react/useHandleItemIndexesChange.js.map +0 -1
  114. package/modules/react/useHandleItemsPropertyChange.js.map +0 -1
  115. package/modules/react/useOnItemHeightChange.js.map +0 -1
@@ -141,8 +141,17 @@ export default class VirtualScroller {
141
141
  }
142
142
  }
143
143
 
144
+ /**
145
+ * @deprecated
146
+ * `.onItemHeightChange()` has been renamed to `.onItemHeightDidChange()`.
147
+ */
144
148
  onItemHeightChange(i) {
145
- this.virtualScroller.onItemHeightChange(i)
149
+ warn('`.onItemHeightChange(i)` method was renamed to `.onItemHeightDidChange(i)`')
150
+ this.onItemHeightDidChange(i)
151
+ }
152
+
153
+ onItemHeightDidChange(i) {
154
+ this.virtualScroller.onItemHeightDidChange(i)
146
155
  }
147
156
 
148
157
  setItemState(i, newState) {
@@ -154,6 +163,7 @@ export default class VirtualScroller {
154
163
  * `.updateItems()` has been renamed to `.setItems()`.
155
164
  */
156
165
  updateItems(newItems, options) {
166
+ warn('`.updateItems()` method was renamed to `.setItems(i)`')
157
167
  this.setItems(newItems, options)
158
168
  }
159
169
 
@@ -119,7 +119,7 @@ export default class ItemHeights {
119
119
  // Measure item heights that haven't been measured previously.
120
120
  // Don't re-measure item heights that have been measured previously.
121
121
  // The rationale is that developers are supposed to manually call
122
- // `.onItemHeightChange()` every time an item's height changes.
122
+ // `.onItemHeightDidChange()` immediately every time an item's height has changed.
123
123
  // If developers don't neglect that rule, item heights won't
124
124
  // change unexpectedly.
125
125
  if (this._get(i) === undefined) {
@@ -169,7 +169,7 @@ export default class ItemHeights {
169
169
  const previousHeight = this._get(i)
170
170
  const height = this._measureItemHeight(i, firstShownItemIndex)
171
171
  if (previousHeight !== height) {
172
- warn('Item index', i, 'height changed unexpectedly: it was', previousHeight, 'before, but now it is', height, '. An item\'s height is allowed to change only in two cases: when the item\'s "state" changes and the developer calls `setItemState(i, newState)`, or when the item\'s height changes for any reason and the developer calls `onItemHeightChange(i)`. Perhaps you forgot to persist the item\'s "state" by calling `setItemState(i, newState)` when it changed, and that "state" got lost when the item element was unmounted, which resulted in a different height when the item was shown again having its "state" reset.')
172
+ warn('Item index', i, 'height changed unexpectedly: it was', previousHeight, 'before, but now it is', height, '. An item\'s height is allowed to change only in two cases: when the item\'s "state" changes and the developer calls `setItemState(i, newState)`, or when the item\'s height changes for any reason and the developer calls `onItemHeightDidChange(i)` right after that happens. Perhaps you forgot to persist the item\'s "state" by calling `setItemState(i, newState)` when it changed, and that "state" got lost when the item element was unmounted, which resulted in a different height when the item was shown again having its "state" reset.')
173
173
  // Update the item's height as an attempt to fix things.
174
174
  this._set(i, height)
175
175
  }
@@ -189,18 +189,18 @@ export default class ItemHeights {
189
189
  remeasureItemHeight(i, firstShownItemIndex) {
190
190
  const previousHeight = this._get(i)
191
191
  const height = this._measureItemHeight(i, firstShownItemIndex)
192
- // // Because this function is called from `.onItemHeightChange()`,
192
+ // // Because this function is called from `.onItemHeightDidChange()`,
193
193
  // // there're no guarantees in which circumstances a developer calls it,
194
194
  // // and for which item indexes.
195
195
  // // Therefore, to guard against cases of incorrect usage,
196
196
  // // this function won't crash anything if the item isn't rendered
197
197
  // // or hasn't been previously rendered.
198
198
  // if (height !== undefined) {
199
- // reportError(`"onItemHeightChange()" has been called for item ${i}, but that item isn't rendered.`)
199
+ // reportError(`"onItemHeightDidChange()" has been called for item ${i}, but that item isn't currently rendered.`)
200
200
  // return
201
201
  // }
202
202
  // if (previousHeight === undefined) {
203
- // reportError(`"onItemHeightChange()" has been called for item ${i}, but that item hasn't been rendered before.`)
203
+ // reportError(`"onItemHeightDidChange()" has been called for item ${i}, but that item hasn't been rendered before.`)
204
204
  // return
205
205
  // }
206
206
  this._set(i, height)
@@ -0,0 +1,16 @@
1
+ export default class ItemNotRenderedError extends Error {
2
+ constructor({
3
+ renderedElementIndex,
4
+ renderedElementsCount,
5
+ message
6
+ }) {
7
+ super(message || getDefaultMessage({ renderedElementIndex, renderedElementsCount }))
8
+ }
9
+ }
10
+
11
+ function getDefaultMessage({
12
+ renderedElementIndex,
13
+ renderedElementsCount
14
+ }) {
15
+ return `Element with index ${renderedElementIndex} was not found in the list of Rendered Item Elements in the Items Container of Virtual Scroller. There're only ${renderedElementsCount} Elements there.`
16
+ }
@@ -148,6 +148,10 @@ describe('Layout', function() {
148
148
 
149
149
  let shouldResetGridLayout
150
150
 
151
+ const errors = []
152
+
153
+ global.VirtualScrollerCatchError = (error) => errors.push(error)
154
+
151
155
  layout.getLayoutUpdateForItemsDiff(
152
156
  {
153
157
  firstShownItemIndex: 3,
@@ -171,6 +175,11 @@ describe('Layout', function() {
171
175
  afterItemsHeight: 5 * (ITEM_HEIGHT + VERTICAL_SPACING)
172
176
  })
173
177
 
178
+ global.VirtualScrollerCatchError = undefined
179
+ errors.length.should.equal(2)
180
+ errors[0].message.should.equal('[virtual-scroller] ~ Prepended items count 5 is not divisible by Columns Count 4 ~')
181
+ errors[1].message.should.equal('[virtual-scroller] Layout reset required')
182
+
174
183
  shouldResetGridLayout.should.equal(true)
175
184
  })
176
185
  })
@@ -1,7 +1,7 @@
1
1
  import VirtualScrollerConstructor from './VirtualScroller.constructor.js'
2
2
  import { hasTbodyStyles, addTbodyStyles } from './DOM/tbody.js'
3
3
  import { LAYOUT_REASON } from './Layout.js'
4
- import log from './utility/debug.js'
4
+ import log, { warn } from './utility/debug.js'
5
5
 
6
6
  export default class VirtualScroller {
7
7
  /**
@@ -35,6 +35,8 @@ export default class VirtualScroller {
35
35
  const isRestart = this._isActive === false
36
36
 
37
37
  if (!isRestart) {
38
+ this.waitingForRender = true
39
+
38
40
  // If no custom state storage has been configured, use the default one.
39
41
  // Also sets the initial state.
40
42
  if (!this._usesCustomStateStorage) {
@@ -205,13 +207,22 @@ export default class VirtualScroller {
205
207
  return this.getListTopOffsetInsideScrollableContainer() + itemTopOffsetInList
206
208
  }
207
209
 
210
+ /**
211
+ * @deprecated
212
+ * `.onItemHeightChange()` has been renamed to `.onItemHeightDidChange()`.
213
+ */
214
+ onItemHeightChange(i) {
215
+ warn('`.onItemHeightChange(i)` method was renamed to `.onItemHeightDidChange(i)`')
216
+ this.onItemHeightDidChange(i)
217
+ }
218
+
208
219
  /**
209
220
  * Forces a re-measure of an item's height.
210
221
  * @param {number} i — Item index
211
222
  */
212
- onItemHeightChange(i) {
223
+ onItemHeightDidChange(i) {
213
224
  this.hasToBeStarted()
214
- this._onItemHeightChange(i)
225
+ this._onItemHeightDidChange(i)
215
226
  }
216
227
 
217
228
  /**
@@ -7,6 +7,8 @@ import { setTimeout, clearTimeout } from 'request-animation-frame-timeout'
7
7
  import log, { warn, isDebug, reportError } from './utility/debug.js'
8
8
  import { LAYOUT_REASON } from './Layout.js'
9
9
 
10
+ import ItemNotRenderedError from './ItemNotRenderedError.js'
11
+
10
12
  export default function() {
11
13
  this.onUpdateShownItemIndexes = ({ reason, stateUpdate }) => {
12
14
  // In case of "don't do anything".
@@ -99,7 +101,7 @@ export default function() {
99
101
  // or an "Expand YouTube video" button, which would result
100
102
  // in the actual height of the list item being different
101
103
  // from what has been initially measured in `this.itemHeights[i]`,
102
- // if the developer didn't call `.setItemState(i, newState)` and `.onItemHeightChange(i)`.
104
+ // if the developer didn't call `.setItemState(i, newState)` and `.onItemHeightDidChange(i)`.
103
105
  if (!validateWillBeHiddenItemHeightsAreAccurate.call(this, firstShownItemIndex, lastShownItemIndex)) {
104
106
  log('~ Because some of the will-be-hidden item heights (listed above) have changed since they\'ve last been measured, redo layout. ~')
105
107
  // Redo layout, now with the correct item heights.
@@ -149,6 +151,9 @@ export default function() {
149
151
 
150
152
  // Set `this.firstNonMeasuredItemIndex`.
151
153
  this.firstNonMeasuredItemIndex = firstNonMeasuredItemIndex
154
+ // if (firstNonMeasuredItemIndex !== undefined) {
155
+ // log('Non-measured item index that will be measured at next layout', firstNonMeasuredItemIndex)
156
+ // }
152
157
 
153
158
  // Set "previously calculated layout".
154
159
  //
@@ -172,9 +177,9 @@ export default function() {
172
177
  // Instead of using a `this.previouslyCalculatedLayout` instance variable,
173
178
  // this code could use `this.getState()` because it reflects what's currently on screen,
174
179
  // but there's a single edge case when it could go out of sync —
175
- // updating item heights externally via `.onItemHeightChange(i)`.
180
+ // updating item heights externally via `.onItemHeightDidChange(i)`.
176
181
  //
177
- // If, for example, an item height was updated externally via `.onItemHeightChange(i)`
182
+ // If, for example, an item height was updated externally via `.onItemHeightDidChange(i)`
178
183
  // then `this.getState().itemHeights` would get updated immediately but
179
184
  // `this.getState().beforeItemsHeight` or `this.getState().afterItemsHeight`
180
185
  // would still correspond to the previous item height, so those would be "stale".
@@ -269,7 +274,7 @@ export default function() {
269
274
  * or an "Expand YouTube video" button, which would result
270
275
  * in the actual height of the list item being different
271
276
  * from what has been initially measured in `this.itemHeights[i]`,
272
- * if the developer didn't call `.setItemState(i, newState)` and `.onItemHeightChange(i)`.
277
+ * if the developer didn't call `.setItemState(i, newState)` and `.onItemHeightDidChange(i)`.
273
278
  */
274
279
  function validateWillBeHiddenItemHeightsAreAccurate(firstShownItemIndex, lastShownItemIndex) {
275
280
  let isValid = true
@@ -281,26 +286,26 @@ export default function() {
281
286
  // The item will be hidden. Re-measure its height.
282
287
  // The rationale is that there could be a situation when an item's
283
288
  // height has changed, and the developer has properly added an
284
- // `.onItemHeightChange(i)` call to notify `VirtualScroller`
289
+ // `.onItemHeightDidChange(i)` call to notify `VirtualScroller`
285
290
  // about that change, but at the same time that wouldn't work.
286
291
  // For example, suppose there's a list of several items on a page,
287
292
  // and those items are in "minimized" state (having height 100px).
288
293
  // Then, a user clicks an "Expand all items" button, and all items
289
294
  // in the list are expanded (expanded item height is gonna be 700px).
290
- // `VirtualScroller` demands that `.onItemHeightChange(i)` is called
295
+ // `VirtualScroller` demands that `.onItemHeightDidChange(i)` is called
291
296
  // in such cases, and the developer has properly added the code to do that.
292
297
  // So, if there were 10 "minimized" items visible on a page, then there
293
- // will be 10 individual `.onItemHeightChange(i)` calls. No issues so far.
294
- // But, as the first `.onItemHeightChange(i)` call executes, it immediately
298
+ // will be 10 individual `.onItemHeightDidChange(i)` calls. No issues so far.
299
+ // But, as the first `.onItemHeightDidChange(i)` call executes, it immediately
295
300
  // ("synchronously") triggers a re-layout, and that re-layout finds out
296
301
  // that now, because the first item is big, it occupies most of the screen
297
302
  // space, and only the first 3 items are visible on screen instead of 10,
298
303
  // and so it leaves the first 3 items mounted and unmounts the rest 7.
299
304
  // Then, after `VirtualScroller` has rerendered, the code returns to
300
- // where it was executing, and calls `.onItemHeightChange(i)` for the
305
+ // where it was executing, and calls `.onItemHeightDidChange(i)` for the
301
306
  // second item. It also triggers an immediate re-layout that finds out
302
307
  // that only the first 2 items are visible on screen, and it unmounts
303
- // the third one too. After that, it calls `.onItemHeightChange(i)`
308
+ // the third one too. After that, it calls `.onItemHeightDidChange(i)`
304
309
  // for the third item, but that item is no longer rendered, so its height
305
310
  // can't be measured, and the same's for all the rest of the original 10 items.
306
311
  // So, even though the developer has written their code properly, the
@@ -318,7 +323,7 @@ export default function() {
318
323
  updatePreviouslyCalculatedLayoutOnItemHeightChange.call(this, i, previouslyMeasuredItemHeight, actualItemHeight)
319
324
  }
320
325
  isValid = false
321
- warn('Item index', i, 'is no longer visible and will be unmounted. Its height has changed from', previouslyMeasuredItemHeight, 'to', actualItemHeight, 'since it was last measured. This is not necessarily a bug, and could happen, for example, on screen width change, or when there\'re several `onItemHeightChange(i)` calls issued at the same time, and the first one triggers a re-layout before the rest of them have had a chance to be executed.')
326
+ 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 `onItemHeightDidChange(i)` calls issued at the same time, and the first one triggers a re-layout before the rest of them have had a chance to be executed.')
322
327
  }
323
328
  }
324
329
  i++
@@ -340,20 +345,21 @@ export default function() {
340
345
  // rather than from scratch, which would be an optimization.
341
346
  //
342
347
  function updatePreviouslyCalculatedLayoutOnItemHeightChange(i, previousHeight, newHeight) {
343
- if (this.previouslyCalculatedLayout) {
348
+ const prevLayout = this.previouslyCalculatedLayout
349
+ if (prevLayout) {
344
350
  const heightDifference = newHeight - previousHeight
345
- if (i < this.previouslyCalculatedLayout.firstShownItemIndex) {
346
- // Patch `this.previouslyCalculatedLayout`'s `.beforeItemsHeight`.
347
- this.previouslyCalculatedLayout.beforeItemsHeight += heightDifference
348
- } else if (i > this.previouslyCalculatedLayout.lastShownItemIndex) {
349
- // Could patch `.afterItemsHeight` of `this.previouslyCalculatedLayout` here,
350
- // if `.afterItemsHeight` property existed in `this.previouslyCalculatedLayout`.
351
- if (this.previouslyCalculatedLayout.afterItemsHeight !== undefined) {
352
- this.previouslyCalculatedLayout.afterItemsHeight += heightDifference
351
+ if (i < prevLayout.firstShownItemIndex) {
352
+ // Patch `prevLayout`'s `.beforeItemsHeight`.
353
+ prevLayout.beforeItemsHeight += heightDifference
354
+ } else if (i > prevLayout.lastShownItemIndex) {
355
+ // Could patch `.afterItemsHeight` of `prevLayout` here,
356
+ // if `.afterItemsHeight` property existed in `prevLayout`.
357
+ if (prevLayout.afterItemsHeight !== undefined) {
358
+ prevLayout.afterItemsHeight += heightDifference
353
359
  }
354
360
  } else {
355
- // Patch `this.previouslyCalculatedLayout`'s shown items height.
356
- this.previouslyCalculatedLayout.shownItemsHeight += newHeight - previousHeight
361
+ // Patch `prevLayout`'s shown items height.
362
+ prevLayout.shownItemsHeight += newHeight - previousHeight
357
363
  }
358
364
  }
359
365
  }
@@ -370,8 +376,8 @@ export default function() {
370
376
  return listTopOffset
371
377
  }
372
378
 
373
- this._onItemHeightChange = (i) => {
374
- log('~ Re-measure item height ~')
379
+ this._onItemHeightDidChange = (i) => {
380
+ log('~ On Item Height Did Change was called ~')
375
381
  log('Item index', i)
376
382
 
377
383
  const {
@@ -383,53 +389,86 @@ export default function() {
383
389
  // Check if the item is still rendered.
384
390
  if (!(i >= firstShownItemIndex && i <= lastShownItemIndex)) {
385
391
  // There could be valid cases when an item is no longer rendered
386
- // by the time `.onItemHeightChange(i)` gets called.
392
+ // by the time `.onItemHeightDidChange(i)` gets called.
387
393
  // For example, suppose there's a list of several items on a page,
388
394
  // and those items are in "minimized" state (having height 100px).
389
395
  // Then, a user clicks an "Expand all items" button, and all items
390
396
  // in the list are expanded (expanded item height is gonna be 700px).
391
- // `VirtualScroller` demands that `.onItemHeightChange(i)` is called
397
+ // `VirtualScroller` demands that `.onItemHeightDidChange(i)` is called
392
398
  // in such cases, and the developer has properly added the code to do that.
393
399
  // So, if there were 10 "minimized" items visible on a page, then there
394
- // will be 10 individual `.onItemHeightChange(i)` calls. No issues so far.
395
- // But, as the first `.onItemHeightChange(i)` call executes, it immediately
400
+ // will be 10 individual `.onItemHeightDidChange(i)` calls. No issues so far.
401
+ // But, as the first `.onItemHeightDidChange(i)` call executes, it immediately
396
402
  // ("synchronously") triggers a re-layout, and that re-layout finds out
397
403
  // that now, because the first item is big, it occupies most of the screen
398
404
  // space, and only the first 3 items are visible on screen instead of 10,
399
405
  // and so it leaves the first 3 items mounted and unmounts the rest 7.
400
406
  // Then, after `VirtualScroller` has rerendered, the code returns to
401
- // where it was executing, and calls `.onItemHeightChange(i)` for the
407
+ // where it was executing, and calls `.onItemHeightDidChange(i)` for the
402
408
  // second item. It also triggers an immediate re-layout that finds out
403
409
  // that only the first 2 items are visible on screen, and it unmounts
404
- // the third one too. After that, it calls `.onItemHeightChange(i)`
410
+ // the third one too. After that, it calls `.onItemHeightDidChange(i)`
405
411
  // for the third item, but that item is no longer rendered, so its height
406
412
  // can't be measured, and the same's for all the rest of the original 10 items.
407
413
  // So, even though the developer has written their code properly, there're
408
414
  // still situations when the item could be no longer rendered by the time
409
- // `.onItemHeightChange(i)` gets called.
410
- return warn('The item is no longer rendered. This is not necessarily a bug, and could happen, for example, when when a developer calls `onItemHeightChange(i)` while looping through a batch of items.')
415
+ // `.onItemHeightDidChange(i)` gets called.
416
+ return warn('The item is no longer rendered. This is not necessarily a bug, and could happen, for example, when when a developer calls `onItemHeightDidChange(i)` while looping through a batch of items.')
411
417
  }
412
418
 
413
419
  const previousHeight = itemHeights[i]
414
420
  if (previousHeight === undefined) {
415
- return reportError(`"onItemHeightChange()" has been called for item ${i}, but that item hasn't been rendered before.`)
421
+ return reportError(`"onItemHeightDidChange()" has been called for item index ${i} but the item hasn't been rendered before.`)
416
422
  }
417
423
 
418
- const newHeight = remeasureItemHeight.call(this, i)
424
+ log('~ Re-measure item height ~')
425
+
426
+ let newHeight
427
+
428
+ try {
429
+ newHeight = remeasureItemHeight.call(this, i)
430
+ } catch (error) {
431
+ // Successfully finishing an `onItemHeightDidChange(i)` call is not considered
432
+ // critical for `VirtualScroller`'s operation, so such errors could be ignored.
433
+ if (error instanceof ItemNotRenderedError) {
434
+ return reportError(`"onItemHeightDidChange()" has been called for item index ${i} but the item is not currently rendered and can\'t be measured. The exact error was: ${error.message}`)
435
+ }
436
+ }
419
437
 
420
438
  log('Previous height', previousHeight)
421
439
  log('New height', newHeight)
422
440
 
423
441
  if (previousHeight !== newHeight) {
424
- log('~ Item height has changed ~')
442
+ log('~ Item height has changed. Should update layout. ~')
425
443
 
426
- // Update or reset previously calculated layout.
444
+ // Update or reset a previously calculated layout
445
+ // so that the "diff"s based on that layout in the future
446
+ // produce correct results.
427
447
  updatePreviouslyCalculatedLayoutOnItemHeightChange.call(this, i, previousHeight, newHeight)
428
448
 
429
449
  // Recalculate layout.
430
- this.onUpdateShownItemIndexes({ reason: LAYOUT_REASON.ITEM_HEIGHT_CHANGED })
450
+ //
451
+ // If the `VirtualScroller` is already waiting for a state update to be rendered,
452
+ // delay `onItemHeightDidChange(i)`'s re-layout until that state update is rendered.
453
+ // The reason is that React `<VirtualScroller/>`'s `onHeightDidChange()` is meant to
454
+ // be called inside `useLayoutEffect()` hook. Due to how React is implemented internally,
455
+ // that might happen in the middle of the currently pending `setState()` operation
456
+ // being applied, resulting in weird "race condition" bugs.
457
+ //
458
+ if (this.waitingForRender) {
459
+ log('~ Another state update is already waiting to be rendered. Delay the layout update until then. ~')
460
+ this.updateLayoutAfterRenderBecauseItemHeightChanged = true
461
+ } else {
462
+ this.onUpdateShownItemIndexes({ reason: LAYOUT_REASON.ITEM_HEIGHT_CHANGED })
463
+ }
431
464
 
432
- // Schedule the item height update for after the new items have been rendered.
465
+ // If there was a request for `setState()` with new `items`, then the changes
466
+ // to `currentState.itemHeights[]` made above in a `remeasureItemHeight()` call
467
+ // would be overwritten when that pending `setState()` call gets applied.
468
+ // To fix that, the updates to current `itemHeights[]` are noted in
469
+ // `this.itemHeightsThatChangedWhileNewItemsWereBeingRendered` variable.
470
+ // That variable is then checked when the `setState()` call with the new `items`
471
+ // has been updated.
433
472
  if (this.newItemsWillBeRendered) {
434
473
  if (!this.itemHeightsThatChangedWhileNewItemsWereBeingRendered) {
435
474
  this.itemHeightsThatChangedWhileNewItemsWereBeingRendered = {}
@@ -1,4 +1,4 @@
1
- import log, { warn, isDebug } from './utility/debug.js'
1
+ import log, { warn, reportError, isDebug } from './utility/debug.js'
2
2
  import getStateSnapshot from './utility/getStateSnapshot.js'
3
3
  import shallowEqual from './utility/shallowEqual.js'
4
4
  import { LAYOUT_REASON } from './Layout.js'
@@ -11,6 +11,8 @@ export default function() {
11
11
  * @param {object} [prevState]
12
12
  */
13
13
  this._onRender = (newState, prevState) => {
14
+ this.waitingForRender = false
15
+
14
16
  log('~ Rendered ~')
15
17
  if (isDebug()) {
16
18
  log('State', getStateSnapshot(newState))
@@ -33,19 +35,58 @@ export default function() {
33
35
  )
34
36
  }
35
37
 
36
- if (!prevState) {
37
- return
38
+ // `this.mostRecentlySetState` checks that state management behavior is correct:
39
+ // that in situations when there're multiple new states waiting to be set,
40
+ // only the latest one gets applied.
41
+ // It keeps the code simpler and prevents possible race condition bugs.
42
+ // For example, `VirtualScroller` keeps track of its latest requested
43
+ // state update in different instance variable flags which assume that
44
+ // only that latest requested state update gets actually applied.
45
+ //
46
+ // This check should also be performed for the initial render in order to
47
+ // guarantee that no potentially incorrect state update goes unnoticed.
48
+ // Incorrect state updates could happen when `VirtualScroller` state
49
+ // is managed externally by passing `getState()`/`updateState()` options.
50
+ //
51
+ // Perform the check only when `this.mostRecentSetStateValue` is defined.
52
+ // `this.mostRecentSetStateValue` is normally gonna be `undefined` at the initial render
53
+ // because the initial state is not set by calling `this.updateState()`.
54
+ // At the same time, it is possible that the initial render is delayed
55
+ // for whatever reason, and `this.updateState()` gets called before the initial render,
56
+ // so `this.mostRecentSetStateValue` could also be defined at the initial render,
57
+ // in which case the check should be performed.
58
+ //
59
+ if (this.mostRecentSetStateValue) {
60
+ // "Shallow equality" is used here instead of "strict equality"
61
+ // because a developer might choose to supply an `updateState()` function
62
+ // rather than a `setState()` function, in which case the `updateState()` function
63
+ // would construct its own state object.
64
+ if (!shallowEqual(newState, this.mostRecentSetStateValue)) {
65
+ warn('The most recent state that was set', getStateSnapshot(this.mostRecentSetStateValue))
66
+ reportError('The state that has been rendered is not the most recent one that was set')
67
+ }
38
68
  }
39
69
 
40
70
  // `this.resetStateUpdateFlags()` must be called before calling
41
71
  // `this.measureItemHeightsAndSpacing()`.
42
72
  const {
43
73
  nonMeasuredItemsHaveBeenRendered,
74
+ itemHeightHasChanged,
44
75
  widthHasChanged
45
76
  } = resetStateUpdateFlags.call(this)
46
77
 
47
78
  let layoutUpdateReason
48
79
 
80
+ if (this.updateLayoutAfterRenderBecauseItemHeightChanged) {
81
+ layoutUpdateReason = LAYOUT_REASON.ITEM_HEIGHT_CHANGED
82
+ }
83
+
84
+ if (!prevState) {
85
+ if (!layoutUpdateReason) {
86
+ return
87
+ }
88
+ }
89
+
49
90
  // If the `VirtualScroller`, while calculating layout parameters, encounters
50
91
  // a not-shown item with a non-measured height, it calls `updateState()` just to
51
92
  // render that item first, and then, after the list has been re-rendered, it measures
@@ -74,41 +115,43 @@ export default function() {
74
115
  this.verticalSpacing = undefined
75
116
  }
76
117
 
77
- const { items: previousItems } = prevState
78
- const { items: newItems } = newState
79
- // Even if `this.newItemsWillBeRendered` flag is `true`,
80
- // `newItems` could still be equal to `previousItems`.
81
- // For example, when `updateState()` calls don't update `state` immediately
82
- // and a developer first calls `setItems(newItems)` and then calls `setItems(oldItems)`:
83
- // in that case, `this.newItemsWillBeRendered` flag will be `true` but the actual `items`
84
- // in state wouldn't have changed due to the first `updateState()` call being overwritten
85
- // by the second `updateState()` call (that's called "batching state updates" in React).
86
- if (newItems !== previousItems) {
87
- const itemsDiff = this.getItemsDiff(previousItems, newItems)
88
- if (itemsDiff) {
89
- // The call to `.onPrepend()` must precede the call to `.measureItemHeights()`
90
- // which is called in `.onRender()`.
91
- // `this.itemHeights.onPrepend()` updates `firstMeasuredItemIndex`
92
- // and `lastMeasuredItemIndex` of `this.itemHeights`.
93
- const { prependedItemsCount } = itemsDiff
94
- this.itemHeights.onPrepend(prependedItemsCount)
95
- } else {
96
- this.itemHeights.reset()
97
- }
118
+ if (prevState) {
119
+ const { items: previousItems } = prevState
120
+ const { items: newItems } = newState
121
+ // Even if `this.newItemsWillBeRendered` flag is `true`,
122
+ // `newItems` could still be equal to `previousItems`.
123
+ // For example, when `updateState()` calls don't update `state` immediately
124
+ // and a developer first calls `setItems(newItems)` and then calls `setItems(oldItems)`:
125
+ // in that case, `this.newItemsWillBeRendered` flag will be `true` but the actual `items`
126
+ // in state wouldn't have changed due to the first `updateState()` call being overwritten
127
+ // by the second `updateState()` call (that's called "batching state updates" in React).
128
+ if (newItems !== previousItems) {
129
+ const itemsDiff = this.getItemsDiff(previousItems, newItems)
130
+ if (itemsDiff) {
131
+ // The call to `.onPrepend()` must precede the call to `.measureItemHeights()`
132
+ // which is called in `.onRender()`.
133
+ // `this.itemHeights.onPrepend()` updates `firstMeasuredItemIndex`
134
+ // and `lastMeasuredItemIndex` of `this.itemHeights`.
135
+ const { prependedItemsCount } = itemsDiff
136
+ this.itemHeights.onPrepend(prependedItemsCount)
137
+ } else {
138
+ this.itemHeights.reset()
139
+ }
98
140
 
99
- if (!widthHasChanged) {
100
- // The call to `this.onNewItemsRendered()` must precede the call to
101
- // `.measureItemHeights()` which is called in `.onRender()` because
102
- // `this.onNewItemsRendered()` updates `firstMeasuredItemIndex` and
103
- // `lastMeasuredItemIndex` of `this.itemHeights` in case of a prepend.
104
- //
105
- // If after prepending items the scroll position
106
- // should be "restored" so that there's no "jump" of content
107
- // then it means that all previous items have just been rendered
108
- // in a single pass, and there's no need to update layout again.
109
- //
110
- if (onNewItemsRendered.call(this, itemsDiff, newState) !== 'SEAMLESS_PREPEND') {
111
- layoutUpdateReason = LAYOUT_REASON.ITEMS_CHANGED
141
+ if (!widthHasChanged) {
142
+ // The call to `this.onNewItemsRendered()` must precede the call to
143
+ // `.measureItemHeights()` which is called in `.onRender()` because
144
+ // `this.onNewItemsRendered()` updates `firstMeasuredItemIndex` and
145
+ // `lastMeasuredItemIndex` of `this.itemHeights` in case of a prepend.
146
+ //
147
+ // If after prepending items the scroll position
148
+ // should be "restored" so that there's no "jump" of content
149
+ // then it means that all previous items have just been rendered
150
+ // in a single pass, and there's no need to update layout again.
151
+ //
152
+ if (onNewItemsRendered.call(this, itemsDiff, newState) !== 'SEAMLESS_PREPEND') {
153
+ layoutUpdateReason = LAYOUT_REASON.ITEMS_CHANGED
154
+ }
112
155
  }
113
156
  }
114
157
  }
@@ -124,9 +167,11 @@ export default function() {
124
167
  // item height measurements is required.
125
168
  //
126
169
  if (
127
- newState.firstShownItemIndex !== prevState.firstShownItemIndex ||
128
- newState.lastShownItemIndex !== prevState.lastShownItemIndex ||
129
- newState.items !== prevState.items ||
170
+ (prevState && (
171
+ newState.firstShownItemIndex !== prevState.firstShownItemIndex ||
172
+ newState.lastShownItemIndex !== prevState.lastShownItemIndex ||
173
+ newState.items !== prevState.items
174
+ )) ||
130
175
  widthHasChanged
131
176
  ) {
132
177
  const verticalSpacingStateUpdate = this.measureItemHeightsAndSpacing()
@@ -202,14 +247,14 @@ export default function() {
202
247
  // See if any items' heights changed while new items were being rendered.
203
248
  if (this.itemHeightsThatChangedWhileNewItemsWereBeingRendered) {
204
249
  for (const i of Object.keys(this.itemHeightsThatChangedWhileNewItemsWereBeingRendered)) {
205
- itemHeights[prependedItemsCount + parseInt(i)] = this.itemHeightsThatChangedWhileNewItemsWereBeingRendered[i]
250
+ itemHeights[prependedItemsCount + Number(i)] = this.itemHeightsThatChangedWhileNewItemsWereBeingRendered[i]
206
251
  }
207
252
  }
208
253
 
209
254
  // See if any items' states changed while new items were being rendered.
210
255
  if (this.itemStatesThatChangedWhileNewItemsWereBeingRendered) {
211
256
  for (const i of Object.keys(this.itemStatesThatChangedWhileNewItemsWereBeingRendered)) {
212
- itemStates[prependedItemsCount + parseInt(i)] = this.itemStatesThatChangedWhileNewItemsWereBeingRendered[i]
257
+ itemStates[prependedItemsCount + Number(i)] = this.itemStatesThatChangedWhileNewItemsWereBeingRendered[i]
213
258
  }
214
259
  }
215
260
 
@@ -325,6 +370,9 @@ export default function() {
325
370
 
326
371
  // Read `this.firstNonMeasuredItemIndex` flag.
327
372
  const nonMeasuredItemsHaveBeenRendered = this.firstNonMeasuredItemIndex !== undefined
373
+ if (nonMeasuredItemsHaveBeenRendered) {
374
+ log('Non-measured item index', this.firstNonMeasuredItemIndex)
375
+ }
328
376
  // Reset `this.firstNonMeasuredItemIndex` flag.
329
377
  this.firstNonMeasuredItemIndex = undefined
330
378
 
@@ -337,8 +385,13 @@ export default function() {
337
385
  // Reset `this.itemStatesThatChangedWhileNewItemsWereBeingRendered`.
338
386
  this.itemStatesThatChangedWhileNewItemsWereBeingRendered = undefined
339
387
 
388
+ // Reset `this.updateLayoutAfterRenderBecauseItemHeightChanged`.
389
+ const itemHeightHasChanged = this.updateLayoutAfterRenderBecauseItemHeightChanged
390
+ this.updateLayoutAfterRenderBecauseItemHeightChanged = undefined
391
+
340
392
  return {
341
393
  nonMeasuredItemsHaveBeenRendered,
394
+ itemHeightHasChanged,
342
395
  widthHasChanged
343
396
  }
344
397
  }