virtual-scroller 1.12.2 → 1.12.3

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 (65) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/CODE_OF_CONDUCT.md +78 -0
  3. package/README.md +1 -1
  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/Layout.js +1 -1
  11. package/commonjs/Layout.js.map +1 -1
  12. package/commonjs/Scroll.js +2 -1
  13. package/commonjs/Scroll.js.map +1 -1
  14. package/commonjs/VirtualScroller.js +62 -23
  15. package/commonjs/VirtualScroller.js.map +1 -1
  16. package/commonjs/VirtualScroller.layout.js +16 -10
  17. package/commonjs/VirtualScroller.layout.js.map +1 -1
  18. package/commonjs/VirtualScroller.onRender.js +2 -2
  19. package/commonjs/VirtualScroller.onRender.js.map +1 -1
  20. package/commonjs/react/VirtualScroller.js +3 -0
  21. package/commonjs/react/VirtualScroller.js.map +1 -1
  22. package/commonjs/react/useEffectDontMountTwiceInStrictMode.js +83 -0
  23. package/commonjs/react/useEffectDontMountTwiceInStrictMode.js.map +1 -0
  24. package/commonjs/react/useInsertionEffectDontMountTwiceInStrictMode.js +20 -0
  25. package/commonjs/react/useInsertionEffectDontMountTwiceInStrictMode.js.map +1 -0
  26. package/commonjs/react/useLayoutEffectDontMountTwiceInStrictMode.js +20 -0
  27. package/commonjs/react/useLayoutEffectDontMountTwiceInStrictMode.js.map +1 -0
  28. package/commonjs/react/useState.js +13 -7
  29. package/commonjs/react/useState.js.map +1 -1
  30. package/commonjs/react/useStateNoStaleBug.js +59 -0
  31. package/commonjs/react/useStateNoStaleBug.js.map +1 -0
  32. package/modules/Layout.js +1 -1
  33. package/modules/Layout.js.map +1 -1
  34. package/modules/Scroll.js +2 -1
  35. package/modules/Scroll.js.map +1 -1
  36. package/modules/VirtualScroller.js +62 -23
  37. package/modules/VirtualScroller.js.map +1 -1
  38. package/modules/VirtualScroller.layout.js +16 -10
  39. package/modules/VirtualScroller.layout.js.map +1 -1
  40. package/modules/VirtualScroller.onRender.js +2 -2
  41. package/modules/VirtualScroller.onRender.js.map +1 -1
  42. package/modules/react/VirtualScroller.js +3 -1
  43. package/modules/react/VirtualScroller.js.map +1 -1
  44. package/modules/react/useEffectDontMountTwiceInStrictMode.js +75 -0
  45. package/modules/react/useEffectDontMountTwiceInStrictMode.js.map +1 -0
  46. package/modules/react/useInsertionEffectDontMountTwiceInStrictMode.js +9 -0
  47. package/modules/react/useInsertionEffectDontMountTwiceInStrictMode.js.map +1 -0
  48. package/modules/react/useLayoutEffectDontMountTwiceInStrictMode.js +9 -0
  49. package/modules/react/useLayoutEffectDontMountTwiceInStrictMode.js.map +1 -0
  50. package/modules/react/useState.js +11 -8
  51. package/modules/react/useState.js.map +1 -1
  52. package/modules/react/useStateNoStaleBug.js +51 -0
  53. package/modules/react/useStateNoStaleBug.js.map +1 -0
  54. package/package.json +1 -1
  55. package/source/Layout.js +1 -1
  56. package/source/Scroll.js +1 -0
  57. package/source/VirtualScroller.js +61 -21
  58. package/source/VirtualScroller.layout.js +14 -8
  59. package/source/VirtualScroller.onRender.js +2 -2
  60. package/source/react/VirtualScroller.js +3 -0
  61. package/source/react/useEffectDontMountTwiceInStrictMode.js +68 -0
  62. package/source/react/useInsertionEffectDontMountTwiceInStrictMode.js +10 -0
  63. package/source/react/useLayoutEffectDontMountTwiceInStrictMode.js +10 -0
  64. package/source/react/useState.js +8 -5
  65. package/source/react/useStateNoStaleBug.js +35 -0
@@ -49,7 +49,11 @@ export default class VirtualScroller {
49
49
  }
50
50
  }
51
51
 
