virtual-scroller 1.11.3 → 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 (96) hide show
  1. package/README.md +6 -4
  2. package/bundle/virtual-scroller-dom.js +1 -1
  3. package/bundle/virtual-scroller-dom.js.map +1 -1
  4. package/bundle/virtual-scroller-react.js +1 -1
  5. package/bundle/virtual-scroller-react.js.map +1 -1
  6. package/bundle/virtual-scroller.js +1 -1
  7. package/bundle/virtual-scroller.js.map +1 -1
  8. package/commonjs/DOM/ItemsContainer.js +10 -3
  9. package/commonjs/DOM/ItemsContainer.js.map +1 -1
  10. package/commonjs/ItemNotRenderedError.js +64 -0
  11. package/commonjs/ItemNotRenderedError.js.map +1 -0
  12. package/commonjs/Layout.test.js +10 -0
  13. package/commonjs/Layout.test.js.map +1 -1
  14. package/commonjs/VirtualScroller.js +2 -1
  15. package/commonjs/VirtualScroller.js.map +1 -1
  16. package/commonjs/VirtualScroller.layout.js +61 -19
  17. package/commonjs/VirtualScroller.layout.js.map +1 -1
  18. package/commonjs/VirtualScroller.onRender.js +97 -45
  19. package/commonjs/VirtualScroller.onRender.js.map +1 -1
  20. package/commonjs/VirtualScroller.state.js +50 -18
  21. package/commonjs/VirtualScroller.state.js.map +1 -1
  22. package/commonjs/react/VirtualScroller.js +26 -42
  23. package/commonjs/react/VirtualScroller.js.map +1 -1
  24. package/commonjs/react/useItemKeys.js +11 -3
  25. package/commonjs/react/useItemKeys.js.map +1 -1
  26. package/commonjs/react/useOnChange.js +19 -0
  27. package/commonjs/react/useOnChange.js.map +1 -0
  28. package/commonjs/react/{useHandleItemsPropertyChange.js → useSetNewItemsOnItemsPropertyChange.js} +15 -14
  29. package/commonjs/react/useSetNewItemsOnItemsPropertyChange.js.map +1 -0
  30. package/commonjs/react/useState.js +162 -69
  31. package/commonjs/react/useState.js.map +1 -1
  32. package/commonjs/react/useStyle.js +3 -5
  33. package/commonjs/react/useStyle.js.map +1 -1
  34. package/commonjs/react/useUpdateItemKeysOnItemsChange.js +61 -0
  35. package/commonjs/react/useUpdateItemKeysOnItemsChange.js.map +1 -0
  36. package/commonjs/test/ItemsContainer.js +22 -1
  37. package/commonjs/test/ItemsContainer.js.map +1 -1
  38. package/commonjs/utility/debug.js +30 -6
  39. package/commonjs/utility/debug.js.map +1 -1
  40. package/index.cjs +2 -0
  41. package/index.d.ts +6 -0
  42. package/index.js +1 -0
  43. package/modules/DOM/ItemsContainer.js +8 -3
  44. package/modules/DOM/ItemsContainer.js.map +1 -1
  45. package/modules/ItemNotRenderedError.js +57 -0
  46. package/modules/ItemNotRenderedError.js.map +1 -0
  47. package/modules/Layout.test.js +10 -0
  48. package/modules/Layout.test.js.map +1 -1
  49. package/modules/VirtualScroller.js +2 -1
  50. package/modules/VirtualScroller.js.map +1 -1
  51. package/modules/VirtualScroller.layout.js +58 -19
  52. package/modules/VirtualScroller.layout.js.map +1 -1
  53. package/modules/VirtualScroller.onRender.js +98 -46
  54. package/modules/VirtualScroller.onRender.js.map +1 -1
  55. package/modules/VirtualScroller.state.js +50 -18
  56. package/modules/VirtualScroller.state.js.map +1 -1
  57. package/modules/react/VirtualScroller.js +26 -42
  58. package/modules/react/VirtualScroller.js.map +1 -1
  59. package/modules/react/useItemKeys.js +8 -3
  60. package/modules/react/useItemKeys.js.map +1 -1
  61. package/modules/react/useOnChange.js +11 -0
  62. package/modules/react/useOnChange.js.map +1 -0
  63. package/modules/react/{useHandleItemsPropertyChange.js → useSetNewItemsOnItemsPropertyChange.js} +11 -13
  64. package/modules/react/useSetNewItemsOnItemsPropertyChange.js.map +1 -0
  65. package/modules/react/useState.js +156 -73
  66. package/modules/react/useState.js.map +1 -1
  67. package/modules/react/useStyle.js +3 -5
  68. package/modules/react/useStyle.js.map +1 -1
  69. package/{commonjs/react/useHandleItemIndexesChange.js → modules/react/useUpdateItemKeysOnItemsChange.js} +18 -21
  70. package/modules/react/useUpdateItemKeysOnItemsChange.js.map +1 -0
  71. package/modules/test/ItemsContainer.js +20 -1
  72. package/modules/test/ItemsContainer.js.map +1 -1
  73. package/modules/utility/debug.js +31 -6
  74. package/modules/utility/debug.js.map +1 -1
  75. package/package.json +1 -1
  76. package/source/DOM/ItemsContainer.js +8 -3
  77. package/source/ItemNotRenderedError.js +16 -0
  78. package/source/Layout.test.js +9 -0
  79. package/source/VirtualScroller.js +2 -0
  80. package/source/VirtualScroller.layout.js +57 -18
  81. package/source/VirtualScroller.onRender.js +95 -42
  82. package/source/VirtualScroller.state.js +57 -20
  83. package/source/react/VirtualScroller.js +23 -35
  84. package/source/react/useItemKeys.js +9 -2
  85. package/source/react/useOnChange.js +11 -0
  86. package/source/react/{useHandleItemsPropertyChange.js → useSetNewItemsOnItemsPropertyChange.js} +11 -11
  87. package/source/react/useState.js +159 -71
  88. package/source/react/useStyle.js +2 -2
  89. package/source/react/{useHandleItemIndexesChange.js → useUpdateItemKeysOnItemsChange.js} +17 -9
  90. package/source/test/ItemsContainer.js +22 -1
  91. package/source/utility/debug.js +18 -4
  92. package/commonjs/react/useHandleItemIndexesChange.js.map +0 -1
  93. package/commonjs/react/useHandleItemsPropertyChange.js.map +0 -1
  94. package/modules/react/useHandleItemIndexesChange.js +0 -45
  95. package/modules/react/useHandleItemIndexesChange.js.map +0 -1
  96. package/modules/react/useHandleItemsPropertyChange.js.map +0 -1
