https://github.com/catamphetamine/virtual-scroller
A component for efficiently rendering large lists of variable height items
https://github.com/catamphetamine/virtual-scroller
Last synced: about 1 month ago
JSON representation
A component for efficiently rendering large lists of variable height items
- Host: GitHub
- URL: https://github.com/catamphetamine/virtual-scroller
- Owner: catamphetamine
- License: mit
- Created: 2019-05-10T08:10:55.000Z (about 6 years ago)
- Default Branch: master
- Last Pushed: 2024-06-04T00:00:05.000Z (about 1 year ago)
- Last Synced: 2025-04-06T04:31:57.083Z (2 months ago)
- Language: JavaScript
- Homepage: https://catamphetamine.gitlab.io/virtual-scroller/?dynamic=%E2%9C%93
- Size: 2.32 MB
- Stars: 129
- Watchers: 7
- Forks: 16
- Open Issues: 3
-
Metadata Files:
- Readme: README.md
- Changelog: CHANGELOG.md
- License: LICENSE
- Code of conduct: CODE_OF_CONDUCT.md
Awesome Lists containing this project
README
# VirtualScroller
A universal open-source implementation of Twitter's [`VirtualScroller`](https://medium.com/@paularmstrong/twitter-lite-and-high-performance-react-progressive-web-apps-at-scale-d28a00e780a3) component: a component for efficiently rendering large lists of *variable height* items. Supports grid layout.
* For React users, includes a [React](#react) component.
* For those who prefer "vanilla" DOM, there's a [DOM](#dom) component.
* For everyone else, there's a low-level [core](#core) component that supports any type of [rendering engine](#rendering-engine), not just DOM. Use it to create your own implementation for any framework or environment.## Demo
[DOM](#dom) (no frameworks):
* [Basic](https://catamphetamine.gitlab.io/virtual-scroller/index-dom.html)
* [Dynamically loaded](https://catamphetamine.gitlab.io/virtual-scroller/index-dom.html?dynamic=✓)[React](#react):
* [Basic](https://catamphetamine.gitlab.io/virtual-scroller/)
* [Dynamically loaded](https://catamphetamine.gitlab.io/virtual-scroller/?dynamic=✓)[Grid Layout](#grid-layout):
* [Basic](https://catamphetamine.gitlab.io/virtual-scroller/index-grid.html)
* [Dynamically loaded](https://catamphetamine.gitlab.io/virtual-scroller/index-grid.html?dynamic=✓)## Rationale
Rendering really long lists in HTML can be performance intensive which sometimes leads to slow page load times and wasting mobile users' battery. For example, consider a chat app rendering a list of a thousand of the most recent messages: when using React the full render cycle can take up to 100 milliseconds or more on a modern PC. If the chat message component is complex enough (rich text formatting, pictures, videos, attachments, buttons) then it could take up to a second or more (on a modern PC). Now imagine users viewing the website on their aged low-tier smartphones and it quickly results in annoying user experience resulting in them closing the website and the website losing its user base.
In 2017 Twitter completely redesigned their website with responsiveness and performance in mind using the latest performance-boosting techniques available at that time. They wrote an [article](https://medium.com/@paularmstrong/twitter-lite-and-high-performance-react-progressive-web-apps-at-scale-d28a00e780a3) about it where they briefly mentioned this:

> On slower devices, we noticed that it could take a long time for our main navigation bar to appear to respond to taps, often leading us to tap multiple times, thinking that perhaps the first tap didn’t register.
It turns out that mounting and unmounting large trees of components (like timelines of Tweets) is very expensive in React.
Over time, we developed a new infinite scrolling component called VirtualScroller. With this new component, we know exactly what slice of Tweets are being rendered into a timeline at any given time, avoiding the need to make expensive calculations as to where we are visually.However, Twitter didn't share the code for their `VirtualScroller` component (unlike Facebook, Twitter doesn't share much of their code). This library is an attempt to create an open-source implementation of such `VirtualScroller` component for anyone to use in their projects.
There's also an ["RFC"](https://github.com/WICG/virtual-scroller) for a native `VirtualScroller` component where they try to formulate what is a `VirtualScroller` component and how it should behave.
## How it works
`VirtualScroller` works by measuring each list item's height as it's being rendered, and then, as the user scrolls, it hides the items that are no longer visible, and shows the now-visible items as they're scrolled to. The hidden items at the top are compensated by setting `padding-top` on the list element, and the hidden items at the bottom are compensated by setting `padding-bottom` on the list element. The component listens to `scroll` / `resize` events and re-renders the currently visible items as the user scrolls (or if the browser window is resized).
To observe list item elements being dynamically mounted and unmounted, go to the [demo](https://catamphetamine.gitlab.io/virtual-scroller) page, open Developer Tools ("Elements" tab), find `
` element, expand it, see `` element, expand it and observe the changes to it while scrolling the page.To add some inter-item spacing, one could use `margin-top` / `margin-bottom` or `border-top` / `border-bottom`: see the [Gotchas](#gotchas) section for more details on how to do that properly.
## Install
```
npm install virtual-scroller --save
```If you're not using a bundler then use a [standalone version from a CDN](#cdn).
## Core
The default export is a low-level `VirtualScroller` class: it implements the core logic of a `VirtualScroller` component and can be used for building a `VirtualScroller` component for any UI framework or even any [rendering engine](#rendering-engine) other than DOM. This core class is not meant to be used in applications directly. Instead, prefer using one of the high-level components provided by this library: [`virtual-scroller/dom`](#dom) or [`virtual-scroller/react`](#react) packages. Or implement your own: see `source/test` folder for an example of using the core class to build an "imaginary" renderer implementation.
#### State
The core `VirtualScroller` component works by dynamically updating its `state` as the user scrolls the page. The `state` provides the calculations on which items should be rendered (and which should not be) depending on the current scroll position.
A higher-level wrapper around the core `VirtualScroller` component must manage the rendering of the items using the information from the `state`. At any given time, `state` should correspond exactly to what's rendered on the screen: whenever `state` gets updated, the corresponding changes should be immediately (without any "timeout" or "delay") rendered on the screen.
state
properties#####
The main `state` properties are:
* `items: any[]` — The list of items (can be updated via [`.setItems()`](#dynamically-loaded-lists)).
* `firstShownItemIndex: number` — The index of the first item that should be rendered.
* `lastShownItemIndex: number` — The index of the last item that should be rendered.
* `beforeItemsHeight: number` — The `padding-top` which should be applied to the "container" element: it emulates all items before `firstShownItemIndex` as if they were rendered.
* `afterItemsHeight: number` — The `padding-bottom` which should be applied to the "container" element: it emulates all items after `lastShownItemIndex` as if they were rendered.
The following `state` properties are only used for saving and restoring `VirtualScroller` `state`, and normally shouldn't be accessed:
* `itemStates: any[]` — The "states" of all items. If an item's appearance is not "static" and could change, then every aspect of the item's appearance that could change should be represented in the item's "state", and that "state" must be preserved somewhere. That's because of the nature of how `VirtualScroller` works: no-longer-visible items get un-rendered, and when they later become visible again, they should precisely restore their latest-rendered appearance by re-rendering from a previously preserved "state".
* The item "state" could be preserved anywhere in the application, or the developer could use `VirtualScroller`'s built-in item "state" storage. To preserve an item's state in the built-in storage, call `.setItemState(i, itemState)` instance method (described below) immediately after an item's state has changed.
* An example would be an item representing a social media comment, with a "Show more"/"Show less" button that shows or hides the full text of the comment. Immediately after the full text of a comment has been shown or hidden, it should call `.setItemState(i, { showMore: true/false })` instance method along with `.onItemHeightDidChange(i)` instance method (described below), so that next time when the item is rendered, it could restore its appearance from `virtualScroller.getState().itemStates[i]`.
* For another similar example, consider a social network feed, where each post optionally has an attachment. Suppose there's a post in the feed having a YouTube video attachment. The attachment is initially shown as a small thumbnail that expands into a full-sized embedded YouTube video player when a user clicks on it. If the expanded/collapsed state of such attachment wasn't preserved, then the following "glitch" would be observed: the user expands the video, then scrolls down so that the post with the video is no longer visible, the post gets unmounted due to going off screen, then the user scrolls back up so that the post with the video is visible again, the post gets mounted again, but the video is not expanded and instead a small thumbnail is shown because there's no previous "state" to restore from.
* In this example, besides preserving the item state itself, one should also call `.onItemHeightDidChange(i)` instance method (described below) right after the YouTube video has been expanded/collapsed.
* `itemHeights: number[]` — The measured heights of all items. If an item's height hasn't been measured yet then it's `undefined`.
* By default, items are only measured once: when they're initially rendered. If an item's height changes afterwards, then `.onItemHeightDidChange(i)` instance method must be called right after it happens (described later in the document), otherwise `VirtualScroller`'s calculations will be off. For example, if an item is a social media comment, and there's a "Show more"/"Show less" button that shows the full text of the comment, then it must call `.onItemHeightDidChange(i)` immediately after the comment text has been expanded or collapsed.
* Besides the requirement of calling `.onItemHeightDidChange(i)`, every change in an item's height must also be reflected in the actual data: the change in height must be either a result of the item's internal properties changing or it could be a result of changing the item's "state". The reason is that when an item gets hidden, it's no longer rendered, so when it becomes visible again, it should precisely restore its last-rendered appearance based on the item's properties and any persisted "state".
* `verticalSpacing: number?` — Vertical item spacing. Is `undefined` until it has been measured. Is only measured once, when at least two rows of items have been rendered.
* `columnsCount: number?` — The count of items in a row. Is `undefined` if no `getColumnsCount()` parameter has been passed to `VirtualScroller`, or if the columns count is `1`.
* `scrollableContainerWidth: number?` — The width of the scrollable container. For DOM implementations, that's gonna be either the browser window width or some scrollable parent element width. Is `undefined` until it has been measured after the `VirtualScroller` has been `start()`-ed.
#### Example
A general idea of using the low-level
VirtualScroller
class:#####
```js
import VirtualScroller from 'virtual-scroller'const items = [
{ name: 'Apple' },
{ name: 'Banana' },
...
]const getContainerElement = () => document.getElementById(...)
const virtualScroller = new VirtualScroller(getContainerElement, items, {
render(state) {
// Re-renders the list based on its `state`.
const {
items,
firstShownItemIndex,
lastShownItemIndex,
beforeItemsHeight,
afterItemsHeight
} = statecontainer.paddingTop = beforeItemsHeight
container.paddingBottom = afterItemsHeightcontainer.children = items
.slice(firstShownItemIndex, lastShownItemIndex + 1)
.map(createItemElement)
}
})// Start listening to scroll events.
virtualScroller.start()// Stop listening to scroll events.
virtualScroller.stop()
```* `getContainerElement()` — Returns the list "element" that is gonna contain all list item "elements".
* `items` — The list of items.
* `render(state, prevState)` — "Renders" the list.#####
An example of implementing a high-level
virtual-scroller/dom
component on top of the low-levelVirtualScroller
class.#####
```js
import VirtualScroller from 'virtual-scroller'const items = [
{ title: 'Apple' },
{ title: 'Banana' },
{ title: 'Cranberry' }
]function renderItem(item) {
const div = document.createElement('div')
div.innerText = item.title
return div
}const container = document.getElementById('list')
function render(state, prevState) {
const {
items,
beforeItemsHeight,
afterItemsHeight,
firstShownItemIndex,
lastShownItemIndex
} = state// Set `paddingTop` and `paddingBottom` on the container element:
// it emulates the non-visible items as if they were rendered.
container.style.paddingTop = Math.round(beforeItemsHeight) + 'px'
container.style.paddingBottom = Math.round(afterItemsHeight) + 'px'// Perform an intelligent "diff" re-render as the user scrolls the page.
// This also requires that the list of `items` hasn't been changed.
// On initial render, `prevState` is `undefined`.
if (prevState && items === prevState.items) {// Remove no longer visible items.
let i = prevState.lastShownItemIndex
while (i >= prevState.firstShownItemIndex) {
if (i >= firstShownItemIndex && i <= lastShownItemIndex) {
// The item is still visible.
} else {
// The item is no longer visible. Remove it.
container.removeChild(container.childNodes[i - prevState.firstShownItemIndex])
}
i--
}// Add newly visible items.
let prependBefore = container.firstChild
let i = firstShownItemIndex
while (i <= lastShownItemIndex) {
if (i >= prevState.firstShownItemIndex && i <= prevState.lastShownItemIndex) {
// The item is already being rendered.
// Next items will be appended rather than prepended.
prependBefore = undefined
} else {
if (prependBefore) {
container.insertBefore(renderItem(items[i]), prependBefore)
} else {
container.appendChild(renderItem(items[i]))
}
}
i++
}
}
else {
// Re-render the list from scratch.
while (container.firstChild) {
container.removeChild(container.firstChild)
}
let i = firstShownItemIndex
while (i <= lastShownItemIndex) {
container.appendChild(renderItem(items[i]))
i++
}
}
}const virtualScroller = new VirtualScroller(() => element, items, { render })
// Start VirtualScroller listening for scroll events.
virtualScroller.start()// Stop VirtualScroller listening for scroll events
// when the user navigates to another page:
// router.onPageUnload(virtualScroller.stop)
```#### Options
VirtualScroller
options
#####
* `state: object` — The initial state for `VirtualScroller`. Can be used, for example, to quicky restore the list when it's re-rendered on "Back" navigation.
* `render(state: object, previousState: object?)` — When a developer doesn't pass custom `getState()`/`updateState()` parameters (more on that later), `VirtualScroller` uses the default ones. The default `updateState()` function relies on a developer-supplied `render()` function that must "render" the current `state` of the `VirtualScroller` on the screen. See DOM `VirtualScroller` implementation for an example of such a `render()` function.
* `onStateChange(newState: object, previousState: object?)` — An "on change" listener for the `VirtualScroller` `state` that gets called whenever `state` gets updated, including when setting the initial `state`.
* Is not called when individual item heights (including "before resize" ones) or individual item states are updated: instead, individual item heights or states are updated in-place, as `state.itemHeights[i] = newItemHeight` or `state.itemStates[i] = newItemState`. That's because those `state` properties are the ones that don’t affect the presentation, so there's no need to re-render the list when those properties do change — updates to those properties are just an effect of a re-render rather than a cause for a new re-render.
* `onStateChange()` parameter could be used to keep a copy of `VirtualScroller` `state` so that it could be quickly restored in case the `VirtualScroller` component gets unmounted and then re-mounted back again — for example, when the user navigates away by clicking on a list item and then navigates "Back" to the list.
* (advanced) If state updates are done "asynchronously" via a custom (external) `updateState()` function, then `onStateChange()` gets called after such state updates get "rendered" (after `virtualScroller.onRender()` gets called).
* `getScrollableContainer(): Element` — (advanced) If the list is being rendered in a "scrollable container" (for example, if one of the parent elements of the list is styled with `max-height` and `overflow: auto`), then passing the "scrollable container" DOM Element is required for correct operation. "Gotchas":
* If `getColumnsCount()` parameter depends on the "scrollable container" argument for getting the available area width, then the "scrollable container" element must already exist when creating a `VirtualScroller` class instance, because the initial `state` is calculated at construction time.
* When used with one of the DOM environment `VirtualScroller` implementations, the width and height of a "scrollable container" should only change when the browser window is resized, i.e. not manually via `scrollableContainerElement.width = 720`, because `VirtualScroller` only listens to browser window resize events, and any other changes in "scrollable container" width won't be detected.
* `getColumnsCount(container: ScrollableContainer): number` — (advanced) Provides support for ["grid"](#grid-layout) layout. Should return the columns count. The `container` argument provides a `.getWidth()` method for getting the available area width.
#### "Advanced" (rarely used) options
* `bypass: boolean` — Pass `true` to turn off `VirtualScroller` behavior and just render the full list of items.
* `getInitialItemState: (item: any) => any?` — Creates the initial state for an item. It can be used to populate the default initial states for list items. By default, an item's state is `undefined`.
* `initialScrollPosition: number` — If passed, the page will be scrolled to this `scrollY` position.
* `onScrollPositionChange(scrollY: number)` — Is called whenever a user scrolls the page.
* `getItemId(item)` — (advanced) When `items` are dynamically updated via `.setItems()`, `VirtualScroller` detects an "incremental" update by comparing "new" and "old" item ["references"](https://codeburst.io/explaining-value-vs-reference-in-javascript-647a975e12a0): this way, `VirtualScroller` can understand that the "new" `items` are (mostly) the same as the "old" `items` when some items get prepended or appended to the list, in which case it doesn't re-render the whole list from scratch, but rather just renders the "new" items that got prepended or appended. Sometimes though, some of the "old" items might get updated: for example, if `items` is a list of comments, then some of those comments might get edited in-between the refreshes. In that case, the edited comment object reference should change in order to indicate that the comment's content has changed and that the comment should be re-rendered (at least that's how it has to be done in React world). At the same time, changing the edited comment object reference would break `VirtualScroller`'s "incremental" update detection, and it would re-render the whole list of comments from scratch, which is not what it should be doing in such cases. So, in cases like this, `VirtualScroller` should have some way to understand that the updated item, even if its object reference has changed, is still the same as the old one, so that it doesn't break "incremental" update detection. For that, `getItemId(item)` parameter could be passed, which `VirtualScroller` would use to compare "old" and "new" items (instead of the default "reference equality" check), and that would fix the "re-rendering the whole list from scratch" issue. It can also be used when `items` are fetched from an external API, in which case all item object references change on every such fetch.
* `onItemInitialRender(item)` — (advanced) Will be called for each `item` when it's about to be rendered for the first time. This function could be used to somehow "initialize" an item before it gets rendered for the first time. For example, consider a list of items that must be somehow "preprocessed" (parsed, enhanced, etc) before being rendered, and such "preprocessing" puts some load on the CPU (and therefore takes some time). In such case, instead of "preprocessing" the whole list of items up front, the application could "preprocess" only the items that're actually visible, preventing the unnecessary work and reducing the "time to first render".
* The function is guaranteed to be called at least once for each item that ever gets rendered.
* In more complex and non-trivial cases it could be called multiple times for a given item, so it should be written in such a way that calling it multiple times wouldn't do anything. For example, it could set a boolean flag on an item and then check that flag on each subsequent invocation.* One example of the function being called multiple times would be when run in an "asynchronous" rendering framework like React. In such frameworks, "rendering" and "painting" are two separate actions separated in time, so one doesn't necessarily cause the other. For example, React could render a component multiple times before it actually gets painted on screen. In that example, the function would be called for a given item on each render until it finally gets painted on screen.
* Another example would be calling `VirtualScroller.setItems()` function with a "non-incremental" `items` update. An `items` update would be "non-incremental", for example, if some items got removed from the list, or some new items got inserted in the middle of the list, or the order of the items changed. In case of a "non-incremental" `items` update, `VirtualScroller` resets then previous state and basically "forgets" everything about the previous items, including the fact that the function has already been called for some of the items.* `measureItemsBatchSize: number` — (advanced) (experimental) Imagine a situation when a user doesn't gradually scroll through a huge list but instead hits an End key to scroll right to the end of such huge list: this will result in the whole list rendering at once (because an item needs to know the height of all previous items in order to render at correct scroll position) which could be CPU-intensive in some cases (for example, when using React due to its slow performance when initially rendering components on a page). To prevent freezing the UI in the process, a `measureItemsBatchSize` could be configured, that would limit the maximum count of items that're being rendered in a single pass for measuring their height: if `measureItemsBatchSize` is configured, then such items will be rendered and measured in batches. By default it's set to `100`. This is an experimental feature and could be removed in future non-major versions of this library. For example, the future React 17 will come with [Fiber](https://www.youtube.com/watch?v=ZCuYPiUIONs) rendering engine that is said to resolve such freezing issues internally. In that case, introducing this option may be reconsidered.
* `getEstimatedItemHeight: () => number` or `getEstimatedVisibleItemRowsCount: () => number` — These functions are only used during the initial render of the list to estimate how many of the list items should be rendered initially to cover the screen space plus some extra vertical margin (called "prerender margin") for future scrolling. The list will then immediately re-render itself the second time anyway, just to make sure that the layout has been calculated correctly, so the values returned by these functions doesn't have to be 100% accurate. But the more accurate they are, the more accurate is the initial render. The only purpose for the existence of these parameters is eliminating any potential initial "flash" of empty space on screen caused by the list of items being only half-rendered. These two parameters are mutually exclusive: one may only provide one of them. If none of these parameters is provided, then the list initially renders with just the first item being shown, then measures the first item's size, and then rerenders itself again using the measured item height as a substitute for `getEstimatedItemHeight()`.
* `prerenderMargin` — The list component renders not only the items that're currently visible but also the items that lie within some extra vertical margin (called "prerender margin") on top and bottom for future scrolling: this way, there'll be significantly less layout recalculations as the user scrolls, because now it doesn't have to recalculate layout on each scroll event. By default, the "prerender margin" is equal to the screen height: this seems to be the optimal value for "Page Up" / "Page Down" navigation and optimized mouse wheel scrolling. This parameter is currently ignored because the default value seems to fit all possible use cases.
#####
VirtualScroller
instance methods#####
* `start()` — `VirtualScroller` starts listening for scroll events. Should be called after the list has been rendered initially.
* `stop()` — `VirtualScroller` stops listening for scroll events. Should be called when the list is about to be removed from the page. To re-start the `VirtualScroller`, call `.start()` method again.
* `getState(): object` — Returns `VirtualScroller` state.
* `setItems(newItems: any[], options: object?)` — Updates `VirtualScroller` `items`. For example, it can be used to prepend or append new items to the list. See [Dynamically Loaded Lists](#dynamically-loaded-lists) section for more details. Available options:
* `preserveScrollPositionOnPrependItems: boolean` — Set to `true` to enable "restore scroll position after prepending new items" feature (should be used when implementing a "Show previous items" button).#### Custom (External) State Management
A developer might prefer to use custom (external) state management rather than the default one. That might be the case when a certain high-order `VirtualScroller` implementation comes with a specific state management paradigm, like in React. In such case, `VirtualScroller` provides the following instance methods:
* `onRender()` — When using custom (external) state management, `.onRender()` function must be called every time right after the list has been "rendered" (including the initial render). The list should always "render" only with the "latest" state where the "latest" state is defined as the argument of the latest `setState()` call. Otherwise, the component may not work correctly.
* `getInitialState(): object` — Returns the initial `VirtualScroller` state for the cases when a developer configures `VirtualScroller` for custom (external) state management.
* `useState({ getState, setState, updateState? })` — Enables custom (external) state management.
* `getState(): object` — Returns the externally managed `VirtualScroller` `state`.
* `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:
* Create a `VirtualScroller` instance.
* Get the initial state value via `virtualScroller.getInitialState()`.
* Initialize the externally managed state with the initial state value.
* Define `getState()` and `updateState()` functions for reading or updating the externally managed state.
* Call `virtualScroller.useState({ getState, updateState })`.
* "Render" the list and call `virtualScroller.start()`.
When using custom (external) state management, contrary to the default (internal) state management approach, the `render()` function parameter can't be passed to the `VirtualScroller` constructor. The reason is that `VirtualScroller` wouldn't know when exactly should it call such `render()` function because by design it can only be called right after the state has been updated, and `VirtualScroller` doesn't know when exactly does the state get updated, because state updates are done via an "external" `updateState()` function that could as well apply state updates "asynchronously" (after a short delay), like in React, rather than "synchronously" (immediately). That's why the `updateState()` function must re-render the list by itself, at any time it finds appropriate, and right after the list has been re-rendered, it must call `virtualScroller.onRender()`.
#### "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. 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 the item's "state". See the descripton of `itemStates` and `itemHeights` properties of the `VirtualScroller` [state](#state) for more details.
* Implementation-wise, calling `onItemHeightDidChange()` manually could be replaced with detecting item height changes automatically via [Resize Observer](https://caniuse.com/#search=Resize%20Observer) in some future version.
* `setItemState(i: number, itemState: any?)` — (advanced) Preserves a list item's "state" inside `VirtualScroller`'s built-in item "state" storage. See the descripton of `itemStates` property of the `VirtualScroller` [state](#state) for more details.
* A developer could use it to preserve an item's "state" if it could change. The reason is that offscreen items get unmounted and any unsaved state is lost in the process. If an item's state is correctly preserved, the item's latest-rendered appearance could be restored from that state when the item gets mounted again due to becoming visible again.
* Calling `setItemState()` doesn't trigger a re-layout of `VirtualScroller` because changing a list item's state doesn't necessarily mean a change of its height, so a re-layout might not be required. If an item's height did change as a result of changing its state, then `VirtualScroller` layout must be updated, and to do that, one should call `onItemHeightDidChange(i)` right after the change in the item's state has been reflected on screen.
* `getItemScrollPosition(i: number): number?` — (advanced) Returns an item's scroll position inside the scrollable container. Returns `undefined` if any of the items before this item haven't been rendered yet.
* `updateLayout()` — (advanced) Triggers a re-layout of `VirtualScroller`. It's what's called every time on page scroll or window resize. You most likely won't ever need to call this method manually. Still, one could imagine a hypothetical case when a developer might want to call this method. For example, when the list's top position changes not as a result of scrolling the page or resizing the window, but rather because of some unrelated "dynamic" changes of the page's content. For example, if some DOM elements above the list are removed (like a closeable "info" notification element) or collapsed (like an "accordion" panel), then the list's top position changes, which means that now some of the previoulsy shown items might go off screen, revealing an unrendered blank area to the user. The area would be blank because the "shift" of the list's vertical position happened not as a result of the user scrolling the page or resizing the window, and, therefore, it won't be registered by the `VirtualScroller` component. To fix that, a developer might want to trigger a re-layout manually.
## DOM
`virtual-scroller/dom` component implements a `VirtualScroller` in a standard [Document Object Model](https://en.wikipedia.org/wiki/Document_Object_Model) environment (a web browser).
The DOM `VirtualScroller` component constructor accepts arguments:
* `container` — Container DOM `Element`.
* `items` — The list of items.
* `renderItem(item)` — A function that "renders" an `item` as a DOM `Element`.
* `options` — (optional) Core `VirtualScroller` options.It `.start()`s automatically upon being created, so there's no need to call `.start()` after creating it.
```js
import VirtualScroller from 'virtual-scroller/dom'const messages = [
{
username: 'john.smith',
date: new Date(),
text: 'I woke up today'
},
...
]function renderMessage(message) {
// Message element.
const root = document.createElement('article')// Message author.
const author = document.createElement('a')
author.setAttribute('href', `/users/${message.username}`)
author.textContent = `@${message.username}`
root.appendChild(author)// Message date.
const time = document.createElement('time')
time.setAttribute('datetime', message.date.toISOString())
time.textContent = message.date.toString()
root.appendChild(time)// Message text.
const text = document.createElement('p')
text.textContent = message.text
root.appendChild(text)// Return message element.
return root
}const virtualScroller = new VirtualScroller(
document.getElementById('messages'),
messages,
renderMessage
)// When the `VirtualScroller` component is no longer needed on the page:
// virtualScroller.stop()
```Additional DOM
VirtualScroller
options#####
* `onItemUnmount(itemElement)` — Is called after a `VirtualScroller` item DOM `Element` is unmounted. Can be used to add DOM `Element` ["pooling"](https://github.com/ChrisAntaki/dom-pool#what-performance-gains-can-i-expect).
#####
DOM
VirtualScroller
instance methods#####
* `start()` — A proxy for the corresponding `VirtualScroller` method.
* `stop()` — A proxy for the corresponding `VirtualScroller` method.
* `setItems(items, options)` — A proxy for the corresponding `VirtualScroller` method.
* `onItemHeightDidChange(i)` — A proxy for the corresponding `VirtualScroller` method.
* `setItemState(i, itemState)` — A proxy for the corresponding `VirtualScroller` method.
## React
`virtual-scroller/react` component implements a `VirtualScroller` in a [React](https://reactjs.org/) environment.
The required properties are:
* `items` — The list of items.
* `itemComponent` — List item React component.
* The `itemComponent` will receive properties:
* `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 — 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 `` component gets re-rendered on scroll.
#####
```js
import React from 'react'
import PropTypes from 'prop-types'
import VirtualScroller from 'virtual-scroller/react'function Messages({ messages }) {
return (
)
}function Message({ item: message }) {
const {
username,
date,
text
} = message
return (
@{username}
{text}
)
}const message = PropTypes.shape({
username: PropTypes.string.isRequired,
date: PropTypes.instanceOf(Date).isRequired,
text: PropTypes.string.isRequired
})Messages.propTypes = {
messages: PropTypes.arrayOf(message).isRequired
}Message.propTypes = {
item: message.isRequired
}
```Managing
itemComponent
state#####
If the `itemComponent` has any internal state, it should be stored in the `VirtualScroller` `state`. The need for saving and restoring list item component state arises because item components get unmounted as they go off screen. If the item component's state is not persested somehow, it would be lost when the item goes off screen. If the user then decides to scroll back up, that item would get re-rendered "from scratch", potentually causing a "jump of content" if it was somehow "expanded" prior to being hidden.
For example, consider a social network feed where feed items (posts) can be expanded or collapsed via a "Show more"/"Show less" button. Suppose a user clicks a "Show more" button on a post resulting in that post expanding in height. Then the user scrolls down and since the post is no longer visible it gets unmounted. Since no state is preserved by default, when the user scrolls back up and the post gets mounted again, its previous state will be lost and it will render as a collapsed post instead of an expanded one, resulting in a perceived "jump" of page content by the difference in height of the post being expanded and collapsed.
To fix that, `itemComponent` receives the following state management properties:
* `state` — Saved state of the item component. Use this property to render the item component.
* In the example described above, `state` might look like `{ expanded: true }`.
* This is simply a proxy for `virtualScroller.getState().itemStates[i]`.
* `setState(newItemState)` — Use this function to save the item component state whenever it changes.
* In the example described above, `setState()` would be called whenever a user clicks a "Show more"/"Show less" button.
* This is simply a proxy for `virtualScroller.setItemState(i, itemState)`.
* `onHeightDidChange()` — Call this function immediately after (if ever) the item element height has changed.
* In the example described above, `onHeightDidChange()` would be called immediately after a user has clicked a "Show more"/"Show less" button and the component has re-rendered itself, because that results in a change of the item element's height, so `VirtualScroller` should re-measure it in order for its internal calculations to stay correct.
* This is simply a proxy for `virtualScroller.onItemHeightDidChange(i)`.
```js
function ItemComponent({
item,
state,
setState,
onHeightDidChange
}) {
const [internalState, setInternalState] = useState(state)const hasMounted = useRef()
useLayoutEffect(() => {
if (hasMounted.current) {
setState(internalState)
onHeightDidChange()
} else {
// Skip the initial mount.
// Only handle the changes of the `internalState`.
hasMounted.current = true
}
}, [internalState])return (
{item.title}
{internalState && internalState.expanded &&
{item.text}
}
{
setInternalState({
...internalState,
expanded: !expanded
})
}}>
{internalState && internalState.expanded ? 'Show less' : 'Show more'}
)
}
```#####
<VirtualScroller/>
optional properties#####
Note: When passing any core `VirtualScroller` class options, only the initial values of those options will be applied, and any updates to those options will be ignored. That's because those options are only passed to the `VirtualScroller` base class constructor at initialization time. That means that none of those options should depend on any variable state or props. For example, if `getColumnsCount()` parameter was defined as `() => props.columnsCount`, then, if the `columnsCount` property changes, the underlying `VirtualScroller` instance won't see that change.
* `itemComponentProps: object` — The props passed to `itemComponent`.
* `getColumnsCount(): number` — The `getColumnsCount()` option of `VirtualScroller`.
* `as` — A component used as a container for the list items. Is `"div"` by default.
* `initialState: object` — The initial state for `VirtualScroller`. For example, one could snapshot the `` state before it is unmounted and then pass it back as the `state` property when the `` is re-mounted, like in the cases when navigating "Back" to a page in a web browser. This is simply the `state` option of `VirtualScroller` constructor.
* `getInitialItemState: (item: any) => any?` — Creates the initial state for an item. It can be used to populate the default initial states for list items. By default, an item's state is `undefined`. This is simply the `getInitialItemState` option of `VirtualScroller` constructor.
* `onStateChange(newState: object, previousState: object?)` — The `onStateChange` option of `VirtualScroller`. Could be used to restore a `VirtualScroller` state on "Back" navigation:
```js
import {
readVirtualScrollerState,
saveVirtualScrollerState
} from './globalStorage'function Example() {
const virtualScrollerState = useRef()useEffect(() => {
return () => {
// Save `VirtualScroller` state before the page unmounts.
saveVirtualScrollerState(virtualScrollerState.current)
}
})return (
virtualScrollerState.current = state}
/>
)
}
```* `preserveScrollPositionOnPrependItems: boolean` — The `preserveScrollPositionOnPrependItems` option of `VirtualScroller.setItems()` method.
* `getItemId(item): any` — The `getItemId` option of `VirtualScroller` class. The React component also uses it as a source for a React `key` for rendering an `item`. If `getItemId()` is not supplied, then item `key`s are autogenerated from a random-generated prefix (that changes every time `items` are updated) and an `item` index. Can be used to prevent `` from re-rendering all visible items every time `items` property is updated.
* `readyToStart: boolean` — One could pass `false` to delay the `start()` of the `VirtualScroller` until everything's "ready".
* For example, when navigating "Back" to a "Catalog" page, the application could fully restore the `VirtualScroller` state from a snapshot, but for that to work, the scroll position has to be restored independently by the application "router". When `VirtualScroller` attempts to "rehydrate" itself from a state snapshot, the scroll position should already be restored by the application "router". By default, `` component "rehydrates" itself in a `useLayoutEffect()`, which happens before `useLayoutEffect()` or `useEffect()` of the application "router" component according to the order in which React effects get executed: from child to parent. But at the same time, the application "router" component typically restores the scroll position in `useEffect()` meaning that the scroll position is not yet restored at the time `` component "rehydrates" itself. So the `` component should somehow "wait" for the application "router" to restore the scroll position, after which it should attempt to "rehydrate" itself. That's what the `readyToStart` property is for: until the scroll position is restored, it should pass `readyToStart={false}` property to the ``.* `bypass: boolean` — The `bypass` option of `VirtualScroller` class.
* `tbody: boolean` — The `tbody` option of `VirtualScroller` class.
* `getEstimatedItemHeight: () => number` — The `getEstimatedItemHeight` option of `VirtualScroller` class.
* `getEstimatedVisibleItemRowsCount: () => number` — The `getEstimatedVisibleItemRowsCount` option of `VirtualScroller` class.
* `measureItemsBatchSize: number` — The `measureItemsBatchSize` option of `VirtualScroller`.
* `onItemInitialRender(item)` — The `onItemInitialRender` option of `VirtualScroller` class.
* `getScrollableContainer(): Element` — The `getScrollableContainer` option of `VirtualScroller` class. The scrollable container DOM Element must exist by the time `` component is mounted.
#####
<VirtualScroller/>
instance methods#####
* `updateLayout()` — A proxy for the corresponding `VirtualScroller` method.
## Dynamically Loaded Lists
All previous examples described cases with static `items` list. When there's a need to update the `items` list dynamically, one can use `virtualScroller.setItems(newItems)` instance method. For example:
* When the user clicks "Show previous items" button, the `newItems` argument should be `previousItems.concat(currentlyShownItems)`.
* When the user clicks "Show next items" button, the `newItems` argument should be `currentlyShownItems.concat(nextItems)`.Find out what are "incremental" and "non-incremental" items updates, and why "incremental" updates are better.
#####
When using `virtual-scroller/dom` component, a developer should call `.setItems(newItems)` instance method in order to update items.
When using `virtual-scroller/react` React component, it calls `.setItems(newItems)` method automatically when new `items` property is passed.
The basic equality check (`===`) is used to intelligently compare `newItems` to the existing `items`. If `getItemId()` parameter is passed, then items are compared by their ids rather than by themselves. If a simple append and/or prepend operation is detected, then the update is an "incremental" one, and the list seamlessly transitions from the current state to the new state, preserving its state and scroll position. If, however, the items have been updated in such a way that it's not a simple append and/or prepend operation, then such update is a "non-incremental" one, and the entire list is rerendered from scratch, losing its state and resetting the scroll position. There're valid use cases for both situations.
For example, suppose a user navigates to a page where a list of `items: object[]` is shown using a `VirtualScroller`. When a user scrolls down to the last item in the list, a developer might want to query the database for the newly added items, and then show those new items to the user. In that case, the developer could send a query to the API with `afterId: number` parameter being the `id: number` of the last item in the list, and the API would then return a list of the `newItems: object[]` whose `id: number` is greater than the `afterId: number` parameter. Then, the developer would append the `newItems: object[]` to the `items: object[]`, and then call `VirtualScroller.setItems()` with the updated `items: object[]`, resulting in a "seamless" update of the list, preserving its state and scroll position.
Another example. Suppose a user navigates to a page where they can filter a huge list by a query entered in a search bar. In that case, when the user edits the query in the search bar, `VirtualScroller.setItems()` method is called with a list of filtered items, and the entire list is rerendered from scratch. In this case, it's ok to reset the `VirtualScroller` state and the scroll position.
When new items are appended to the list, the page scroll position remains unchanged. Same's for prepending new items to the list: the scroll position of the page stays the same, resulting in the list "jumping" down when new items get prepended. To fix that, pass `preserveScrollPositionOnPrependItems: true` option to the `VirtualScroller`. When using `virtual-scroller/dom` component, pass that option when creating a new instance, and when using `virtual-scroller/react` React component, pass `preserveScrollPositionOnPrependItems` property.
For implementing "infinite scroll" lists, a developer could also use [`on-scroll-to`](https://gitlab.com/catamphetamine/on-scroll-to) component.
## Grid Layout
To display items using a "grid" layout (i.e. multiple columns in a row), supply a `getColumnsCount(container: ScrollableContainer): number` parameter to `VirtualScroller`.
For example, to show a three-column layout on screens wider than `1280px`:
```js
function getColumnsCount(container) {
// The `container` argument provides a `.getWidth()` method.
if (container.getWidth() > 1280) {
return 3
}
return 1
}```
```css
.container {
display: flex;
flex-wrap: wrap;
}.item {
flex-basis: 33.333333%;
box-sizing: border-box;
}@media screen and (max-width: 1280px) {
.item {
flex-basis: 100%;
}
}
```## Gotchas
### Images
`VirtualScroller` measures item heights as soon as they've rendered and later uses those measurements to determine which items should be rendered when the user scrolls. This means that things like `
` having `position: relative` and `padding-bottom: ${100/aspectRatio}%`.`s require special handling to prevent them from changing their size. For example, when rendering a simple `
` first it renders an element with zero width and height and only after the image file header has been parsed does it resize itself to the actual image's width and height. When used inside `VirtualScroller` items such images would result in scroll position "jumping" as the user scrolls. To avoid that, any `
`s rendered inside `VirtualScroller` items must either have their `width` and `height` set explicitly or have their [aspect ratio](https://www.w3schools.com/howto/howto_css_aspect_ratio.asp) set explicitly by making them `position: absolute` and wrapping them in a parent `
### Margin collapse
If any vertical CSS `margin` is set on the list items, then this may lead to page content "jumping" by the value of that margin while scrolling. The reason is that when the top of the list is visible on screen, no `padding-top` gets applied to the list element, and the CSS spec states that having `padding` on an element disables its ["margin collapse"](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Box_Model/Mastering_margin_collapsing), so, while there's no `padding-top` on the list element, its margins do "collapse" with outer margins, but when the first item is no longer visible (and no longer rendered), `padding-top` gets applied to the list element to compensate for the non-rendered items, and that `padding-top` prevents the list's margins from "collapsing" with outer margins. So that results in the page content "jumping" when the first item in the list becomes invisible or becomes visible again. To fix that, don't set any `margin-top` on the first item of the list, and don't set any `margin-bottom` on the last item of the list. An example of fixing `margin` for the first and the last items of the list:
```css
/* This margin is supposed to "collapse" with the outer ones
but requires a fix below to work correctly with `VirtualScroller`. */
.list-item {
margin: 10px;
}
/* Fixes margin "collapse" for the first item. */
.list-item:first-child {
margin-top: 0;
}
/* Fixes margin "collapse" for the last item. */
.list-item:last-child {
margin-top: 0;
}
```### Styling `:first-child` and `:last-child`
When styling the first and the last items of the list via `:first-child` and `:last-child`, one should also check that such styles don't change the item's height, which means that one should not add any `border` or `padding` styles to `:first-child` and `:last-child`, otherwise the list items will jump by that extra height during scrolling.
An example of a `:first-child`/`:last-child` style that will not work correctly with `VirtualScroller`:
```css
.list-item {
border-bottom: 1px solid black;
}
.list-item:first-child {
border-top: 1px solid black;
}
```### Resize
When the container width changes, all items' heights must be recalculated because:
* If item elements render multi-line text, the lines count might've changed because there's more or less width available now.
* Some CSS [`@media()`](https://developer.mozilla.org/en-US/docs/Web/CSS/Media_Queries) rules might have been added or removed, affecting item layout.
If the list currently shows items starting from the `N`-th one, then all `N - 1` previous items have to be remeasured. But they can't be remeasured until they're rendered again, so `VirtualScroller` temporarily uses their old heights until those items get re-measured after they become visible again as the user scrolls up.
When such upper items get rendered and re-measured, the scroll position is automatically corrected to avoid ["content jumping"](https://css-tricks.com/content-jumping-avoid/).
I found a single edge case when the automatic correction of scroll position doesn't seem to work.
#####
(was reproduced in Chrome web browser on a desktop)
When the user scrolls up past the "prerender margin", which equals to the screen height by default, the list content does "jump" because the web browser doesn't want to apply the scroll position correction while scrolling for some weird reason. Looks like a bug in the web browser.
```
[virtual-scroller] The user is scrolling: perform a re-layout when they stop scrolling
Current scroll position: 7989
[virtual-scroller] The user is scrolling: perform a re-layout when they stop scrolling
Current scroll position: 7972
[virtual-scroller] The user is scrolling: perform a re-layout when they stop scrolling
Current scroll position: 7957
[virtual-scroller] The user has scrolled far enough: perform a re-layout
[virtual-scroller] ~ Update Layout (on scroll) ~
...
[virtual-scroller] ~ Rendered ~
[virtual-scroller] State ...
[virtual-scroller] ~ Measure item heights ~
[virtual-scroller] Item index 27 height 232
[virtual-scroller] Item index 28 height 178
[virtual-scroller] ~ Clean up "before resize" item heights and correct scroll position ~
[virtual-scroller] For item indexes from 27 to 28 — drop "before resize" heights [340, 259]
[virtual-scroller] Correct scroll position by -189
Scroll to position: 7768
[virtual-scroller] Set state ...
[virtual-scroller] ~ Rendered ~
[virtual-scroller] State ...
Current scroll position: 7944
[virtual-scroller] The user is scrolling: perform a re-layout when they stop scrolling
Current scroll position: 7933
[virtual-scroller] The user is scrolling: perform a re-layout when they stop scrolling
Current scroll position: 7924
[virtual-scroller] The user is scrolling: perform a re-layout when they stop scrolling
...
``````js
var listener = () => {
console.log('Current scroll position:', window.pageYOffset)
}
document.addEventListener('scroll', listener)
var unlisten = () => document.removeEventListener('scroll', listener)// Also add `console.log('Scroll to position:', scrollY)` in
// `scrollToY()` method in `./source/DOM/ScrollableContainer.js`.
```Also, pressing the "Home" key wouldn't scroll up past the "prerender margin", which is equal to the screen height by default. The reason is the same: applying scroll position correction while the "Home" key is pressed cancels the effect of pressing the "Home" key.
A hypothetical workaround for this edge case bug could be rewriting the scroll position automatic correction code to postpone scroll position correction until the user stops scrolling, and instead change `margin-bottom` of some "spacer" element at the top of the list (or maybe even before the list). When the user stops scrolling, the scroll position would get corrected by the value of `margin-bottom` of that "spacer" element, after which the `margin-bottom` value on that "spacer" element would be reset. But this type of a workaround would only work in a DOM environment because it requires the support of "negative" margin.
For now, I don't see it as a bug that would be worth fixing. The user could just refresh the page, or not scroll up at all because they've already seen that content.
#####
The "before resize" layout parameters snapshot is stored in `VirtualScroller` state in `beforeResize` object:
* `itemHeights: number[]`
* `verticalSpacing: number`
* `columnsCount: number`### ``
Due to the [inherent limitations](https://gitlab.com/catamphetamine/virtual-scroller/-/issues/1) of the `` HTML tag, when a `` is used as a container for the list items, the `VirtualScroller` code has to use a workaround involving CSS variables, and CSS variables aren't supported in Internet Explorer, so using a `` as a list items container won't work in Internet Explorer: in such case, `VirtualScroller` renders in "bypass" mode (render all items). In all browsers other than Internet Explorer it works as usual.
### Search, focus management.
Due to offscreen list items not being rendered native browser features like "Find on page", moving focus through items via `Tab` key, screen reader announcement and such won't work. A workaround for "search on page" is adding a custom "🔍 Search" input field that would filter items by their content and then call `VirtualScroller.setItems()`.
### "Item index N height changed unexpectedly" warning on page load in dev mode.
`VirtualScroller` assumes there'd be no "unexpected" (unannounced) changes in items' heights. If an item's height changes for whatever reason, a developer must announce it immediately by calling `.onItemHeightDidChange(i)` instance method.
There might still be cases outside of a developer's control when items' heights do change "unexpectedly". One such case is when running an application in "development" mode and the CSS styles or custom fonts haven't loaded yet, resulting in different item height measurements "before" and "after" the page has fully loaded.
To filter out such "false" warnings, one could temporarily override
console.warn
function in development mode.######
```js
const PAGE_LOAD_TIMEOUT = 1000let consoleWarnHasBeenInstrumented = false
export default function suppressVirtualScrollerDevModePageLoadWarnings() {
if (consoleWarnHasBeenInstrumented) {
return
}
// `virtual-scroller` might produce false warnings about items changing their height unexpectedly.
// https://gitlab.com/catamphetamine/virtual-scroller/#item-index-n-height-changed-unexpectedly-warning-on-page-load-in-dev-mode
// That might be the case because Webpack hasn't yet loaded the styles by the time `virtual-scroller`
// performs its initial items measurement.
// To clear the console from such false warnings, a "page load timeout" is introduced in development mode.
if (process.env.NODE_ENV !== 'production') {
consoleWarnHasBeenInstrumented = true
const originalConsoleWarn = console.warn
const startedAt = Date.now()
let muteVirtualScrollerUnexpectedHeightChangeWarnings = true
console.warn = (...args) => {
if (muteVirtualScrollerUnexpectedHeightChangeWarnings) {
if (Date.now() - startedAt < PAGE_LOAD_TIMEOUT) {
if (args[0] === '[virtual-scroller]' && args[1] === 'Item index' && args[3] === 'height changed unexpectedly: it was') {
// Mute the warning.
console.log('(muted `virtual-scroller` warning because the page hasn\'t loaded yet)')
return
}
} else {
muteVirtualScrollerUnexpectedHeightChangeWarnings = false
}
}
return originalConsoleWarn(...args)
}
}
}
```### If only the first item is rendered on page load in dev mode.
See the description of this very rare dev mode bug.
#####
`VirtualScroller` calculates the shown item indexes when its `.onMount()` method is called, but if the page styles are applied after `VirtualScroller` is mounted (for example, if styles are applied via javascript, like Webpack does it in dev mode with its `style-loader`) then the list might not render correctly and will only show the first item. The reason for that is because calling `.getBoundingClientRect()` on the list container DOM element on mount returns "incorrect" `top` position because the styles haven't been applied yet, and so `VirtualScroller` thinks it's offscreen.
For example, consider a page:
```html
...
...
```The sidebar is styled as `position: fixed`, but until the page styles have been applied it's gonna be a regular `
` meaning that `` will be rendered below the sidebar causing it to be offscreen and so the list will only render the first item. Then, the page styles are loaded and applied and the sidebar is now `position: fixed` so `` is now rendered at the top of the page but `VirtualScroller` has already been rendered and it won't re-render until the user scrolls or the window is resized.This type of a bug doesn't occur in production, but it can appear in development mode when using Webpack. The workaround `VirtualScroller` implements for such cases is calling `.getBoundingClientRect()` on the list container DOM element periodically (every second) to check if the `top` coordinate has changed as a result of CSS being applied: if it has then it recalculates the shown item indexes and re-renders.
## Debug
Set `window.VirtualScrollerDebug` to `true` to output debug messages to `console`.
## Rendering Engine
(advanced)
`VirtualScroller` is written in such a way that it supports any type of a rendering engine, not just DOM. For example, it could support something like React Native or ``: for that, someone would have to write custom versions of [`Screen.js`](https://gitlab.com/catamphetamine/virtual-scroller/-/blob/master/source/DOM/Screen.js) and [`ScrollableContainer.js`](https://gitlab.com/catamphetamine/virtual-scroller/-/blob/master/source/DOM/ScrollableContainer.js), and then instruct `VirtualScroller` to use those instead of the default ones by passing custom `engine` object when constructing a `VirtualScroller` instance:
```js
import VirtualScroller from 'virtual-scroller'import Container from './Container'
import ScrollableContainer from './ScrollableContainer'new VirtualScroller(getItemsContainerElement, items, {
getScrollableContainer,
engine: {
createItemsContainer(getItemsContainerElement) {
return new Container(getItemsContainerElement)
},
createScrollableContainer(getScrollableContainer, getItemsContainerElement) {
return new ScrollableContainer(getScrollableContainer, getItemsContainerElement)
}
},
...
})
````getItemsContainerElement()` function would simply return a list "element", whatever that could mean. The concept of an "element" is "something, that can be rendered", so it could be anything, not just a DOM Element. Any operations with "elements" are done either in `Container.js` or in `ScrollableContainer.js`: `Container.js` defines the operations that could be applied to the list "container", or its items, such as getting its height or getting an items' height, and `ScrollableContainer.js` defines the operations that could be applied to a "scrollable container", such as getting its dimensions, listening for "resize" and "scroll" events, controlling scroll position, etc.
## CDN
One can use any npm CDN service, e.g. [unpkg.com](https://unpkg.com) or [jsdelivr.net](https://jsdelivr.net)
```html
new VirtualScroller(...)
new VirtualScroller(...)
<VirtualScroller .../>
```
## TypeScript
This library comes with TypeScript "typings". If you happen to find any bugs in those, create an issue.
## Possible enhancements
### Alternative approach in DOM rendering
This library's `DOM` and `React` component implementations use `padding-top` and `padding-bottom` on the items container to emulate the items that're not currently visible. In DOM environment, this approach comes with a slight drawback: the web browser has to perform a "reflow" every time shown item indexes change as a result of the user scrolling the page.
Twitter seems to use a slightly different approach: they set `position: relative` and `min-height: ` on the items container, and then `position: absolute`, `width: 100%` and `transform: translateY()` on every items. Since `transform`s are only applied at the "compositing" stage of a web browser's rendering cycle, there's no need to recalculate anything, and so scrolling the page comes without any possible performance penalties at all.
My thoughts on moving from
padding
s totransform
s######
I've fantasised a bit about moving to `transforms` in this library's `DOM` and `React` component implementations, and it seems to involve a bit more than it initially seems:
* Item heights aren't known before the items have been rendered, so it'll have to re-render twice rather than once as the user scrolls: first time to measure the newly-shown items' heights and second time to apply the calculated Y positions of those items.
* A bit more complexity is added when one recalls that this library supports multi-column layout: now not only `y` positions but also `x` positions of every item would have to be calculated, and not only vertical spacing but also horizontal spacing between the items in a row.
* The `state` would have to include a new property — `itemPositions` — that would include an `x` and `y` position for every item.
* Using `x`/`y` positions for every item would mean that the `x`/`y` position of every item would no longer be dynamically calculated by a web browser (in `auto` mode) and instead would have to be pre-calculated by the library meaning that everything would have to be constantly re-calculated and re-rendered as the user resizes the window, not just on window resize end like it currently does. For example, if the user starts shrinking window width, the items' heights would start increasing due to content overflow, which, without constant re-calculation and re-rendering, would result in items being rendered on top of each other. So the fix for that would be re-calculating and re-rendering stuff immediately on every window `resize` event as the user drags the handle rather than waiting for the user to let go of that handle and stop resizing the window, which would obviously come with some performance penalties but maybe a modern device can handle such things without breaking a sweat.
The points listed above aren't something difficult to implement, it's just that I don't want to do it unless there're any real observed performance issues related to the "reflows" during scrolling. "If it works, no need to change it".
## Tests
This component comes with about 80% code coverage (for the core `VirtualScroller`).
To run tests:
```
npm test
```To generate a code coverage report:
```
npm run test-coverage
```The code coverage report can be viewed by opening `./coverage/lcov-report/index.html`.
The `[email protected]` [work](https://github.com/handlebars-lang/handlebars.js/issues/1646#issuecomment-578306544)[around](https://github.com/facebook/jest/issues/9396#issuecomment-573328488) in `devDependencies` is for the test coverage to not produce empty reports:
```
Handlebars: Access has been denied to resolve the property "statements" because it is not an "own property" of its parent.
You can add a runtime option to disable the check or this warning:
See https://handlebarsjs.com/api-reference/runtime-options.html#options-to-control-prototype-access for details
```## GitHub
On March 9th, 2020, GitHub, Inc. silently [banned](https://medium.com/@catamphetamine/how-github-blocked-me-and-all-my-libraries-c32c61f061d3) my account (erasing all my repos, issues and comments) without any notice or explanation. Because of that, all source codes had to be promptly moved to GitLab. The [GitHub repo](https://github.com/catamphetamine/virtual-scroller) is now only used as a backup (you can star the repo there too), and the primary repo is now the [GitLab one](https://gitlab.com/catamphetamine/virtual-scroller). Issues can be reported in any repo.
## License
[MIT](LICENSE)