52
- log('~ Start ~')
52
+ if (isRestart) {
53
+ log('~ Start (restart) ~')
54
+ } else {
55
+ log('~ Start ~')
56
+ }
53
57
 
54
58
  // `this._isActive = true` should be placed somewhere at the start of this function.
55
59
  this._isActive = true
@@ -71,18 +75,13 @@ export default class VirtualScroller {
71
75
  }
72
76
  }
73
77
 
74
- // If there was a pending state update that didn't get applied
75
- // because of stopping the `VirtualScroller`, apply that state update now.
76
- //
77
- // The pending state update won't get applied if the scrollable container width
78
- // has changed but that's ok because that state update currently could only contain:
79
- // * `scrollableContainerWidth`
80
- // * `verticalSpacing`
81
- // * `beforeResize`
82
- // All of those get rewritten in `onResize()` anyway.
83
- //
84
- let stateUpdate = this._stoppedStateUpdate
85
- this._stoppedStateUpdate = undefined
78
+ // If there was a pending "after render" state update that didn't get applied
79
+ // because the `VirtualScroller` got stopped, then apply that pending "after render"
80
+ // state update now. Such state update could include properties like:
81
+ // * A `verticalSpacing` that has been measured in `onRender()`.
82
+ // * A cleaned-up `beforeResize` object that was cleaned-up in `onRender()`.
83
+ let stateUpdate = this._afterRenderStateUpdateThatWasStopped
84
+ this._afterRenderStateUpdateThatWasStopped = undefined
86
85
 
87
86
  // Reset `this.verticalSpacing` so that it re-measures it in cases when
88
87
  // the `VirtualScroller` was previously stopped and is now being restarted.
@@ -122,12 +121,10 @@ export default class VirtualScroller {
122
121
  const prevWidth = this.getState().scrollableContainerWidth
123
122
  if (newWidth !== prevWidth) {
124
123
  log('~ Scrollable container width changed from', prevWidth, 'to', newWidth, '~')
125
- // `stateUpdate` doesn't get passed to `this.onResize()`, and, therefore,
126
- // won't be applied. But that's ok because currently it could only contain:
127
- // * `scrollableContainerWidth`
128
- // * `verticalSpacing`
129
- // * `beforeResize`
130
- // All of those get rewritten in `onResize()` anyway.
124
+ // The pending state update (if present) won't be applied in this case.
125
+ // That's ok because such state update could currently only originate in
126
+ // `this.onResize()` function. Therefore, alling `this.onResize()` again
127
+ // would rewrite all those `stateUpdate` properties anyway, so they're not passed.
131
128
  return this.onResize()
132
129
  }
133
130
  }
