Skip to content

Commit

Permalink
.
Browse files Browse the repository at this point in the history
  • Loading branch information
catamphetamine committed Feb 15, 2023
1 parent efd5bbd commit e1f1449
Show file tree
Hide file tree
Showing 25 changed files with 677 additions and 205 deletions.
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -319,11 +319,13 @@ A developer might prefer to use custom (external) state management rather than t

* `getInitialState(): object` — Returns the initial `VirtualScroller` state for the cases when a developer configures `VirtualScroller` for custom (external) state management.

* `useState({ getState, updateState })` — Enables custom (external) state management.
* `useState({ getState, setState, updateState? })` — Enables custom (external) state management.

* `getState(): object` — Returns the externally managed `VirtualScroller` `state`.

* `updateState(stateUpdate: object)` — Updates the externally managed `VirtualScroller` `state`. Must call `.onRender()` right after the updated `state` gets "rendered". A higher-order `VirtualScroller` implementation could either "render" the list immediately in its `updateState()` function, like the DOM implementation does, or the `updateState()` function could "schedule" a "re-render", like the React implementation does, in which case such `updateState()` function would be called an ["asynchronous"](https://reactjs.org/docs/state-and-lifecycle.html#state-updates-may-be-asynchronous) one, meaning that state updates aren't "rendered" immediately and are instead queued and then "rendered" in a single compound state update for better performance.
* `setState(newState: object)` — Sets the externally managed `VirtualScroller` `state`. Must call `.onRender()` right after the updated `state` gets "rendered". A higher-order `VirtualScroller` implementation could either "render" the list immediately in its `setState()` function, in which case it would be better to use the default state management instead and pass a custom `render()` function, or the `setState()` function could "schedule" an "asynchronous" "re-render", like the React implementation does, in which case such `setState()` function would be called an ["asynchronous"](https://reactjs.org/docs/state-and-lifecycle.html#state-updates-may-be-asynchronous) one, meaning that state updates aren't "rendered" immediately and are instead queued and then "rendered" in a single compound state update for better performance.

* `updateState(stateUpdate: object)` — (optional) `setState()` parameter could be replaced with `updateState()` parameter. The only difference between the two is that `updateState()` gets called with just the portion of the state that is being updated while `setState()` gets called with the whole updated state object, so it's just a matter of preference.

For a usage example, see `./source/react/VirtualScroller.js`. The steps are:

Expand All @@ -343,7 +345,7 @@ When using custom (external) state management, contrary to the default (internal

#### "Advanced" (rarely used) instance methods

* `onItemHeightDidChange(i: number)` — (advanced) If an item's height could've changed, this function should be called immediately after the item's height has potentially changed. An example would be having an "Expand"/"Collapse" button in a list item. The function re-measures the item's height and re-calculates `VirtualScroller` layout.
* `onItemHeightDidChange(i: number)` — (advanced) If an item's height could've changed, this function should be called immediately after the item's height has potentially changed. The function re-measures the item's height (the item must still be rendered) and re-calculates `VirtualScroller` layout. An example for using this function would be having an "Expand"/"Collapse" button in a list item.

* There's also a convention that every change in an item's height must come as a result of changing its "state", be it the item's state that is stored internally in the `VirtualScroller` state (see `.setItemState()` function) or some other application-level state that is stored outside of the `VirtualScroller` state.

Expand Down Expand Up @@ -467,7 +469,7 @@ The required properties are:
* `item: any` — The item object itself (an element of the `items` array).
* `state: any?` — Item's state. See the description of `itemStates` property of `VirtualScroller` `state` for more details.
* `setState(newState: any?)` — Can be called to replace item's state. See the description of `setItemState(i, newState)` function of `VirtualScroller` for more details.
* `onHeightDidChange(i)` — If an item's height could change after the initial render, this function should be called immediately after the item's height has potentially changed. See the description of `onItemHeightDidChange()` function of `VirtualScroller` for more details.
* `onHeightDidChange(i)` — If an item's height could change after the initial render, this function should be called immediately after the item's height has potentially changed — in a `useLayoutEffect()`. See the description of `onItemHeightDidChange()` function of `VirtualScroller` for more details.

* For best performance, make sure that `itemComponent` is a `React.memo()` component or a `React.PureComponent`. Otherwise, list items will keep re-rendering themselves as the user scrolls because the containing `<VirtualScroller/>` component gets re-rendered on scroll.

Expand Down
2 changes: 2 additions & 0 deletions index.cjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
'use strict'

exports = module.exports = require('./commonjs/VirtualScroller.js').default

exports.ItemNotRenderedError = require('./commonjs/ItemNotRenderedError.js').default
exports['default'] = require('./commonjs/VirtualScroller.js').default
6 changes: 6 additions & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,3 +99,9 @@ export default class VirtualScroller<Element, Item, ItemState = NoItemState> {
getInitialState(): State<Item, ItemState>;
useState(options: UseStateOptions<Item, ItemState>): void;
}

export class ItemNotRenderedError {
constructor(
message: string
);
}
1 change: 1 addition & 0 deletions index.js
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export { default } from './modules/VirtualScroller.js'
export { default as ItemNotRenderedError } from './modules/ItemNotRenderedError.js'
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "virtual-scroller",
"version": "1.11.3",
"version": "1.12.0",
"description": "A component for efficiently rendering large lists of variable height items",
"main": "index.cjs",
"module": "index.js",
Expand Down
11 changes: 8 additions & 3 deletions source/DOM/ItemsContainer.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import ItemNotRenderedError from '../ItemNotRenderedError.js'

export default class ItemsContainer {
/**
* Constructs a new "container" from an element.
Expand All @@ -10,9 +12,12 @@ export default class ItemsContainer {
_getNthRenderedItemElement(renderedElementIndex) {
const childNodes = this.getElement().childNodes
if (renderedElementIndex > childNodes.length - 1) {
console.log('~ Items Container Contents ~')
console.log(this.getElement().innerHTML)
throw new Error(`Element with index ${renderedElementIndex} was not found in the list of Rendered Item Elements in the Items Container of Virtual Scroller. There're only ${childNodes.length} Elements there.`)
// console.log('~ Items Container Contents ~')
// console.log(this.getElement().innerHTML)
throw new ItemNotRenderedError({
renderedElementIndex,
renderedElementsCount: childNodes.length
})
}
return childNodes[renderedElementIndex]
}
Expand Down
16 changes: 16 additions & 0 deletions source/ItemNotRenderedError.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
export default class ItemNotRenderedError extends Error {
constructor({
renderedElementIndex,
renderedElementsCount,
message
}) {
super(message || getDefaultMessage({ renderedElementIndex, renderedElementsCount }))
}
}

function getDefaultMessage({
renderedElementIndex,
renderedElementsCount
}) {
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.`
}
9 changes: 9 additions & 0 deletions source/Layout.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,10 @@ describe('Layout', function() {

let shouldResetGridLayout

const errors = []

global.VirtualScrollerCatchError = (error) => errors.push(error)

layout.getLayoutUpdateForItemsDiff(
{
firstShownItemIndex: 3,
Expand All @@ -171,6 +175,11 @@ describe('Layout', function() {
afterItemsHeight: 5 * (ITEM_HEIGHT + VERTICAL_SPACING)
})

global.VirtualScrollerCatchError = undefined
errors.length.should.equal(2)
errors[0].message.should.equal('[virtual-scroller] ~ Prepended items count 5 is not divisible by Columns Count 4 ~')
errors[1].message.should.equal('[virtual-scroller] Layout reset required')

shouldResetGridLayout.should.equal(true)
})
})
2 changes: 2 additions & 0 deletions source/VirtualScroller.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ export default class VirtualScroller {
const isRestart = this._isActive === false

if (!isRestart) {
this.waitingForRender = true

// If no custom state storage has been configured, use the default one.
// Also sets the initial state.
if (!this._usesCustomStateStorage) {
Expand Down
75 changes: 57 additions & 18 deletions source/VirtualScroller.layout.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import { setTimeout, clearTimeout } from 'request-animation-frame-timeout'
import log, { warn, isDebug, reportError } from './utility/debug.js'
import { LAYOUT_REASON } from './Layout.js'

import ItemNotRenderedError from './ItemNotRenderedError.js'

export default function() {
this.onUpdateShownItemIndexes = ({ reason, stateUpdate }) => {
// In case of "don't do anything".
Expand Down Expand Up @@ -149,6 +151,9 @@ export default function() {

// Set `this.firstNonMeasuredItemIndex`.
this.firstNonMeasuredItemIndex = firstNonMeasuredItemIndex
// if (firstNonMeasuredItemIndex !== undefined) {
// log('Non-measured item index that will be measured at next layout', firstNonMeasuredItemIndex)
// }

// Set "previously calculated layout".
//
Expand Down Expand Up @@ -340,20 +345,21 @@ export default function() {
// rather than from scratch, which would be an optimization.
//
function updatePreviouslyCalculatedLayoutOnItemHeightChange(i, previousHeight, newHeight) {
if (this.previouslyCalculatedLayout) {
const prevLayout = this.previouslyCalculatedLayout
if (prevLayout) {
const heightDifference = newHeight - previousHeight
if (i < this.previouslyCalculatedLayout.firstShownItemIndex) {
// Patch `this.previouslyCalculatedLayout`'s `.beforeItemsHeight`.
this.previouslyCalculatedLayout.beforeItemsHeight += heightDifference
} else if (i > this.previouslyCalculatedLayout.lastShownItemIndex) {
// Could patch `.afterItemsHeight` of `this.previouslyCalculatedLayout` here,
// if `.afterItemsHeight` property existed in `this.previouslyCalculatedLayout`.
if (this.previouslyCalculatedLayout.afterItemsHeight !== undefined) {
this.previouslyCalculatedLayout.afterItemsHeight += heightDifference
if (i < prevLayout.firstShownItemIndex) {
// Patch `prevLayout`'s `.beforeItemsHeight`.
prevLayout.beforeItemsHeight += heightDifference
} else if (i > prevLayout.lastShownItemIndex) {
// Could patch `.afterItemsHeight` of `prevLayout` here,
// if `.afterItemsHeight` property existed in `prevLayout`.
if (prevLayout.afterItemsHeight !== undefined) {
prevLayout.afterItemsHeight += heightDifference
}
} else {
// Patch `this.previouslyCalculatedLayout`'s shown items height.
this.previouslyCalculatedLayout.shownItemsHeight += newHeight - previousHeight
// Patch `prevLayout`'s shown items height.
prevLayout.shownItemsHeight += newHeight - previousHeight
}
}
}
Expand All @@ -371,7 +377,7 @@ export default function() {
}

this._onItemHeightDidChange = (i) => {
log('~ Re-measure item height ~')
log('~ On Item Height Did Change was called ~')
log('Item index', i)

const {
Expand Down Expand Up @@ -412,24 +418,57 @@ export default function() {

const previousHeight = itemHeights[i]
if (previousHeight === undefined) {
return reportError(`"onItemHeightDidChange()" has been called for item ${i}, but that item hasn't been rendered before.`)
return reportError(`"onItemHeightDidChange()" has been called for item index ${i} but the item hasn't been rendered before.`)
}

const newHeight = remeasureItemHeight.call(this, i)
log('~ Re-measure item height ~')

let newHeight

try {
newHeight = remeasureItemHeight.call(this, i)
} catch (error) {
// Successfully finishing an `onItemHeightDidChange(i)` call is not considered
// critical for `VirtualScroller`'s operation, so such errors could be ignored.
if (error instanceof ItemNotRenderedError) {
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}`)
}
}

log('Previous height', previousHeight)
log('New height', newHeight)

if (previousHeight !== newHeight) {
log('~ Item height has changed ~')
log('~ Item height has changed. Should update layout. ~')

// Update or reset previously calculated layout.
// Update or reset a previously calculated layout
// so that the "diff"s based on that layout in the future
// produce correct results.
updatePreviouslyCalculatedLayoutOnItemHeightChange.call(this, i, previousHeight, newHeight)

// Recalculate layout.
this.onUpdateShownItemIndexes({ reason: LAYOUT_REASON.ITEM_HEIGHT_CHANGED })
//
// If the `VirtualScroller` is already waiting for a state update to be rendered,
// delay `onItemHeightDidChange(i)`'s re-layout until that state update is rendered.
// The reason is that React `<VirtualScroller/>`'s `onHeightDidChange()` is meant to
// be called inside `useLayoutEffect()` hook. Due to how React is implemented internally,
// that might happen in the middle of the currently pending `setState()` operation
// being applied, resulting in weird "race condition" bugs.
//
if (this.waitingForRender) {
log('~ Another state update is already waiting to be rendered. Delay the layout update until then. ~')
this.updateLayoutAfterRenderBecauseItemHeightChanged = true
} else {
this.onUpdateShownItemIndexes({ reason: LAYOUT_REASON.ITEM_HEIGHT_CHANGED })
}

// Schedule the item height update for after the new items have been rendered.
// If there was a request for `setState()` with new `items`, then the changes
// to `currentState.itemHeights[]` made above in a `remeasureItemHeight()` call
// would be overwritten when that pending `setState()` call gets applied.
// To fix that, the updates to current `itemHeights[]` are noted in
// `this.itemHeightsThatChangedWhileNewItemsWereBeingRendered` variable.
// That variable is then checked when the `setState()` call with the new `items`
// has been updated.
if (this.newItemsWillBeRendered) {
if (!this.itemHeightsThatChangedWhileNewItemsWereBeingRendered) {
this.itemHeightsThatChangedWhileNewItemsWereBeingRendered = {}
Expand Down
Loading

0 comments on commit e1f1449

Please sign in to comment.