@@ -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
  })
@@ -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) {
@@ -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".
@@ -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
  //
@@ -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
  }
@@ -371,7 +377,7 @@ export default function() {
371
377
  }
372
378
 
373
379
  this._onItemHeightDidChange = (i) => {
374
- log('~ Re-measure item height ~')
380
+ log('~ On Item Height Did Change was called ~')
375
381
  log('Item index', i)
376
382
 
377
383
  const {
@@ -412,24 +418,57 @@ export default function() {
412
418
 
413
419
  const previousHeight = itemHeights[i]
414
420
  if (previousHeight === undefined) {
415
- return reportError(`"onItemHeightDidChange()" 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
  }
@@ -52,7 +52,13 @@ export default function createStateHelpers({
52
52
 
53
53
  this.getState().itemStates[i] = newItemState
54
54
 
55
- // Schedule the item state update for after the new items have been rendered.
55
+ // If there was a request for `setState()` with new `items`, then the changes
56
+ // to `currentState.itemStates[]` made above would be overwritten when that
57
+ // pending `setState()` call gets applied.
58
+ // To fix that, the updates to current `itemStates[]` are noted in
59
+ // `this.itemStatesThatChangedWhileNewItemsWereBeingRendered` variable.
60
+ // That variable is then checked when the `setState()` call with the new `items`
61
+ // has been updated.
56
62
  if (this.newItemsWillBeRendered) {
57
63
  if (!this.itemStatesThatChangedWhileNewItemsWereBeingRendered) {
58
64
  this.itemStatesThatChangedWhileNewItemsWereBeingRendered = {}
@@ -78,9 +84,25 @@ export default function createStateHelpers({
78
84
  }
79
85
  this._isSettingNewItems = undefined
80
86
 
81
- // Update `state`.
87
+ this.waitingForRender = true
88
+
89
+ // Store previous `state`.
82
90
  this.previousState = this.getState()
83
- this._updateState(stateUpdate)
91
+
92
+ // If it's the first call to `this.updateState()` then initialize
93
+ // the most recent `setState()` value to be the current state.
94
+ if (!this.mostRecentSetStateValue) {
95
+ this.mostRecentSetStateValue = this.getState()
96
+ }
97
+
98
+ // Accumulates all "pending" state updates until they have been applied.
99
+ this.mostRecentSetStateValue = {
100
+ ...this.mostRecentSetStateValue,
101
+ ...stateUpdate
102
+ }
103
+
104
+ // Update `state`.
105
+ this._setState(this.mostRecentSetStateValue, stateUpdate)
84
106
  }
85
107
 
86
108
  this.getInitialState = () => {
@@ -92,6 +114,7 @@ export default function createStateHelpers({
92
114
 
93
115
  this.useState = ({
94
116
  getState,
117
+ setState,
95
118
  updateState
96
119
  }) => {
97
120
  if (this._isActive) {
@@ -103,17 +126,28 @@ export default function createStateHelpers({
103
126
  }
104
127
 
105
128
  if (render) {
106
- throw new Error('[virtual-scroller] Creating a `VirtualScroller` class instance with a `render()` parameter means using the default (internal) state storage')
129
+ throw new Error('[virtual-scroller] Creating a `VirtualScroller` class instance with a `render()` parameter implies using the default (internal) state storage')
107
130
  }
108
131
 
109
- if (!getState || !updateState) {
110
- throw new Error('[virtual-scroller] When using a custom state storage, one must supply both `getState()` and `updateState()` functions')
132
+ if (setState && updateState) {
133
+ throw new Error('[virtual-scroller] When using a custom state storage, one must supply either `setState()` or `updateState()` function but not both')
134
+ }
135
+
136
+ if (!getState || !(setState || updateState)) {
137
+ throw new Error('[virtual-scroller] When using a custom state storage, one must supply both `getState()` and `setState()`/`updateState()` functions')
111
138
  }
112
139
 
113
140
  this._usesCustomStateStorage = true
114
141
 
115
142
  this._getState = getState
116
- this._updateState = updateState
143
+
144
+ this._setState = (newState, stateUpdate) => {
145
+ if (setState) {
146
+ setState(newState)
147
+ } else {
148
+ updateState(stateUpdate)
149
+ }
150
+ }
117
151
  }
118
152
 
119
153
  this.useDefaultStateStorage = () => {
@@ -121,9 +155,9 @@ export default function createStateHelpers({
121
155
  throw new Error('[virtual-scroller] When using the default (internal) state management, one must supply a `render(state, prevState)` function parameter')
122
156
  }
123
157
 
124
- // Create default `getState()`/`updateState()` functions.
158
+ // Create default `getState()`/`setState()` functions.
125
159
  this._getState = defaultGetState.bind(this)
126
- this._updateState = defaultUpdateState.bind(this)
160
+ this._setState = defaultSetState.bind(this)
127
161
 
128
162
  // When `state` is stored externally, a developer is responsible for
129
163
  // initializing it with the initial value.
@@ -140,17 +174,20 @@ export default function createStateHelpers({
140
174
  this.state = newState
141
175
  }
142
176
 
143
- function defaultUpdateState(stateUpdate) {
144
- // Because this variant of `.updateState()` is "synchronous" (immediate),
145
- // it can be written like `...prevState`, and no state updates would be lost.
146
- // But if it was "asynchronous" (not immediate), then `...prevState`
147
- // wouldn't work in all cases, because it could be stale in cases
148
- // when more than a single `updateState()` call is made before
149
- // the state actually updates, making `prevState` stale.
150
- this.state = {
151
- ...this.state,
152
- ...stateUpdate
153
- }
177
+ function defaultSetState(newState, stateUpdate) {
178
+ // // Because the default state updates are "synchronous" (immediate),
179
+ // // the `...stateUpdate` could be applied over `...this.state`,
180
+ // // and no state updates would be lost.
181
+ // // But if it was "asynchronous" (not immediate), then `...this.state`
182
+ // // wouldn't work in all cases, because it could be stale in cases
183
+ // // when more than a single `setState()` call is made before
184
+ // // the state actually updates, making some properties of `this.state` stale.
185
+ // this.state = {
186
+ // ...this.state,
187
+ // ...stateUpdate
188
+ // }
189
+
190
+ this.state = newState
154
191
 
155
192
  render(this.state, this.previousState)
156
193
 
@@ -8,32 +8,21 @@ import useInstanceMethods from './useInstanceMethods.js'
8
8
  import useItemKeys from './useItemKeys.js'
9
9
  import useSetItemState from './useSetItemState.js'
10
10
  import useOnItemHeightDidChange from './useOnItemHeightDidChange.js'
11
- import useHandleItemsPropertyChange from './useHandleItemsPropertyChange.js'
12
- import useHandleItemIndexesChange from './useHandleItemIndexesChange.js'
11
+ import useSetNewItemsOnItemsPropertyChange from './useSetNewItemsOnItemsPropertyChange.js'
12
+ import useUpdateItemKeysOnItemsChange from './useUpdateItemKeysOnItemsChange.js'
13
13
  import useClassName from './useClassName.js'
14
14
  import useStyle from './useStyle.js'
15
15
 
16
- // When `items` property changes, `useHandleItemsPropertyChange()` hook detects that
17
- // and calls `VirtualScroller.setItems()` which in turn calls the `updateState()` function.
18
- // At this point, an insignificant optimization could be applied:
19
- // the component could avoid re-rendering the second time.
20
- // Instead, the state update could be applied "immediately" if it originated
21
- // from `.setItems()` function call, eliminating the unneeded second re-render.
22
- //
23
- // I could see how this minor optimization could get brittle when modifiying the code,
24
- // so I put it under a feature flag so that it could potentially be turned off
25
- // in case of any potential weird issues in some future.
26
- //
27
- // Another reason for using this feature is:
28
- //
29
- // Since `useHandleItemsPropertyChange()` runs at render time
30
- // and not after the render has finished (not in an "effect"),
31
- // if the state update was done "conventionally" (by calling `_setNewState()`),
32
- // React would throw an error about updating state during render.
33
- // No one knows what the original error message was.
34
- // Perhaps it's no longer relevant in newer versions of React.
35
- //
36
- const USE_ITEMS_UPDATE_NO_SECOND_RENDER_OPTIMIZATION = true
16
+ // When `items` property changes:
17
+ // * A new `items` property is supplied to the React component.
18
+ // * The React component re-renders itself.
19
+ // * `useSetNewItemsOnItemsPropertyChange()` hook is run.
20
+ // * `useSetNewItemsOnItemsPropertyChange()` hook detects that the `items` property
21
+ // has changed and calls `VirtualScroller.setItems(items)`.
22
+ // * `VirtualScroller.setItems(items)` calls `VirtualScroller.setState()`.
23
+ // * `VirtualScroller.setState()` calls the `setState()` function.
24
+ // * The `setState()` function calls a setter from a `useState()` hook.
25
+ // * The React component re-renders itself the second time.
37
26
 
38
27
  function VirtualScroller({
39
28
  as: AsComponent,
@@ -113,20 +102,19 @@ function VirtualScroller({
113
102
  // This way, React will re-render the component on every state update.
114
103
  const {
115
104
  getState,
116
- updateState,
117
- getNextState
105
+ setState,
106
+ stateToRender
118
107
  } = useState({
119
108
  initialState: _initialState,
120
109
  onRender: virtualScroller.onRender,
121
- itemsProperty,
122
- USE_ITEMS_UPDATE_NO_SECOND_RENDER_OPTIMIZATION
110
+ itemsProperty
123
111
  })
124
112
 
125
113
  // Use custom (external) state storage in the `VirtualScroller`.
126
114
  useMemo(() => {
127
115
  virtualScroller.useState({
128
116
  getState,
129
- updateState
117
+ setState
130
118
  })
131
119
  }, [])
132
120
 
@@ -138,6 +126,7 @@ function VirtualScroller({
138
126
  // "reuse" `itemComponent`s in cases when `items` are changed.
139
127
  const {
140
128
  getItemKey,
129
+ usesAutogeneratedItemKeys,
141
130
  updateItemKeysForNewItems
142
131
  } = useItemKeys({
143
132
  getItemId
@@ -158,19 +147,18 @@ function VirtualScroller({
158
147
  })
159
148
 
160
149
  // Calls `.setItems()` if `items` property has changed.
161
- useHandleItemsPropertyChange(itemsProperty, {
150
+ useSetNewItemsOnItemsPropertyChange(itemsProperty, {
162
151
  virtualScroller,
163
152
  // `preserveScrollPosition` property name is deprecated,
164
153
  // use `preserveScrollPositionOnPrependItems` property instead.
165
154
  preserveScrollPosition,
166
- preserveScrollPositionOnPrependItems,
167
- nextItems: getNextState().items
155
+ preserveScrollPositionOnPrependItems
168
156
  })
169
157
 
170
158
  // Updates `key`s if item indexes have changed.
171
- useHandleItemIndexesChange({
159
+ useUpdateItemKeysOnItemsChange(stateToRender.items, {
172
160
  virtualScroller,
173
- itemsBeingRendered: getNextState().items,
161
+ usesAutogeneratedItemKeys,
174
162
  updateItemKeysForNewItems
175
163
  })
176
164
 
@@ -209,7 +197,7 @@ function VirtualScroller({
209
197
 
210
198
  const style = useStyle({
211
199
  tbody,
212
- getNextState
200
+ state: stateToRender
213
201
  })
214
202
 
215
203
  const {
@@ -217,7 +205,7 @@ function VirtualScroller({
217
205
  itemStates,
218
206
  firstShownItemIndex,
219
207
  lastShownItemIndex
220
- } = getNextState()
208
+ } = stateToRender
221
209
 
222
210
  return (
223
211
  <AsComponent