@@ -221,7 +218,9 @@ export default class VirtualScroller {
221
218
  * @param {number} i — Item index
222
219
  */
223
220
  onItemHeightDidChange(i) {
224
- this.hasToBeStarted()
221
+ // See the comments in the `setItemState()` function below for the rationale
222
+ // on why the `hasToBeStarted()` check was commented out.
223
+ // this.hasToBeStarted()
225
224
  this._onItemHeightDidChange(i)
226
225
  }
227
226
 
@@ -231,7 +230,48 @@ export default class VirtualScroller {
231
230
  * @param {any} i — Item's new state
232
231
  */
233
232
  setItemState(i, newItemState) {
234
- this.hasToBeStarted()
233
+ // There is an issue in React 18.2.0 when `useInsertionEffect()` doesn't run twice
234
+ // on mount unlike `useLayoutEffect()` in "strict" mode. That causes a bug in a React
235
+ // implementation of the `virtual-scroller`.
236
+ // https://gitlab.com/catamphetamine/virtual-scroller/-/issues/33
237
+ // https://github.com/facebook/react/issues/26320
238
+ // A workaround for that bug is ignoring the second-initial run of the effects at mount.
239
+ //
240
+ // But in that case, if an `ItemComponent` calls `setItemState()` in `useLayoutEffect()`,
241
+ // it could result in a bug.
242
+ //
243
+ // Consider a type of `useLayoutEffect()` that skips the initial mount:
244
+ // `useLayoutEffectSkipInitialMount()`.
245
+ // Suppose that effect is written in such a way that it only skips the first call of itself.
246
+ // In that case, if React is run in "strict" mode, the effect will no longer work as expected
247
+ // and it won't actually skip the initial mount and will be executed during the second initial run.
248
+ // But the `VirtualScroller` itself has already implemented a workaround that prevents
249
+ // its hooks from running twice on mount. This means that `useVirtualScrollerStartStop()`
250
+ // of the React component would have already stopped the `VirtualScroller` by the time
251
+ // `ItemComponent`'s incorrectly-behaving `useLayoutEffectSkipInitialMount()` effect is run,
252
+ // resulting in an error: "`VirtualScroller` hasn't been started".
253
+ //
254
+ // The log when not in "strict" mode would be:
255
+ //
256
+ // * `useLayoutEffect()` is run in `ItemComponent` — skips the initial run.
257
+ // * `useLayoutEffect()` is run in `useVirtualScrollerStartStop()`. It starts the `VirtualScroller`.
258
+ // * Some dependency property gets updated inside `ItemComponent`.
259
+ // * `useLayoutEffect()` is run in `ItemComponent` — no longer skips. Calls `setItemState()`.
260
+ // * The `VirtualScroller` is started so it handles `setState()` correctly.
261
+ //
262
+ // The log when in "strict" mode would be:
263
+ //
264
+ // * `useLayoutEffect()` is run in `ItemComponent` — skips the initial run.
265
+ // * `useLayoutEffect()` is run in `useVirtualScrollerStartStop()`. It starts the `VirtualScroller`.
266
+ // * `useLayoutEffect()` is unmounted in `useVirtualScrollerStartStop()`. It stops the `VirtualScroller`.
267
+ // * `useLayoutEffect()` is unmounted in `ItemComponent` — does nothing.
268
+ // * `useLayoutEffect()` is run the second time in `ItemComponent` — no longer skips. Calls `setItemState()`.
269
+ // * The `VirtualScroller` is stopped so it throws an error: "`VirtualScroller` hasn't been started".
270
+ //
271
+ // For that reason, the requirement of the `VirtualScroller` to be started was commented out.
272
+ // Commenting it out wouldn't result in any potential bugs because the code would work correctly
273
+ // in both cases.
274
+ // this.hasToBeStarted()
235
275
  this._setItemState(i, newItemState)
236
276
  }
237
277
 
@@ -441,9 +441,13 @@ export default function() {
441
441
  if (previousHeight !== newHeight) {
442
442
  log('~ Item height has changed. Should update layout. ~')
443
443
 
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.
444
+ // Update or reset a previously calculated layout with the new item height
445
+ // so that the potential future "diff"s based on that "previously calculated" layout
446
+ // would be correct.
447
+ //
448
+ // The "previously calculated layout" feature is not currently used
449
+ // so this function call doesn't really affect anything.
450
+ //
447
451
  updatePreviouslyCalculatedLayoutOnItemHeightChange.call(this, i, previousHeight, newHeight)
448
452
 
449
453
  // Recalculate layout.
@@ -455,11 +459,13 @@ export default function() {
455
459
  // that might happen in the middle of the currently pending `setState()` operation
456
460
  // being applied, resulting in weird "race condition" bugs.
457
461
  //
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 })
462
+ if (this._isActive) {
463
+ if (this.waitingForRender) {
464
+ log('~ Another state update is already waiting to be rendered. Delay the layout update until then. ~')
465
+ this.updateLayoutAfterRenderBecauseItemHeightChanged = true
466
+ } else {
467
+ this.onUpdateShownItemIndexes({ reason: LAYOUT_REASON.ITEM_HEIGHT_CHANGED })
468
+ }
463
469
  }
464
470
 
465
471
  // If there was a request for `setState()` with new `items`, then the changes
@@ -63,7 +63,7 @@ export default function() {
63
63
  // would construct its own state object.
64
64
  if (!shallowEqual(newState, this.mostRecentSetStateValue)) {
65
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')
66
+ reportError('`VirtualScroller` has been rendered with a `state` that is not equal to the most recently set one')
67
67
  }
68
68
  }
69
69
 
@@ -199,7 +199,7 @@ export default function() {
199
199
  }
200
200
 
201
201
  if (!this._isActive) {
202
- this._stoppedStateUpdate = stateUpdate
202
+ this._afterRenderStateUpdateThatWasStopped = stateUpdate
203
203
  return
204
204
  }
205
205
 
@@ -13,6 +13,8 @@ import useUpdateItemKeysOnItemsChange from './useUpdateItemKeysOnItemsChange.js'
13
13
  import useClassName from './useClassName.js'
14
14
  import useStyle from './useStyle.js'
15
15
 
16
+ import { warn } from '../utility/debug.js'
17
+
16
18
  // When `items` property changes:
17
19
  // * A new `items` property is supplied to the React component.
18
20
  // * The React component re-renders itself.
@@ -172,6 +174,7 @@ function VirtualScroller({
172
174
  // `onMount()` option is deprecated due to no longer being used.
173
175
  // If someone thinks there's a valid use case for it, create an issue.
174
176
  if (onMount) {
177
+ warn('`onMount` property is deprecated')
175
178
  onMount()
176
179
  }
177
180
  }, [])
@@ -0,0 +1,68 @@
1
+ import { useRef, useCallback } from 'react'
2
+
3
+ // A workaround for a React bug when `useInsertionEffect()` doesn't run twice on mount
4
+ // in "strict" mode unlike `useEffect()` and `useLayoutEffect()` do.
5
+ // https://github.com/facebook/react/issues/26320
6
+ export default function useEffectDontMountTwiceInStrictMode(useEffect, handler, dependencies) {
7
+ if (!Array.isArray(dependencies)) {
8
+ throw new Error('Dependencies argument must be an array')
9
+ }
10
+
11
+ const { onEffect } = useEffectStatus()
12
+ const { onChange } = usePrevousValue(dependencies)
13
+
14
+ useEffect(() => {
15
+ const { isInitialRun } = onEffect()
16
+ const previousDependencies = onChange(dependencies)
17
+ if (isInitialRun || !isShallowEqualArrays(previousDependencies, dependencies)) {
18
+ const cleanUpFunction = handler()
19
+ if (typeof cleanUpFunction === 'function') {
20
+ throw new Error('An effect can\'t return a clean-up function when used with `useEffectDontMountTwiceInStrictMode()` because the clean-up function won\'t behave correctly in that case')
21
+ }
22
+ }
23
+ }, dependencies)
24
+ }
25
+
26
+ function useEffectStatus() {
27
+ const hasMounted = useRef(false)
28
+
29
+ const onEffect = useCallback(() => {
30
+ const wasAlreadyMounted = hasMounted.current
31
+ hasMounted.current = true
32
+ return {
33
+ isInitialRun: !wasAlreadyMounted
34
+ }
35
+ }, [])
36
+
37
+ return {
38
+ onEffect
39
+ }
40
+ }
41
+
42
+ function usePrevousValue(value) {
43
+ const prevValue = useRef(value)
44
+
45
+ const onChange = useCallback((value) => {
46
+ const previousValue = prevValue.current
47
+ prevValue.current = value
48
+ return previousValue
49
+ }, [])
50
+
51
+ return {
52
+ onChange
53
+ }
54
+ }
55
+
56
+ function isShallowEqualArrays(a, b) {
57
+ if (a.length !== b.length) {
58
+ return false
59
+ }
60
+ let i = 0
61
+ while (i < a.length) {
62
+ if (a[i] !== b[i]) {
63
+ return false
64
+ }
65
+ i++
66
+ }
67
+ return true
68
+ }
@@ -0,0 +1,10 @@
1
+ import { useInsertionEffect } from 'react'
2
+
3
+ import useEffectDontMountTwiceInStrictMode from './useEffectDontMountTwiceInStrictMode.js'
4
+
5
+ // A workaround for a React bug when `useInsertionEffect()` doesn't run twice on mount
6
+ // in "strict" mode unlike `useEffect()` and `useLayoutEffect()` do.
7
+ // https://github.com/facebook/react/issues/26320
8
+ export default function useInsertionEffectDontMountTwiceInStrictMode(handler, dependencies) {
9
+ return useEffectDontMountTwiceInStrictMode(useInsertionEffect, handler, dependencies)
10
+ }
@@ -0,0 +1,10 @@
1
+ import { useLayoutEffect } from 'react'
2
+
3
+ import useEffectDontMountTwiceInStrictMode from './useEffectDontMountTwiceInStrictMode.js'
4
+
5
+ // A workaround for a React bug when `useInsertionEffect()` doesn't run twice on mount
6
+ // in "strict" mode unlike `useEffect()` and `useLayoutEffect()` do.
7
+ // https://github.com/facebook/react/issues/26320
8
+ export default function useLayoutEffectDontMountTwiceInStrictMode(handler, dependencies) {
9
+ return useEffectDontMountTwiceInStrictMode(useLayoutEffect, handler, dependencies)
10
+ }
@@ -1,7 +1,10 @@
1
1
  import log, { isDebug } from '../utility/debug.js'
2
2
  import getStateSnapshot from '../utility/getStateSnapshot.js'
3
3
 
4
- import { useState, useRef, useCallback, useLayoutEffect, useInsertionEffect } from 'react'
4
+ import { useRef, useCallback } from 'react'
5
+ import useStateNoStaleBug from './useStateNoStaleBug.js'
6
+ import useInsertionEffectDontMountTwiceInStrictMode from './useInsertionEffectDontMountTwiceInStrictMode.js'
7
+ import useLayoutEffectDontMountTwiceInStrictMode from './useLayoutEffectDontMountTwiceInStrictMode.js'
5
8
 
6
9
  // Creates state management functions.
7
10
  export default function _useState({
@@ -14,7 +17,7 @@ export default function _useState({
14
17
  // `VirtualScroller` state gets updated from this variable.
15
18
  // The reason for that is that `VirtualScroller` state must always
16
19
  // correspond exactly to what's currently rendered on the screen.
17
- const [_newState, _setNewState] = useState(initialState)
20
+ const [_newState, _setNewState] = useStateNoStaleBug(initialState)
18
21
 
19
22
  // This `state` reference is what `VirtualScroller` uses internally.
20
23
  // It's the "source of truth" on the actual `VirtualScroller` state.
@@ -137,7 +140,7 @@ export default function _useState({
137
140
  // So if `useInsertionEffect()` gets removed from React in some hypothetical future,
138
141
  // it could be replaced with using `ref`s on `ItemComponent`s to measure the DOM element heights.
139
142
  //
140
- useInsertionEffect(() => {
143
+ useInsertionEffectDontMountTwiceInStrictMode(() => {
141
144
  // Update the actual `VirtualScroller` state right before the DOM changes
142
145
  // are going to be applied for the requested state update.
143
146
  //
@@ -160,13 +163,13 @@ export default function _useState({
160
163
  // This hook doesn't do anything at the initial render.
161
164
  //
162
165
  if (isDebug()) {
163
- log('React: ~ The requested state is about to be applied in DOM. Set it as the `VirtualScroller` state. ~')
166
+ log('React: ~ The requested state is about to be applied in DOM. Setting it as the `VirtualScroller` state. ~')
164
167
  log(getStateSnapshot(_newState))
165
168
  }
166
169
  setState(_newState)
167
170
  }, [_newState])
168
171
 
169
- useLayoutEffect(() => {
172
+ useLayoutEffectDontMountTwiceInStrictMode(() => {
170
173
  // Call `onRender()` right after a requested state update has been applied,
171
174
  // and also right after the initial render.
172
175
  onRender()
@@ -0,0 +1,35 @@
1
+ import { useRef, useState, useCallback } from 'react'
2
+
3
+ // This hook fixes any weird intermediate inconsistent/invalid/stale state values.
4
+ // https://github.com/facebook/react/issues/25023#issuecomment-1480463544
5
+ export default function useStateNoStaleBug(initialState) {
6
+ // const latestValidState = useRef(initialState)
7
+ const latestWrittenState = useRef(initialState)
8
+ const [_state, _setState] = useState(initialState)
9
+
10
+ // Instead of dealing with a potentially out-of-sync (stale) state value,
11
+ // simply use the correct latest one.
12
+ const state = latestWrittenState.current
13
+
14
+ /*
15
+ let state
16
+ if (_state === latestWrittenState.current) {
17
+ state = _state
18
+ latestValidState.current = _state
19
+ } else {
20
+ // React bug detected: an out-of-sync (stale) state value received.
21
+ // Ignore the out-of-sync (stale) state value.
22
+ state = latestValidState.current
23
+ }
24
+ */
25
+
26
+ const setState = useCallback((newState) => {
27
+ if (typeof newState === 'function') {
28
+ throw new Error('Function argument of `setState()` function is not supported by this hook')
29
+ }
30
+ latestWrittenState.current = newState
31
+ _setState(newState)
32
+ }, [])
33
+
34
+ return [state, setState]
35
+ }