From 7b3d4b565c12afe674a7f567262c8d50325264cb Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Sun, 17 Jun 2018 10:03:37 -0700 Subject: [PATCH 01/37] Initial DynamicSizeList implementation. Only partially functional. --- src/DynamicSizeList.js | 369 ++++++++++++++++++ src/createListComponent.js | 72 ++-- src/index.js | 5 +- website/package.json | 3 +- website/src/App.js | 16 +- website/src/code/DynamicSizeListHorizontal.js | 17 + website/src/code/DynamicSizeListVertical.js | 16 + website/src/components/CodeSandboxLink.js | 3 +- .../src/routes/examples/DynamicSizeList.js | 123 ++++++ website/src/routes/examples/shared.module.css | 15 + website/yarn.lock | 10 +- 11 files changed, 607 insertions(+), 42 deletions(-) create mode 100644 src/DynamicSizeList.js create mode 100644 website/src/code/DynamicSizeListHorizontal.js create mode 100644 website/src/code/DynamicSizeListVertical.js create mode 100644 website/src/routes/examples/DynamicSizeList.js diff --git a/src/DynamicSizeList.js b/src/DynamicSizeList.js new file mode 100644 index 00000000..cf47b78b --- /dev/null +++ b/src/DynamicSizeList.js @@ -0,0 +1,369 @@ +// @flow + +import React, { createElement, Component } from 'react'; +import { findDOMNode } from 'react-dom'; + +import createListComponent, { defaultItemKey } from './createListComponent'; + +import type { Direction, Props, ScrollToAlign } from './createListComponent'; + +const DEFAULT_ESTIMATED_ITEM_SIZE = 50; + +type DynanmicProps = {| + estimatedItemSize: number, + ...Props, +|}; + +type ItemMetadata = {| + offset: number, + size: number, +|}; +type InstanceProps = {| + estimatedItemSize: number, + itemMetadataMap: { [index: number]: ItemMetadata }, + lastMeasuredIndex: number, + totalMeasuredSize: number, +|}; + +const getItemMetadata = ( + props: Props, + index: number, + instanceProps: InstanceProps +): ItemMetadata => { + const { estimatedItemSize, itemMetadataMap } = instanceProps; + + let itemMetadata = itemMetadataMap[index]; + + // If the specified item has not yet been measured, + // Just return an estimated size for now. + if (itemMetadata === undefined) { + itemMetadata = itemMetadataMap[index] = { + offset: 0, + size: estimatedItemSize, + }; + } + + return itemMetadata; +}; + +const findNearestItemBinarySearch = ( + props: Props, + instanceProps: InstanceProps, + high: number, + low: number, + offset: number +): number => { + while (low <= high) { + const middle = low + Math.floor((high - low) / 2); + const currentOffset = getItemMetadata(props, middle, instanceProps).offset; + + if (currentOffset === offset) { + return middle; + } else if (currentOffset < offset) { + low = middle + 1; + } else if (currentOffset > offset) { + high = middle - 1; + } + } + + if (low > 0) { + return low - 1; + } else { + return 0; + } +}; + +const getEstimatedTotalSize = ( + { itemCount }: Props, + { + itemMetadataMap, + estimatedItemSize, + lastMeasuredIndex, + totalMeasuredSize, + }: InstanceProps +) => + totalMeasuredSize + (itemCount - lastMeasuredIndex - 1) * estimatedItemSize; + +type CellMeasurerProps = {| + direction: Direction, + index: number, + instance: any, + item: React$Element, + itemMetadata: ItemMetadata, +|}; +class CellMeasurer extends Component { + componentDidMount() { + this._measureItem(); + } + + componentDidUpdate() { + // TODO Check if item needs to be remeasured. + } + + render() { + return this.props.item; + } + + _measureItem() { + const { direction, index, instance, itemMetadata } = this.props; + + const node = findDOMNode(this); + + if ( + node && + node.ownerDocument && + node.ownerDocument.defaultView && + node instanceof node.ownerDocument.defaultView.HTMLElement + ) { + const size = + direction === 'horizontal' + ? Math.ceil(node.offsetWidth) + : Math.ceil(node.offsetHeight); + + if (itemMetadata.size !== size) { + instance._handleNewMeasurements(index, size); + } + } + } +} + +const DynamicSizeList = createListComponent({ + getItemOffset: ( + props: Props, + index: number, + instanceProps: InstanceProps + ): number => getItemMetadata(props, index, instanceProps).offset, + + getItemSize: ( + props: Props, + index: number, + instanceProps: InstanceProps + ): number => { + if (index > instanceProps.lastMeasuredIndex) { + return instanceProps.estimatedItemSize; + } else { + return instanceProps.itemMetadataMap[index].size; + } + }, + + getEstimatedTotalSize, + + getOffsetForIndexAndAlignment: ( + props: Props, + index: number, + align: ScrollToAlign, + scrollOffset: number, + instanceProps: InstanceProps + ): number => { + const { direction, height, width } = props; + + const size = (((direction === 'horizontal' ? width : height): any): number); + const itemMetadata = getItemMetadata(props, index, instanceProps); + + // Get estimated total size after ItemMetadata is computed, + // To ensure it reflects actual measurements instead of just estimates. + const estimatedTotalSize = getEstimatedTotalSize(props, instanceProps); + + const maxOffset = Math.min(estimatedTotalSize - size, itemMetadata.offset); + const minOffset = Math.max( + 0, + itemMetadata.offset - size + itemMetadata.size + ); + + switch (align) { + case 'start': + return maxOffset; + case 'end': + return minOffset; + case 'center': + return Math.round(minOffset + (maxOffset - minOffset) / 2); + case 'auto': + default: + if (scrollOffset >= minOffset && scrollOffset <= maxOffset) { + return scrollOffset; + } else if (scrollOffset - minOffset < maxOffset - scrollOffset) { + return minOffset; + } else { + return maxOffset; + } + } + }, + + getStartIndexForOffset: ( + props: Props, + offset: number, + instanceProps: InstanceProps + ): number => { + const { lastMeasuredIndex, totalMeasuredSize } = instanceProps; + + // If we've already positioned and measured past this point, + // Use a binary search to find the closets cell. + if (offset <= totalMeasuredSize) { + return findNearestItemBinarySearch( + props, + instanceProps, + lastMeasuredIndex, + 0, + offset + ); + } + + // Otherwise render a new batch of items starting from where we left off. + return lastMeasuredIndex + 1; + }, + + getStopIndexForStartIndex: ( + props: Props, + startIndex: number, + scrollOffset: number, + instanceProps: InstanceProps + ): number => { + const { direction, height, itemCount, width } = props; + + const size = (((direction === 'horizontal' ? width : height): any): number); + const itemMetadata = getItemMetadata(props, startIndex, instanceProps); + const maxOffset = scrollOffset + size; + + let offset = itemMetadata.offset + itemMetadata.size; + let stopIndex = startIndex; + + while (stopIndex < itemCount - 1 && offset < maxOffset) { + stopIndex++; + offset += getItemMetadata(props, stopIndex, instanceProps).size; + } + + return stopIndex; + }, + + initInstanceProps(props: Props, instance: any): InstanceProps { + const { estimatedItemSize } = ((props: any): DynanmicProps); + + const instanceProps = { + estimatedItemSize: estimatedItemSize || DEFAULT_ESTIMATED_ITEM_SIZE, + itemMetadataMap: {}, + lastMeasuredIndex: -1, + totalMeasuredSize: 0, + }; + + let hasNewMeasurements: boolean = false; + + // List calls this method automatically after "mount" and "update". + instance._commitHook = () => { + if (hasNewMeasurements) { + hasNewMeasurements = false; + + // We could potentially optimize further by only evicting styles after this index, + // But since styles are only cached while scrolling is in progress- + // It seems an unnecessary optimization. + // It's unlikely that resetAfterIndex() will be called while a user is scrolling. + instance._itemStyleCache = {}; + instance.forceUpdate(); + } + }; + + // This function may be called out of order! + // It is not safe to reposition items here. + // Be careful when comparing index and lastMeasuredIndex. + instance._handleNewMeasurements = (index: number, size: number) => { + const { itemMetadataMap, lastMeasuredIndex } = instanceProps; + + const itemMetadata = itemMetadataMap[index]; + + // Adjust item position in case new measurements were recorded. + // This method will always be called in order (lowest to highest index), + // So it is safe to adjust positions here. + if (index > 0) { + const prevItemMetadata = itemMetadataMap[index - 1]; + itemMetadata.offset = prevItemMetadata.offset + prevItemMetadata.size; + } + + if (index <= lastMeasuredIndex) { + if (itemMetadata.size === size) { + return; + } + + instanceProps.totalMeasuredSize += size - itemMetadata.size; + } else { + instanceProps.lastMeasuredIndex = index; + instanceProps.totalMeasuredSize += size; + } + + itemMetadata.size = size; + + hasNewMeasurements = true; + }; + + // Override the item-rendering process to wrap items with CellMeasurer. + // This keep the external API simpler. + instance._renderItems = () => { + const { + children, + direction, + height, + itemCount, + itemKey = defaultItemKey, + useIsScrolling, + width, + } = instance.props; + const { isScrolling } = instance.state; + + const [startIndex, stopIndex] = instance._getRangeToRender(); + + const items = []; + if (itemCount > 0) { + for (let index = startIndex; index <= stopIndex; index++) { + let style = instance._getItemStyle(index); + + if (index > instanceProps.lastMeasuredIndex) { + // Strip hard-coded dimensions from the inline style. + // These would interfere with the item laying itself out anyway. + // Constrain the item to fill either the width or height of the list, + // Depending on the direction being windowed. + style = { + ...style, + height: direction === 'horizontal' ? height : undefined, + width: direction === 'vertical' ? width : undefined, + }; + } + + const item = createElement(children, { + index, + isScrolling: useIsScrolling ? isScrolling : undefined, + style, + }); + + // Always wrap children in a CellMeasurer. + // We could only wrap them for the initial render, + // But we also want to automatically detect resizes. + items.push( + + ); + } + } + return items; + }; + + // TODO Add reset methods: + // resetItem(index) + // resetAllItems() + + return instanceProps; + }, + + validateProps: ({ itemSize }: Props): void => { + if (process.env.NODE_ENV !== 'production') { + if (itemSize !== undefined) { + throw Error('An unexpected "itemSize" prop has been provided.'); + } + } + }, +}); + +export default DynamicSizeList; diff --git a/src/createListComponent.js b/src/createListComponent.js index a0294e73..a5f755f2 100644 --- a/src/createListComponent.js +++ b/src/createListComponent.js @@ -6,10 +6,10 @@ import React, { createElement, PureComponent } from 'react'; export type ScrollToAlign = 'auto' | 'center' | 'start' | 'end'; type itemSize = number | ((index: number) => number); -type Direction = 'horizontal' | 'vertical'; +export type Direction = 'horizontal' | 'vertical'; type ItemKeyGetter = (index: number) => any; -type RenderComponentProps = {| +export type RenderComponentProps = {| index: number, isScrolling?: boolean, style: Object, @@ -86,7 +86,7 @@ type ValidateProps = (props: Props) => void; const IS_SCROLLING_DEBOUNCE_INTERVAL = 150; -const defaultItemKey: ItemKeyGetter = index => index; +export const defaultItemKey: ItemKeyGetter = index => index; export default function createListComponent({ getItemOffset, @@ -108,7 +108,6 @@ export default function createListComponent({ validateProps: ValidateProps, |}) { return class List extends PureComponent { - _instanceProps: any = initInstanceProps(this.props, this); _itemStyleCache: { [index: number]: Object } = {}; _resetIsScrollingTimeoutId: TimeoutID | null = null; _scrollingContainer: ?HTMLDivElement; @@ -180,6 +179,7 @@ export default function createListComponent({ } this._callPropsCallbacks(); + this._commitHook(); } componentDidUpdate() { @@ -197,6 +197,7 @@ export default function createListComponent({ } this._callPropsCallbacks(); + this._commitHook(); } componentWillUnmount() { @@ -206,17 +207,7 @@ export default function createListComponent({ } render() { - const { - children, - className, - direction, - height, - itemCount, - itemKey = defaultItemKey, - style, - useIsScrolling, - width, - } = this.props; + const { className, direction, height, style, width } = this.props; const { isScrolling } = this.state; const onScroll = @@ -224,21 +215,7 @@ export default function createListComponent({ ? this._onScrollVertical : this._onScrollHorizontal; - const [startIndex, stopIndex] = this._getRangeToRender(); - - const items = []; - if (itemCount > 0) { - for (let index = startIndex; index <= stopIndex; index++) { - items.push( - createElement(children, { - key: itemKey(index), - index, - isScrolling: useIsScrolling ? isScrolling : undefined, - style: this._getItemStyle(index), - }) - ); - } - } + const items = this._renderItems(); // Read this value AFTER items have been created, // So their actual sizes (if variable) are taken into consideration. @@ -348,6 +325,10 @@ export default function createListComponent({ } } + // This method is called after mount and update. + // List implementations can override this method to be notified. + _commitHook() {} + // Lazily create and cache item styles while scrolling, // So that pure component sCU will prevent re-renders. // We maintain this cache, and pass a style prop rather than index, @@ -415,6 +396,33 @@ export default function createListComponent({ ]; } + _renderItems() { + const { + children, + itemCount, + itemKey = defaultItemKey, + useIsScrolling, + } = this.props; + const { isScrolling } = this.state; + + const [startIndex, stopIndex] = this._getRangeToRender(); + + const items = []; + if (itemCount > 0) { + for (let index = startIndex; index <= stopIndex; index++) { + items.push( + createElement(children, { + key: itemKey(index), + index, + isScrolling: useIsScrolling ? isScrolling : undefined, + style: this._getItemStyle(index), + }) + ); + } + } + return items; + } + _onScrollHorizontal = (event: ScrollEvent): void => { const { scrollLeft } = event.currentTarget; this.setState(prevState => { @@ -479,6 +487,10 @@ export default function createListComponent({ this._itemStyleCache = {}; }); }; + + // Intentionally placed after all other instance properties have been initialized, + // So that DynamicSizeList can override the render behavior. + _instanceProps: any = initInstanceProps(this.props, this); }; } diff --git a/src/index.js b/src/index.js index a7882a1a..67cfa8c2 100644 --- a/src/index.js +++ b/src/index.js @@ -1,6 +1,7 @@ // @flow -export { default as VariableSizeGrid } from './VariableSizeGrid'; -export { default as VariableSizeList } from './VariableSizeList'; +export { default as DynamicSizeList } from './DynamicSizeList'; export { default as FixedSizeGrid } from './FixedSizeGrid'; export { default as FixedSizeList } from './FixedSizeList'; +export { default as VariableSizeGrid } from './VariableSizeGrid'; +export { default as VariableSizeList } from './VariableSizeList'; diff --git a/website/package.json b/website/package.json index d51d8f19..be839426 100644 --- a/website/package.json +++ b/website/package.json @@ -25,7 +25,7 @@ "babel-preset-react-app": "4.0.0-next.66cc7a90", "case-sensitive-paths-webpack-plugin": "2.1.1", "chalk": "1.1.3", - "classnames": "^2.2.5", + "classnames": "^2.2.6", "codemirror": "^5.37.0", "css-loader": "0.28.7", "dotenv": "4.0.0", @@ -48,6 +48,7 @@ "promise": "8.0.1", "prop-types": "^15.6.1", "raf": "3.4.0", + "random-words": "^1.1.0", "react": "^16.4.1", "react-codemirror2": "^5.0.1", "react-dev-utils": "^6.0.0-next.66cc7a90", diff --git a/website/src/App.js b/website/src/App.js index 48f5a3d2..47f7b95a 100644 --- a/website/src/App.js +++ b/website/src/App.js @@ -4,16 +4,17 @@ import { Nav } from './components/Nav'; import { SubMenu } from './components/SubMenu'; // Routes -import VariableSizeGridApi from './routes/api/VariableSizeGrid'; -import VariableSizeGridExample from './routes/examples/VariableSizeGrid'; -import VariableSizeListApi from './routes/api/VariableSizeList'; -import VariableSizeListExample from './routes/examples/VariableSizeList'; +import DynamicSizeListExample from './routes/examples/DynamicSizeList'; import FixedSizeGridApi from './routes/api/FixedSizeGrid'; -import FixedSizeListApi from './routes/api/FixedSizeList'; import FixedSizeGridExample from './routes/examples/FixedSizeGrid'; +import FixedSizeListApi from './routes/api/FixedSizeList'; import FixedSizeListExample from './routes/examples/FixedSizeList'; import ListWithScrollingIndicatorExample from './routes/examples/ListWithScrollingIndicator'; import ScrollToItemExample from './routes/examples/ScrollToItem'; +import VariableSizeGridApi from './routes/api/VariableSizeGrid'; +import VariableSizeGridExample from './routes/examples/VariableSizeGrid'; +import VariableSizeListApi from './routes/api/VariableSizeList'; +import VariableSizeListExample from './routes/examples/VariableSizeList'; import styles from './App.module.css'; @@ -62,6 +63,11 @@ const EXAMPLE_ROUTES = [ title: 'Variable Size List', component: VariableSizeListExample, }, + { + path: '/examples/list/dynamic-size', + title: 'Dynamic Size List', + component: DynamicSizeListExample, + }, { path: '/examples/grid/fixed-size', title: 'Fixed Size Grid', diff --git a/website/src/code/DynamicSizeListHorizontal.js b/website/src/code/DynamicSizeListHorizontal.js new file mode 100644 index 00000000..ee36d3be --- /dev/null +++ b/website/src/code/DynamicSizeListHorizontal.js @@ -0,0 +1,17 @@ +import { DynamicSizeList as List } from 'react-window'; + +// This example assumes 'items' is an array of strings. +// Your application may render a more complex list of items. + + + {({ index, style }) => ( +
+ {items[index]} +
+ )} +
\ No newline at end of file diff --git a/website/src/code/DynamicSizeListVertical.js b/website/src/code/DynamicSizeListVertical.js new file mode 100644 index 00000000..e29127c7 --- /dev/null +++ b/website/src/code/DynamicSizeListVertical.js @@ -0,0 +1,16 @@ +import { DynamicSizeList as List } from 'react-window'; + +// This example assumes 'items' is an array of strings. +// Your application may render a more complex list of items. + + + {({ index, style }) => ( +
+ {items[index]} +
+ )} +
\ No newline at end of file diff --git a/website/src/components/CodeSandboxLink.js b/website/src/components/CodeSandboxLink.js index 1ef25dd5..51e33a79 100644 --- a/website/src/components/CodeSandboxLink.js +++ b/website/src/components/CodeSandboxLink.js @@ -1,3 +1,4 @@ +import cn from 'classnames'; import React from 'react'; import styles from './CodeSandboxLink.module.css'; @@ -9,7 +10,7 @@ const CodeSandboxLink = ({ className, tree = 'master', sandbox }) => { return ( ({ + paragraph: randomWords({ min: 3, max: 30 }).join(', '), + name: names[Math.floor(Math.random() * names.length)], +})); + +const itemRowRenderer = ({ index, style }) => ( +
+ {index}: {items[index].paragraph} +
+); + +const itemColumnRenderer = ({ index, style }) => ( +
+ {index}: {items[index].name} +
+); + +export default class ScrollToItem extends Component { + horizontalListRef = React.createRef(); + verticalListRef = React.createRef(); + + render() { + return ( +
+

Dynamic Size List

+
+ + + + + {itemRowRenderer} + + +
+ +
+
+
+ + + {itemColumnRenderer} + + +
+ +
+
+
+ ); + } + + scrollTo15000Pixels = () => this.verticalListRef.current.scrollTo(15000); + scrollToRow200Auto = () => this.verticalListRef.current.scrollToItem(200); +} diff --git a/website/src/routes/examples/shared.module.css b/website/src/routes/examples/shared.module.css index b1011d43..d3e62e91 100644 --- a/website/src/routes/examples/shared.module.css +++ b/website/src/routes/examples/shared.module.css @@ -69,11 +69,26 @@ justify-content: center; } +.DynamicListItemEven, +.DynamicListItemOdd { + box-sizing: border-box; + display: flex; + justify-content: flex-start; + padding: 0.5rem; +} + +.DynamicListItemEven, .GridItemEven, .ListItemEven { background-color: #f8f8f0; } +.ListColumn { + white-space: pre; + word-wrap: nowrap; + align-items: center; +} + @media (max-width: 37.5rem) { .ExampleWrapper { padding: 0; diff --git a/website/yarn.lock b/website/yarn.lock index 182b359a..0f4fdaf5 100644 --- a/website/yarn.lock +++ b/website/yarn.lock @@ -2379,9 +2379,9 @@ class-utils@^0.3.5: isobject "^3.0.0" static-extend "^0.1.1" -classnames@^2.2.5: - version "2.2.5" - resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.2.5.tgz#fb3801d453467649ef3603c7d61a02bd129bde6d" +classnames@^2.2.6: + version "2.2.6" + resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.2.6.tgz#43935bffdd291f326dad0a205309b38d00f650ce" clean-css@4.1.x: version "4.1.11" @@ -7466,6 +7466,10 @@ raf@3.4.0: dependencies: performance-now "^2.1.0" +random-words@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/random-words/-/random-words-1.1.0.tgz#d42f9775d14ef5c58fd255968158303e1daa0a22" + randomatic@^1.1.3: version "1.1.7" resolved "https://registry.yarnpkg.com/randomatic/-/randomatic-1.1.7.tgz#c7abe9cc8b87c0baa876b19fde83fd464797e38c" From b0d2ce16c43beb7d31cfd2862335a1d25de08033 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Sat, 7 Jul 2018 19:41:05 -0700 Subject: [PATCH 02/37] Mocked randomWords dep --- website/src/routes/examples/DynamicSizeList.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/website/src/routes/examples/DynamicSizeList.js b/website/src/routes/examples/DynamicSizeList.js index 8278c412..04d14b08 100644 --- a/website/src/routes/examples/DynamicSizeList.js +++ b/website/src/routes/examples/DynamicSizeList.js @@ -1,7 +1,7 @@ import cn from 'classnames'; import React, { Component } from 'react'; import { DynamicSizeList } from 'react-window'; -import randomWords from 'random-words'; +// import randomWords from 'random-words'; import CodeBlock from '../../components/CodeBlock'; import ProfiledExample from '../../components/ProfiledExample'; @@ -10,6 +10,12 @@ import CODE_VERTICAL from '../../code/DynamicSizeListVertical.js'; import styles from './shared.module.css'; +const words = ['cat', 'kitten', 'feline', 'kitty']; +const randomWords = ({ min, max }) => { + const target = min + Math.round(Math.random() * (max - min)); + return new Array(target).fill(true).map(() => words[Math.floor(Math.random() * words.length)]); +} + const names = [ 'Stasia', 'Shaunta', From df974880eeebbbbf7ee90fd42d63fb6df4e1cf27 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Wed, 10 Oct 2018 18:05:46 +0800 Subject: [PATCH 03/37] Iterated on DynamicSizeList --- src/CellMeasurer.js | 78 +++++++ src/DynamicSizeList.js | 198 ++++++++++-------- src/createListComponent.js | 3 - website/package.json | 1 + website/src/code/DynamicSizeListVertical.js | 23 +- website/src/components/ProfiledExample.js | 7 +- .../src/routes/examples/DynamicSizeList.js | 154 ++++++++------ website/src/routes/examples/shared.module.css | 17 ++ website/yarn.lock | 8 +- 9 files changed, 321 insertions(+), 168 deletions(-) create mode 100644 src/CellMeasurer.js diff --git a/src/CellMeasurer.js b/src/CellMeasurer.js new file mode 100644 index 00000000..55d7d520 --- /dev/null +++ b/src/CellMeasurer.js @@ -0,0 +1,78 @@ +// @flow + +import { Component } from 'react'; +import { findDOMNode } from 'react-dom'; + +import type { Direction } from './createListComponent'; +import type { HandleNewMeasurements } from './DynamicSizeList'; + +declare class ResizeObserver { + constructor(callback: Function): void; + observe(target: HTMLElement): void; + unobserve(target: HTMLElement): void; +} + +type CellMeasurerProps = {| + direction: Direction, + handleNewMeasurements: HandleNewMeasurements, + index: number, + item: React$Element, + size: number, +|}; + +export default class CellMeasurer extends Component { + _node: HTMLElement = (null: any); + _resizeObserver: ResizeObserver = (null: any); + + componentDidMount() { + this._node = ((findDOMNode(this): any): HTMLElement); + + // Force sync measure for the initial mount. + // This is necessary to support the DynamicSizeList layout logic. + this._measureItem(true); + + // Watch for resizes due to changed content, + // Or changes in the size of the parent container. + this._resizeObserver = new ResizeObserver(this._onResize); + this._resizeObserver.observe(this._node); + } + + componentWillUnmount() { + this._resizeObserver.unobserve(this._node); + } + + render() { + return this.props.item; + } + + _measureItem = (isCommitPhase: boolean) => { + const { + direction, + handleNewMeasurements, + index, + size: oldSize, + } = this.props; + + const node = this._node; + + if ( + node && + node.ownerDocument && + node.ownerDocument.defaultView && + node instanceof node.ownerDocument.defaultView.HTMLElement + ) { + const newSize = + direction === 'horizontal' + ? Math.ceil(node.offsetWidth) + : Math.ceil(node.offsetHeight); + + if (oldSize !== newSize) { + handleNewMeasurements(index, newSize, isCommitPhase); + } + } + }; + + _onResize = () => { + this._measureItem(false); + }; +} diff --git a/src/DynamicSizeList.js b/src/DynamicSizeList.js index 3cc0347f..baf59b12 100644 --- a/src/DynamicSizeList.js +++ b/src/DynamicSizeList.js @@ -1,53 +1,84 @@ // @flow -import React, { createElement, Component } from 'react'; -import { findDOMNode } from 'react-dom'; +import { createElement } from 'react'; import createListComponent, { defaultItemKey } from './createListComponent'; +import CellMeasurer from './CellMeasurer'; -import type { Direction, Props, ScrollToAlign } from './createListComponent'; +import type { Props, ScrollToAlign } from './createListComponent'; const DEFAULT_ESTIMATED_ITEM_SIZE = 50; type DynanmicProps = {| estimatedItemSize: number, - ...Props, + ...Props, |}; +export type HandleNewMeasurements = ( + index: number, + newSize: number, + isCommitPhase: boolean +) => void; + type ItemMetadata = {| offset: number, size: number, |}; type InstanceProps = {| estimatedItemSize: number, - itemMetadataMap: { [index: number]: ItemMetadata }, + itemOffsetMap: { [index: number]: number }, + itemSizeMap: { [index: number]: number }, lastMeasuredIndex: number, + lastPositionedIndex: number, totalMeasuredSize: number, |}; const getItemMetadata = ( - props: Props, + props: Props, index: number, instanceProps: InstanceProps ): ItemMetadata => { - const { estimatedItemSize, itemMetadataMap } = instanceProps; - - let itemMetadata = itemMetadataMap[index]; + const { + estimatedItemSize, + itemOffsetMap, + itemSizeMap, + lastMeasuredIndex, + lastPositionedIndex, + } = instanceProps; // If the specified item has not yet been measured, // Just return an estimated size for now. - if (itemMetadata === undefined) { - itemMetadata = itemMetadataMap[index] = { + if (index > lastMeasuredIndex) { + return { offset: 0, size: estimatedItemSize, }; } - return itemMetadata; + // Lazily update positions if they are stale. + if (index > lastPositionedIndex) { + if (lastPositionedIndex < 0) { + itemOffsetMap[0] = 0; + } + + for (let i = Math.max(1, lastPositionedIndex); i <= index; i++) { + const prevOffset = itemOffsetMap[i - 1]; + const prevSize = itemSizeMap[i - 1]; + + itemOffsetMap[i] = prevOffset + prevSize; + } + + instanceProps.lastPositionedIndex = index; + } + + let offset = itemOffsetMap[index]; + let size = itemSizeMap[index]; + + return { offset, size }; }; const findNearestItemBinarySearch = ( - props: Props, + props: Props, instanceProps: InstanceProps, high: number, low: number, @@ -74,9 +105,9 @@ const findNearestItemBinarySearch = ( }; const getEstimatedTotalSize = ( - { itemCount }: Props, + { itemCount }: Props, { - itemMetadataMap, + itemSizeMap, estimatedItemSize, lastMeasuredIndex, totalMeasuredSize, @@ -84,72 +115,29 @@ const getEstimatedTotalSize = ( ) => totalMeasuredSize + (itemCount - lastMeasuredIndex - 1) * estimatedItemSize; -type CellMeasurerProps = {| - direction: Direction, - index: number, - instance: any, - item: React$Element, - itemMetadata: ItemMetadata, -|}; -class CellMeasurer extends Component { - componentDidMount() { - this._measureItem(); - } - - componentDidUpdate() { - // TODO Check if item needs to be remeasured. - } - - render() { - return this.props.item; - } - - _measureItem() { - const { direction, index, instance, itemMetadata } = this.props; - - const node = findDOMNode(this); - - if ( - node && - node.ownerDocument && - node.ownerDocument.defaultView && - node instanceof node.ownerDocument.defaultView.HTMLElement - ) { - const size = - direction === 'horizontal' - ? Math.ceil(node.offsetWidth) - : Math.ceil(node.offsetHeight); - - if (itemMetadata.size !== size) { - instance._handleNewMeasurements(index, size); - } - } - } -} - const DynamicSizeList = createListComponent({ getItemOffset: ( - props: Props, + props: Props, index: number, instanceProps: InstanceProps ): number => getItemMetadata(props, index, instanceProps).offset, getItemSize: ( - props: Props, + props: Props, index: number, instanceProps: InstanceProps ): number => { if (index > instanceProps.lastMeasuredIndex) { return instanceProps.estimatedItemSize; } else { - return instanceProps.itemMetadataMap[index].size; + return instanceProps.itemSizeMap[index]; } }, getEstimatedTotalSize, getOffsetForIndexAndAlignment: ( - props: Props, + props: Props, index: number, align: ScrollToAlign, scrollOffset: number, @@ -190,7 +178,7 @@ const DynamicSizeList = createListComponent({ }, getStartIndexForOffset: ( - props: Props, + props: Props, offset: number, instanceProps: InstanceProps ): number => { @@ -213,7 +201,7 @@ const DynamicSizeList = createListComponent({ }, getStopIndexForStartIndex: ( - props: Props, + props: Props, startIndex: number, scrollOffset: number, instanceProps: InstanceProps @@ -235,18 +223,31 @@ const DynamicSizeList = createListComponent({ return stopIndex; }, - initInstanceProps(props: Props, instance: any): InstanceProps { + initInstanceProps(props: Props, instance: any): InstanceProps { const { estimatedItemSize } = ((props: any): DynanmicProps); const instanceProps = { estimatedItemSize: estimatedItemSize || DEFAULT_ESTIMATED_ITEM_SIZE, - itemMetadataMap: {}, + itemOffsetMap: {}, + itemSizeMap: {}, lastMeasuredIndex: -1, + lastPositionedIndex: -1, totalMeasuredSize: 0, }; let hasNewMeasurements: boolean = false; + // TODO Cancel pending debounce on unmount + let debounceForceUpdateID = null; + const debounceForceUpdate = () => { + if (debounceForceUpdateID === null) { + debounceForceUpdateID = setTimeout(() => { + debounceForceUpdateID = null; + instance.forceUpdate(); + }, 1); + } + }; + // List calls this method automatically after "mount" and "update". instance._commitHook = () => { if (hasNewMeasurements) { @@ -264,34 +265,45 @@ const DynamicSizeList = createListComponent({ // This function may be called out of order! // It is not safe to reposition items here. // Be careful when comparing index and lastMeasuredIndex. - instance._handleNewMeasurements = (index: number, size: number) => { - const { itemMetadataMap, lastMeasuredIndex } = instanceProps; + const handleNewMeasurements: HandleNewMeasurements = ( + index: number, + newSize: number, + isCommitPhase: boolean + ) => { + const { + itemSizeMap, + lastMeasuredIndex, + lastPositionedIndex, + } = instanceProps; - const itemMetadata = itemMetadataMap[index]; + const oldSize = itemSizeMap[index]; - // Adjust item position in case new measurements were recorded. - // This method will always be called in order (lowest to highest index), - // So it is safe to adjust positions here. - if (index > 0) { - const prevItemMetadata = itemMetadataMap[index - 1]; - itemMetadata.offset = prevItemMetadata.offset + prevItemMetadata.size; + // Mark offsets after this as stale so that getItemMetadata() will lazily recalculate it. + if (index < lastPositionedIndex) { + instanceProps.lastPositionedIndex = index; } if (index <= lastMeasuredIndex) { - if (itemMetadata.size === size) { + if (oldSize === newSize) { return; } - instanceProps.totalMeasuredSize += size - itemMetadata.size; + // Adjust total size estimate by the delta in size. + instanceProps.totalMeasuredSize += newSize - oldSize; } else { instanceProps.lastMeasuredIndex = index; - instanceProps.totalMeasuredSize += size; + instanceProps.totalMeasuredSize += newSize; } - itemMetadata.size = size; + itemSizeMap[index] = newSize; - hasNewMeasurements = true; + if (isCommitPhase) { + hasNewMeasurements = true; + } else { + debounceForceUpdate(); + } }; + instance._handleNewMeasurements = handleNewMeasurements; // Override the item-rendering process to wrap items with CellMeasurer. // This keep the external API simpler. @@ -311,10 +323,18 @@ const DynamicSizeList = createListComponent({ const items = []; if (itemCount > 0) { + const { lastMeasuredIndex } = instanceProps; + for (let index = startIndex; index <= stopIndex; index++) { let style = instance._getItemStyle(index); - if (index > instanceProps.lastMeasuredIndex) { + const { offset, size } = getItemMetadata( + instance.props, + index, + instanceProps + ); + + if (index > lastMeasuredIndex) { // Strip hard-coded dimensions from the inline style. // These would interfere with the item laying itself out anyway. // Constrain the item to fill either the width or height of the list, @@ -324,6 +344,14 @@ const DynamicSizeList = createListComponent({ height: direction === 'horizontal' ? height : undefined, width: direction === 'vertical' ? width : undefined, }; + } else { + style = { + ...style, + height: direction === 'horizontal' ? '100%' : undefined, + width: direction === 'vertical' ? '100%' : undefined, + left: direction === 'horizontal' ? offset : 0, + top: direction === 'vertical' ? offset : 0, + }; } const item = createElement(children, { @@ -338,11 +366,11 @@ const DynamicSizeList = createListComponent({ items.push( createElement(CellMeasurer, { direction, + handleNewMeasurements: instance._handleNewMeasurements, index, - instance, item, - itemMetadata: instanceProps.itemMetadataMap[index], key: itemKey(index), + size, }) ); } @@ -357,7 +385,9 @@ const DynamicSizeList = createListComponent({ return instanceProps; }, - validateProps: ({ itemSize }: Props): void => { + shouldResetStyleCacheOnItemSizeChange: false, + + validateProps: ({ itemSize }: Props): void => { if (process.env.NODE_ENV !== 'production') { if (itemSize !== undefined) { throw Error('An unexpected "itemSize" prop has been provided.'); diff --git a/src/createListComponent.js b/src/createListComponent.js index 385ab111..8ffff95e 100644 --- a/src/createListComponent.js +++ b/src/createListComponent.js @@ -231,9 +231,6 @@ export default function createListComponent({ height, innerRef, innerTagName, - itemCount, - itemData, - itemKey = defaultItemKey, outerTagName, style, width, diff --git a/website/package.json b/website/package.json index 5bfa5a23..5ef9acb5 100644 --- a/website/package.json +++ b/website/package.json @@ -42,6 +42,7 @@ "fs-extra": "3.0.1", "html-webpack-plugin": "2.29.0", "jest": "20.0.4", + "lorem-ipsum": "^1.0.6", "object-assign": "4.1.1", "postcss-flexbugs-fixes": "3.2.0", "postcss-loader": "2.0.10", diff --git a/website/src/code/DynamicSizeListVertical.js b/website/src/code/DynamicSizeListVertical.js index e29127c7..bb66e00a 100644 --- a/website/src/code/DynamicSizeListVertical.js +++ b/website/src/code/DynamicSizeListVertical.js @@ -2,15 +2,16 @@ import { DynamicSizeList as List } from 'react-window'; // This example assumes 'items' is an array of strings. // Your application may render a more complex list of items. +const Row = ({ index, style }) => ( +
items[index]
+); - - {({ index, style }) => ( -
- {items[index]} -
- )} -
\ No newline at end of file +const Example = () => ( + + {Row} + +); \ No newline at end of file diff --git a/website/src/components/ProfiledExample.js b/website/src/components/ProfiledExample.js index fcf7881f..cf108584 100644 --- a/website/src/components/ProfiledExample.js +++ b/website/src/components/ProfiledExample.js @@ -10,6 +10,7 @@ import styles from './ProfiledExample.module.css'; type Props = {| className?: string, sandbox?: string, + style?: Object, |}; const isProfilingEnabled = window.location.hash.includes('profile=true'); @@ -21,11 +22,11 @@ export default class ProfiledExample extends PureComponent { _totalActualTime = 0; render() { - const { className, children, sandbox } = this.props; + const { className, children, sandbox, style } = this.props; if (isProfilingEnabled) { return ( -
+
{children} @@ -46,7 +47,7 @@ export default class ProfiledExample extends PureComponent { ); } else { return ( -
+
{children}
diff --git a/website/src/routes/examples/DynamicSizeList.js b/website/src/routes/examples/DynamicSizeList.js index 28e8c032..c4aa67ab 100644 --- a/website/src/routes/examples/DynamicSizeList.js +++ b/website/src/routes/examples/DynamicSizeList.js @@ -1,79 +1,93 @@ -import cn from 'classnames'; -import React, { Component } from 'react'; -import { DynamicSizeList } from 'react-window'; -// import randomWords from 'random-words'; +import React, { PureComponent } from 'react'; +import { DynamicSizeList as List } from 'react-window'; +import loremIpsum from 'lorem-ipsum'; import CodeBlock from '../../components/CodeBlock'; import ProfiledExample from '../../components/ProfiledExample'; -import CODE_HORIZONTAL from '../../code/DynamicSizeListHorizontal.js'; +//import CODE_HORIZONTAL from '../../code/DynamicSizeListHorizontal.js'; import CODE_VERTICAL from '../../code/DynamicSizeListVertical.js'; import styles from './shared.module.css'; -const words = ['cat', 'kitten', 'feline', 'kitty']; -const randomWords = ({ min, max }) => { - const target = min + Math.round(Math.random() * (max - min)); - return new Array(target) - .fill(true) - .map(() => words[Math.floor(Math.random() * words.length)]); -}; - -const names = [ - 'Stasia', - 'Shaunta', - 'Lavona', - 'Felica', - 'Glinda', - 'Percy', - 'Irina', - 'Noriko', - 'Evette', - 'Margene', - 'Cordia', - 'Karmen', - 'Kitty', - 'Rima', - 'Dessie', - 'Kory', - 'Oda', - 'Alesia', - 'Loura', - 'Lucius', +var colors = [ + ['#1E88E5', '#90CAF9'], + ['#6D4C41', '#D7CCC8'], + ['#212121', '#BDBDBD'], + ['#388E3C', '#A5D6A7'], + ['#E53935', '#EF9A9A'], + ['#F4511E', '#FFAB91'], + ['#8E24AA', '#E1BEE7'], + ['#FFD600', '#FFF59D'], ]; -const items = new Array(500).fill(true).map(() => ({ - paragraph: randomWords({ min: 3, max: 30 }).join(', '), - name: names[Math.floor(Math.random() * names.length)], -})); +const items = new Array(500).fill(true).map(() => { + const text = loremIpsum({ units: 'paragraphs' }); + return { + colors: colors[Math.floor(Math.random() * colors.length)], + paragraph: text, + sentence: text.substr(0, text.indexOf('.')) + '…', + }; +}); + +class Row extends PureComponent { + state = { + isExpanded: true, + }; + + toggleExpanded = () => + this.setState(prevState => ({ + isExpanded: !prevState.isExpanded, + })); + + render() { + const { index, style } = this.props; + const { isExpanded } = this.state; -const itemRowRenderer = ({ index, style }) => ( -
- {index}: {items[index].paragraph} -
-); + const item = items[index]; -const itemColumnRenderer = ({ index, style }) => ( -
- {index}: {items[index].name} -
-); + return ( +
+
+ {index} +
+
+ {isExpanded ? item.paragraph : item.sentence} +
+
+ ); + } +} -export default class ScrollToItem extends Component { +export default class DynamicSizeList extends PureComponent { horizontalListRef = React.createRef(); verticalListRef = React.createRef(); + state = { + halfSize: false, + }; + + handleToggleResize = () => + this.setState(prevState => ({ + halfSize: !prevState.halfSize, + })); + render() { + const { halfSize } = this.state; + return (

Dynamic Size List

@@ -91,23 +105,30 @@ export default class ScrollToItem extends Component { > Scroll to 15,000px - + Resize list + + - {itemRowRenderer} - + {Row} +
+ {/*
- {itemColumnRenderer} - +
+ */}
); } diff --git a/website/src/routes/examples/shared.module.css b/website/src/routes/examples/shared.module.css index d3e62e91..8db6a3c2 100644 --- a/website/src/routes/examples/shared.module.css +++ b/website/src/routes/examples/shared.module.css @@ -89,6 +89,23 @@ align-items: center; } +.DynamicRowAvatar { + width: 2.5rem; + height: 2.5rem; + border-radius: 50%; + margin-right: 0.5rem; + flex: 0 0 auto; + line-height: 2.5rem; + text-align: center; +} + +.DynamicRowText { + flex: 1; + display: flex; + align-items: center; + cursor: pointer; +} + @media (max-width: 37.5rem) { .ExampleWrapper { padding: 0; diff --git a/website/yarn.lock b/website/yarn.lock index 5d1eb8c1..ad055fac 100644 --- a/website/yarn.lock +++ b/website/yarn.lock @@ -6077,6 +6077,12 @@ loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.2.0, loose-envify@^1.3 dependencies: js-tokens "^3.0.0" +lorem-ipsum@^1.0.6: + version "1.0.6" + resolved "https://registry.yarnpkg.com/lorem-ipsum/-/lorem-ipsum-1.0.6.tgz#69e9ab02bbb0991915d71b5559fe016d526f013f" + dependencies: + minimist "~1.2.0" + loud-rejection@^1.0.0: version "1.6.0" resolved "https://registry.yarnpkg.com/loud-rejection/-/loud-rejection-1.6.0.tgz#5b46f80147edee578870f086d04821cf998e551f" @@ -6294,7 +6300,7 @@ minimist@0.0.8: version "0.0.8" resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.8.tgz#857fcabfc3397d2625b8228262e86aa7a011b05d" -minimist@^1.1.1, minimist@^1.1.3, minimist@^1.2.0: +minimist@^1.1.1, minimist@^1.1.3, minimist@^1.2.0, minimist@~1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.0.tgz#a35008b20f41383eec1fb914f4cd5df79a264284" From f95828fa56c0234bac9fe1c123fed39598e8db46 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Wed, 10 Oct 2018 18:22:41 +0800 Subject: [PATCH 04/37] Added horizontal scroller to demo page --- src/DynamicSizeList.js | 4 ++ website/src/code/DynamicSizeListHorizontal.js | 25 ++++++----- .../src/routes/examples/DynamicSizeList.js | 44 ++++++++++++++++--- website/src/routes/examples/shared.module.css | 1 - 4 files changed, 54 insertions(+), 20 deletions(-) diff --git a/src/DynamicSizeList.js b/src/DynamicSizeList.js index baf59b12..96d09271 100644 --- a/src/DynamicSizeList.js +++ b/src/DynamicSizeList.js @@ -260,6 +260,8 @@ const DynamicSizeList = createListComponent({ instance._itemStyleCache = {}; instance.forceUpdate(); } + + // TODO Add ResizeObserver for list to clear all cached sizes and positions. }; // This function may be called out of order! @@ -378,6 +380,8 @@ const DynamicSizeList = createListComponent({ return items; }; + // TODO Override scrollToItem to just-in-time measure. + // TODO Add reset methods: // resetItem(index) // resetAllItems() diff --git a/website/src/code/DynamicSizeListHorizontal.js b/website/src/code/DynamicSizeListHorizontal.js index ee36d3be..c4f8e09e 100644 --- a/website/src/code/DynamicSizeListHorizontal.js +++ b/website/src/code/DynamicSizeListHorizontal.js @@ -2,16 +2,17 @@ import { DynamicSizeList as List } from 'react-window'; // This example assumes 'items' is an array of strings. // Your application may render a more complex list of items. +const Column = ({ index, style }) => ( +
items[index]
+); - - {({ index, style }) => ( -
- {items[index]} -
- )} -
\ No newline at end of file +const Example = () => ( + + {Column} + +); diff --git a/website/src/routes/examples/DynamicSizeList.js b/website/src/routes/examples/DynamicSizeList.js index c4aa67ab..9fceb7c6 100644 --- a/website/src/routes/examples/DynamicSizeList.js +++ b/website/src/routes/examples/DynamicSizeList.js @@ -4,7 +4,7 @@ import loremIpsum from 'lorem-ipsum'; import CodeBlock from '../../components/CodeBlock'; import ProfiledExample from '../../components/ProfiledExample'; -//import CODE_HORIZONTAL from '../../code/DynamicSizeListHorizontal.js'; +import CODE_HORIZONTAL from '../../code/DynamicSizeListHorizontal.js'; import CODE_VERTICAL from '../../code/DynamicSizeListVertical.js'; import styles from './shared.module.css'; @@ -26,6 +26,7 @@ const items = new Array(500).fill(true).map(() => { colors: colors[Math.floor(Math.random() * colors.length)], paragraph: text, sentence: text.substr(0, text.indexOf('.')) + '…', + words: loremIpsum({ units: 'words' }), }; }); @@ -72,6 +73,37 @@ class Row extends PureComponent { } } +class Column extends PureComponent { + render() { + const { index, style } = this.props; + + const item = items[index]; + + return ( +
+
+ {index} +
+
{item.words}
+
+ ); + } +} + export default class DynamicSizeList extends PureComponent { horizontalListRef = React.createRef(); verticalListRef = React.createRef(); @@ -85,6 +117,9 @@ export default class DynamicSizeList extends PureComponent { halfSize: !prevState.halfSize, })); + scrollTo15000Pixels = () => this.verticalListRef.current.scrollTo(15000); + scrollToRow200Auto = () => this.verticalListRef.current.scrollToItem(200); + render() { const { halfSize } = this.state; @@ -125,7 +160,6 @@ export default class DynamicSizeList extends PureComponent {
- {/*
- {itemColumnRenderer} + {Column}
- */}
); } - - scrollTo15000Pixels = () => this.verticalListRef.current.scrollTo(15000); - scrollToRow200Auto = () => this.verticalListRef.current.scrollToItem(200); } diff --git a/website/src/routes/examples/shared.module.css b/website/src/routes/examples/shared.module.css index 8db6a3c2..4d1d83a0 100644 --- a/website/src/routes/examples/shared.module.css +++ b/website/src/routes/examples/shared.module.css @@ -103,7 +103,6 @@ flex: 1; display: flex; align-items: center; - cursor: pointer; } @media (max-width: 37.5rem) { From f210a41f90c3722037507e87ededb616bbe93a95 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Wed, 10 Oct 2018 18:30:23 +0800 Subject: [PATCH 05/37] Tweaked dynamic list demo --- website/now.json | 4 ++-- website/src/routes/examples/shared.module.css | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/website/now.json b/website/now.json index 45a83b12..9753a495 100644 --- a/website/now.json +++ b/website/now.json @@ -1,5 +1,5 @@ { - "name": "react-window", - "alias": "react-window", + "name": "react-window-next", + "alias": "react-window-next", "public": true } diff --git a/website/src/routes/examples/shared.module.css b/website/src/routes/examples/shared.module.css index 4d1d83a0..00596fee 100644 --- a/website/src/routes/examples/shared.module.css +++ b/website/src/routes/examples/shared.module.css @@ -74,6 +74,7 @@ box-sizing: border-box; display: flex; justify-content: flex-start; + align-items: center; padding: 0.5rem; } From ee0682f6c036a6700306ed1738166118023fd253 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Thu, 11 Oct 2018 10:19:07 +0800 Subject: [PATCH 06/37] Fixed style cache busting to avoid unnecessary re-renders --- src/DynamicSizeList.js | 69 +++++++------------ src/createListComponent.js | 10 ++- .../src/routes/examples/DynamicSizeList.js | 55 ++++++++------- website/src/routes/examples/shared.module.css | 21 ++++-- 4 files changed, 79 insertions(+), 76 deletions(-) diff --git a/src/DynamicSizeList.js b/src/DynamicSizeList.js index 96d09271..b7d54abb 100644 --- a/src/DynamicSizeList.js +++ b/src/DynamicSizeList.js @@ -26,6 +26,7 @@ type ItemMetadata = {| |}; type InstanceProps = {| estimatedItemSize: number, + instance: any, itemOffsetMap: { [index: number]: number }, itemSizeMap: { [index: number]: number }, lastMeasuredIndex: number, @@ -40,6 +41,7 @@ const getItemMetadata = ( ): ItemMetadata => { const { estimatedItemSize, + instance, itemOffsetMap, itemSizeMap, lastMeasuredIndex, @@ -61,11 +63,14 @@ const getItemMetadata = ( itemOffsetMap[0] = 0; } - for (let i = Math.max(1, lastPositionedIndex); i <= index; i++) { + for (let i = Math.max(1, lastPositionedIndex + 1); i <= index; i++) { const prevOffset = itemOffsetMap[i - 1]; const prevSize = itemSizeMap[i - 1]; itemOffsetMap[i] = prevOffset + prevSize; + + // Reset cached style to clear stale position. + delete instance._itemStyleCache[i]; } instanceProps.lastPositionedIndex = index; @@ -126,12 +131,11 @@ const DynamicSizeList = createListComponent({ props: Props, index: number, instanceProps: InstanceProps - ): number => { - if (index > instanceProps.lastMeasuredIndex) { - return instanceProps.estimatedItemSize; - } else { - return instanceProps.itemSizeMap[index]; - } + ): ?number => { + // Do not hard-code item dimensions. + // We don't know them initially. + // Even once we do, changes in item content or list size should reflow. + return undefined; }, getEstimatedTotalSize, @@ -228,6 +232,7 @@ const DynamicSizeList = createListComponent({ const instanceProps = { estimatedItemSize: estimatedItemSize || DEFAULT_ESTIMATED_ITEM_SIZE, + instance, itemOffsetMap: {}, itemSizeMap: {}, lastMeasuredIndex: -1, @@ -252,16 +257,11 @@ const DynamicSizeList = createListComponent({ instance._commitHook = () => { if (hasNewMeasurements) { hasNewMeasurements = false; - - // We could potentially optimize further by only evicting styles after this index, - // But since styles are only cached while scrolling is in progress- - // It seems an unnecessary optimization. - // It's unlikely that resetAfterIndex() will be called while a user is scrolling. - instance._itemStyleCache = {}; instance.forceUpdate(); } - // TODO Add ResizeObserver for list to clear all cached sizes and positions. + // TODO Add ResizeObserver for list to clear all cached sizes and positions? + // Alternately we could adust scrollOffset by delta when scrollDirection is BACKWARDS. }; // This function may be called out of order! @@ -299,6 +299,11 @@ const DynamicSizeList = createListComponent({ itemSizeMap[index] = newSize; + // Even though the size has changed, we don't need to reset the cached style, + // Because dynamic list items don't have constrained sizes. + // This enables them to resize when their content (or container size) changes. + // It also lets us avoid an unnecessary render in this case. + if (isCommitPhase) { hasNewMeasurements = true; } else { @@ -313,11 +318,9 @@ const DynamicSizeList = createListComponent({ const { children, direction, - height, itemCount, itemKey = defaultItemKey, useIsScrolling, - width, } = instance.props; const { isScrolling } = instance.state; @@ -325,36 +328,16 @@ const DynamicSizeList = createListComponent({ const items = []; if (itemCount > 0) { - const { lastMeasuredIndex } = instanceProps; - for (let index = startIndex; index <= stopIndex; index++) { - let style = instance._getItemStyle(index); - - const { offset, size } = getItemMetadata( + const { size } = getItemMetadata( instance.props, index, instanceProps ); - if (index > lastMeasuredIndex) { - // Strip hard-coded dimensions from the inline style. - // These would interfere with the item laying itself out anyway. - // Constrain the item to fill either the width or height of the list, - // Depending on the direction being windowed. - style = { - ...style, - height: direction === 'horizontal' ? height : undefined, - width: direction === 'vertical' ? width : undefined, - }; - } else { - style = { - ...style, - height: direction === 'horizontal' ? '100%' : undefined, - width: direction === 'vertical' ? '100%' : undefined, - left: direction === 'horizontal' ? offset : 0, - top: direction === 'vertical' ? offset : 0, - }; - } + // It's important to read style after fetching item metadata. + // getItemMetadata() will clear stale styles. + const style = instance._getItemStyle(index); const item = createElement(children, { index, @@ -362,13 +345,11 @@ const DynamicSizeList = createListComponent({ style, }); - // Always wrap children in a CellMeasurer. - // We could only wrap them for the initial render, - // But we also want to automatically detect resizes. + // Always wrap children in a CellMeasurer to detect changes in size. items.push( createElement(CellMeasurer, { direction, - handleNewMeasurements: instance._handleNewMeasurements, + handleNewMeasurements, index, item, key: itemKey(index), diff --git a/src/createListComponent.js b/src/createListComponent.js index 8ffff95e..803dd925 100644 --- a/src/createListComponent.js +++ b/src/createListComponent.js @@ -72,7 +72,7 @@ type GetItemSize = ( props: Props, index: number, instanceProps: any -) => number; +) => ?number; type GetEstimatedTotalSize = (props: Props, instanceProps: any) => number; type GetOffsetForIndexAndAlignment = ( props: Props, @@ -395,8 +395,14 @@ export default function createListComponent({ return style; }; + _itemStyleCache: ItemStyleCache; + _getItemStyleCache: (_: any) => ItemStyleCache; - _getItemStyleCache = memoizeOne(_ => ({})); + _getItemStyleCache = memoizeOne(_ => { + this._itemStyleCache = {}; + + return this._itemStyleCache; + }); _getRangeToRender(): [number, number, number, number] { const { itemCount, overscanCount } = this.props; diff --git a/website/src/routes/examples/DynamicSizeList.js b/website/src/routes/examples/DynamicSizeList.js index 9fceb7c6..f1faa6d7 100644 --- a/website/src/routes/examples/DynamicSizeList.js +++ b/website/src/routes/examples/DynamicSizeList.js @@ -25,36 +25,32 @@ const items = new Array(500).fill(true).map(() => { return { colors: colors[Math.floor(Math.random() * colors.length)], paragraph: text, + isColumnExpanded: true, + isRowExpanded: true, sentence: text.substr(0, text.indexOf('.')) + '…', - words: loremIpsum({ units: 'words' }), + word: loremIpsum({ units: 'words' }), + words: loremIpsum({ units: 'words', count: 3 }), }; }); class Row extends PureComponent { - state = { - isExpanded: true, + toggleExpanded = () => { + const { index } = this.props; + // We mutate in place here rather than using setState, + // Because it persists the data after unmount an remount. + const item = items[index]; + item.isRowExpanded = !item.isRowExpanded; + this.forceUpdate(); }; - toggleExpanded = () => - this.setState(prevState => ({ - isExpanded: !prevState.isExpanded, - })); - render() { const { index, style } = this.props; - const { isExpanded } = this.state; - const item = items[index]; return (
- {isExpanded ? item.paragraph : item.sentence} + {item.isRowExpanded ? item.paragraph : item.sentence}
); @@ -74,23 +70,28 @@ class Row extends PureComponent { } class Column extends PureComponent { + toggleExpanded = () => { + const { index } = this.props; + // We mutate in place here rather than using setState, + // Because it persists the data after unmount an remount. + const item = items[index]; + item.isColumnExpanded = !item.isColumnExpanded; + this.forceUpdate(); + }; + render() { const { index, style } = this.props; - const item = items[index]; return (
{index}
-
{item.words}
+
+ {item.isColumnExpanded ? item.words : item.word} +
); } diff --git a/website/src/routes/examples/shared.module.css b/website/src/routes/examples/shared.module.css index 00596fee..66d99099 100644 --- a/website/src/routes/examples/shared.module.css +++ b/website/src/routes/examples/shared.module.css @@ -69,16 +69,23 @@ justify-content: center; } -.DynamicListItemEven, -.DynamicListItemOdd { +.DynamicColumnEven, +.DynamicColumnOdd, +.DynamicRowEven, +.DynamicRowOdd { box-sizing: border-box; + padding: 0.5rem; + display: flex; +} +.DynamicColumnEven, +.DynamicColumnOdd { display: flex; justify-content: flex-start; align-items: center; - padding: 0.5rem; } -.DynamicListItemEven, +.DynamicColumnEven, +.DynamicRowEven, .GridItemEven, .ListItemEven { background-color: #f8f8f0; @@ -90,6 +97,7 @@ align-items: center; } +.DynamicColumnAvatar, .DynamicRowAvatar { width: 2.5rem; height: 2.5rem; @@ -100,10 +108,15 @@ text-align: center; } +.DynamicColumnText, .DynamicRowText { flex: 1; display: flex; align-items: center; + cursor: pointer; +} +.DynamicColumnText { + white-space: nowrap; } @media (max-width: 37.5rem) { From 7a4b84f24822cec2788a95dfc8abdc0a419be4b1 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Thu, 11 Oct 2018 10:46:16 +0800 Subject: [PATCH 07/37] Prevent items from jumping around while scrolling up after a resize --- src/DynamicSizeList.js | 46 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 43 insertions(+), 3 deletions(-) diff --git a/src/DynamicSizeList.js b/src/DynamicSizeList.js index b7d54abb..b3a14510 100644 --- a/src/DynamicSizeList.js +++ b/src/DynamicSizeList.js @@ -240,8 +240,6 @@ const DynamicSizeList = createListComponent({ totalMeasuredSize: 0, }; - let hasNewMeasurements: boolean = false; - // TODO Cancel pending debounce on unmount let debounceForceUpdateID = null; const debounceForceUpdate = () => { @@ -253,11 +251,48 @@ const DynamicSizeList = createListComponent({ } }; + let hasNewMeasurements: boolean = false; + let sizeDeltaTotal = 0; + // List calls this method automatically after "mount" and "update". instance._commitHook = () => { if (hasNewMeasurements) { hasNewMeasurements = false; - instance.forceUpdate(); + + let shouldForceUpdate; + + // If the user is scrolling up, we need to adjust the scroll offset, + // To prevent items from "jumping" as items before them have been resized. + instance.setState( + prevState => { + if (prevState.scrollDirection === 'backward') { + return { + scrollOffset: prevState.scrollOffset + sizeDeltaTotal, + }; + } else { + // There's no state to update, + // But we still want to re-render in this case. + shouldForceUpdate = true; + + return null; + } + }, + () => { + if (shouldForceUpdate) { + instance.forceUpdate(); + } else { + const { direction, scrollOffset } = instance.state; + + if (direction === 'horizontal') { + ((instance._outerRef: any): HTMLDivElement).scrollLeft = scrollOffset; + } else { + ((instance._outerRef: any): HTMLDivElement).scrollTop = scrollOffset; + } + } + + sizeDeltaTotal = 0; + } + ); } // TODO Add ResizeObserver for list to clear all cached sizes and positions? @@ -306,6 +341,11 @@ const DynamicSizeList = createListComponent({ if (isCommitPhase) { hasNewMeasurements = true; + + // Record the size delta here in case the user is scrolling up. + // In that event, we need to adjust the scroll offset by thie amount, + // To prevent items from "jumping" as items before them are resized. + sizeDeltaTotal += newSize - oldSize; } else { debounceForceUpdate(); } From eaa849d16562d029371ea71fd09c001f7d020b34 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Thu, 11 Oct 2018 10:54:44 +0800 Subject: [PATCH 08/37] Cleanup setTimeout on unmount. Use ResizeObserver.disconnect instead of unobserve --- src/CellMeasurer.js | 4 ++-- src/DynamicSizeList.js | 15 ++++++++++----- src/createListComponent.js | 6 ++++++ 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/src/CellMeasurer.js b/src/CellMeasurer.js index 55d7d520..d93ee0d7 100644 --- a/src/CellMeasurer.js +++ b/src/CellMeasurer.js @@ -9,7 +9,7 @@ import type { HandleNewMeasurements } from './DynamicSizeList'; declare class ResizeObserver { constructor(callback: Function): void; observe(target: HTMLElement): void; - unobserve(target: HTMLElement): void; + disconnect(): void; } type CellMeasurerProps = {| @@ -38,7 +38,7 @@ export default class CellMeasurer extends Component { } componentWillUnmount() { - this._resizeObserver.unobserve(this._node); + this._resizeObserver.disconnect(); } render() { diff --git a/src/DynamicSizeList.js b/src/DynamicSizeList.js index b3a14510..ec30e199 100644 --- a/src/DynamicSizeList.js +++ b/src/DynamicSizeList.js @@ -240,7 +240,6 @@ const DynamicSizeList = createListComponent({ totalMeasuredSize: 0, }; - // TODO Cancel pending debounce on unmount let debounceForceUpdateID = null; const debounceForceUpdate = () => { if (debounceForceUpdateID === null) { @@ -251,10 +250,18 @@ const DynamicSizeList = createListComponent({ } }; + // This method is called before unmounting. + instance._unmountHook = () => { + if (debounceForceUpdateID !== null) { + clearTimeout(debounceForceUpdateID); + debounceForceUpdateID = null; + } + }; + let hasNewMeasurements: boolean = false; let sizeDeltaTotal = 0; - // List calls this method automatically after "mount" and "update". + // This method is called after mount and update. instance._commitHook = () => { if (hasNewMeasurements) { hasNewMeasurements = false; @@ -294,9 +301,6 @@ const DynamicSizeList = createListComponent({ } ); } - - // TODO Add ResizeObserver for list to clear all cached sizes and positions? - // Alternately we could adust scrollOffset by delta when scrollDirection is BACKWARDS. }; // This function may be called out of order! @@ -402,6 +406,7 @@ const DynamicSizeList = createListComponent({ }; // TODO Override scrollToItem to just-in-time measure. + // Or decide not to support this and log a NO-OP warning. // TODO Add reset methods: // resetItem(index) diff --git a/src/createListComponent.js b/src/createListComponent.js index 803dd925..bdefb806 100644 --- a/src/createListComponent.js +++ b/src/createListComponent.js @@ -222,6 +222,8 @@ export default function createListComponent({ if (this._resetIsScrollingTimeoutId !== null) { clearTimeout(this._resetIsScrollingTimeoutId); } + + this._unmountHook(); } render() { @@ -355,6 +357,10 @@ export default function createListComponent({ // List implementations can override this method to be notified. _commitHook() {} + // This method is called before unmounting. + // List implementations can override this method to be notified. + _unmountHook() {} + // Lazily create and cache item styles while scrolling, // So that pure component sCU will prevent re-renders. // We maintain this cache, and pass a style prop rather than index, From a43cd50f545c1308547f89b05c91edd84ea21301 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Thu, 11 Oct 2018 10:55:56 +0800 Subject: [PATCH 09/37] Removed an outdated TODO --- src/DynamicSizeList.js | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/DynamicSizeList.js b/src/DynamicSizeList.js index ec30e199..ebac332f 100644 --- a/src/DynamicSizeList.js +++ b/src/DynamicSizeList.js @@ -408,10 +408,6 @@ const DynamicSizeList = createListComponent({ // TODO Override scrollToItem to just-in-time measure. // Or decide not to support this and log a NO-OP warning. - // TODO Add reset methods: - // resetItem(index) - // resetAllItems() - return instanceProps; }, From 944ad3c12df273d0b5ac9bd24e3345061fcfbe18 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Thu, 11 Oct 2018 12:09:46 +0800 Subject: [PATCH 10/37] Added a cleanup TODO --- src/createGridComponent.js | 3 +++ src/createListComponent.js | 3 +++ 2 files changed, 6 insertions(+) diff --git a/src/createGridComponent.js b/src/createGridComponent.js index db796449..cdc85413 100644 --- a/src/createGridComponent.js +++ b/src/createGridComponent.js @@ -497,6 +497,9 @@ export default function createGridComponent({ return style; }; + // TODO This memoized getter doesn't make much sense. + // If all that's really needed is for the impl to be able to reset the cache, + // Then we could expose a better API for that. _getItemStyleCache: (_: any, __: any) => ItemStyleCache; _getItemStyleCache = memoizeOne((_, __) => ({})); diff --git a/src/createListComponent.js b/src/createListComponent.js index bdefb806..e26edae6 100644 --- a/src/createListComponent.js +++ b/src/createListComponent.js @@ -403,6 +403,9 @@ export default function createListComponent({ _itemStyleCache: ItemStyleCache; + // TODO This memoized getter doesn't make much sense. + // If all that's really needed is for the impl to be able to reset the cache, + // Then we could expose a better API for that. _getItemStyleCache: (_: any) => ItemStyleCache; _getItemStyleCache = memoizeOne(_ => { this._itemStyleCache = {}; From ae340c28893323559dd9a7417e7586b3aaa72792 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Thu, 11 Oct 2018 12:15:00 +0800 Subject: [PATCH 11/37] Added scrollToItem() DEV warning --- src/DynamicSizeList.js | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/DynamicSizeList.js b/src/DynamicSizeList.js index ebac332f..8819c0c5 100644 --- a/src/DynamicSizeList.js +++ b/src/DynamicSizeList.js @@ -149,6 +149,16 @@ const DynamicSizeList = createListComponent({ ): number => { const { direction, height, width } = props; + if (process.env.NODE_ENV !== 'production') { + const { lastMeasuredIndex } = instanceProps; + if (index > lastMeasuredIndex) { + console.warn( + `DynamicSizeList does not support scrolling to items that yave not yet measured. ` + + `scrollToItem() was called with index ${index} but the last measured item was ${lastMeasuredIndex}.` + ); + } + } + const size = (((direction === 'horizontal' ? width : height): any): number); const itemMetadata = getItemMetadata(props, index, instanceProps); @@ -405,9 +415,6 @@ const DynamicSizeList = createListComponent({ return items; }; - // TODO Override scrollToItem to just-in-time measure. - // Or decide not to support this and log a NO-OP warning. - return instanceProps; }, From ad0ddf5558d223e409e7185f881d2263fc0cc521 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Thu, 11 Oct 2018 12:18:17 +0800 Subject: [PATCH 12/37] Renamed CellMeasurer to ItemMeasurer --- src/DynamicSizeList.js | 8 ++++---- src/{CellMeasurer.js => ItemMeasurer.js} | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) rename src/{CellMeasurer.js => ItemMeasurer.js} (95%) diff --git a/src/DynamicSizeList.js b/src/DynamicSizeList.js index 8819c0c5..0309edcf 100644 --- a/src/DynamicSizeList.js +++ b/src/DynamicSizeList.js @@ -3,7 +3,7 @@ import { createElement } from 'react'; import createListComponent, { defaultItemKey } from './createListComponent'; -import CellMeasurer from './CellMeasurer'; +import ItemMeasurer from './ItemMeasurer'; import type { Props, ScrollToAlign } from './createListComponent'; @@ -366,7 +366,7 @@ const DynamicSizeList = createListComponent({ }; instance._handleNewMeasurements = handleNewMeasurements; - // Override the item-rendering process to wrap items with CellMeasurer. + // Override the item-rendering process to wrap items with ItemMeasurer. // This keep the external API simpler. instance._renderItems = () => { const { @@ -399,9 +399,9 @@ const DynamicSizeList = createListComponent({ style, }); - // Always wrap children in a CellMeasurer to detect changes in size. + // Always wrap children in a ItemMeasurer to detect changes in size. items.push( - createElement(CellMeasurer, { + createElement(ItemMeasurer, { direction, handleNewMeasurements, index, diff --git a/src/CellMeasurer.js b/src/ItemMeasurer.js similarity index 95% rename from src/CellMeasurer.js rename to src/ItemMeasurer.js index d93ee0d7..89d3a357 100644 --- a/src/CellMeasurer.js +++ b/src/ItemMeasurer.js @@ -12,7 +12,7 @@ declare class ResizeObserver { disconnect(): void; } -type CellMeasurerProps = {| +type ItemMeasurerProps = {| direction: Direction, handleNewMeasurements: HandleNewMeasurements, index: number, @@ -20,7 +20,7 @@ type CellMeasurerProps = {| size: number, |}; -export default class CellMeasurer extends Component { +export default class ItemMeasurer extends Component { _node: HTMLElement = (null: any); _resizeObserver: ResizeObserver = (null: any); From b378c444f62ca127da9b98283fc2a2c5a6ef5f84 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Thu, 11 Oct 2018 13:07:41 +0800 Subject: [PATCH 13/37] Improved another edge-case item "jumping" after resize issue --- src/DynamicSizeList.js | 22 ++++++--- website/src/code/DynamicSizeListHorizontal.js | 4 +- website/src/code/DynamicSizeListVertical.js | 4 +- .../src/routes/examples/DynamicSizeList.js | 48 +++++++++---------- 4 files changed, 44 insertions(+), 34 deletions(-) diff --git a/src/DynamicSizeList.js b/src/DynamicSizeList.js index 0309edcf..00a8f1d5 100644 --- a/src/DynamicSizeList.js +++ b/src/DynamicSizeList.js @@ -282,7 +282,10 @@ const DynamicSizeList = createListComponent({ // To prevent items from "jumping" as items before them have been resized. instance.setState( prevState => { - if (prevState.scrollDirection === 'backward') { + if ( + prevState.scrollDirection === 'backward' && + !prevState.scrollUpdateWasRequested + ) { return { scrollOffset: prevState.scrollOffset + sizeDeltaTotal, }; @@ -298,7 +301,8 @@ const DynamicSizeList = createListComponent({ if (shouldForceUpdate) { instance.forceUpdate(); } else { - const { direction, scrollOffset } = instance.state; + const { scrollOffset } = instance.state; + const { direction } = instance.props; if (direction === 'horizontal') { ((instance._outerRef: any): HTMLDivElement).scrollLeft = scrollOffset; @@ -341,6 +345,13 @@ const DynamicSizeList = createListComponent({ // Adjust total size estimate by the delta in size. instanceProps.totalMeasuredSize += newSize - oldSize; + + // Record the size delta here in case the user is scrolling up. + // In that event, we need to adjust the scroll offset by thie amount, + // To prevent items from "jumping" as items before them are resized. + if (isCommitPhase) { + sizeDeltaTotal += newSize - oldSize; + } } else { instanceProps.lastMeasuredIndex = index; instanceProps.totalMeasuredSize += newSize; @@ -355,11 +366,6 @@ const DynamicSizeList = createListComponent({ if (isCommitPhase) { hasNewMeasurements = true; - - // Record the size delta here in case the user is scrolling up. - // In that event, we need to adjust the scroll offset by thie amount, - // To prevent items from "jumping" as items before them are resized. - sizeDeltaTotal += newSize - oldSize; } else { debounceForceUpdate(); } @@ -373,6 +379,7 @@ const DynamicSizeList = createListComponent({ children, direction, itemCount, + itemData, itemKey = defaultItemKey, useIsScrolling, } = instance.props; @@ -394,6 +401,7 @@ const DynamicSizeList = createListComponent({ const style = instance._getItemStyle(index); const item = createElement(children, { + data: itemData, index, isScrolling: useIsScrolling ? isScrolling : undefined, style, diff --git a/website/src/code/DynamicSizeListHorizontal.js b/website/src/code/DynamicSizeListHorizontal.js index c4f8e09e..e1e69e79 100644 --- a/website/src/code/DynamicSizeListHorizontal.js +++ b/website/src/code/DynamicSizeListHorizontal.js @@ -1,11 +1,13 @@ import { DynamicSizeList as List } from 'react-window'; // This example assumes 'items' is an array of strings. -// Your application may render a more complex list of items. +// Your application may render a more complex item, +// Like the one shown in this demo. const Column = ({ index, style }) => (
items[index]
); +// Note that no itemSize is required for dyanmic lists! const Example = () => ( (
items[index]
); +// Note that no itemSize is required for dyanmic lists! const Example = () => ( {index}
-
- {item.isColumnExpanded ? item.words : item.word} -
+ {showText && ( +
+ {item.isColumnExpanded ? item.words : item.word} +
+ )}
); } } export default class DynamicSizeList extends PureComponent { - horizontalListRef = React.createRef(); - verticalListRef = React.createRef(); - state = { halfSize: false, + showText: true, }; handleToggleResize = () => @@ -120,29 +123,19 @@ export default class DynamicSizeList extends PureComponent { halfSize: !prevState.halfSize, })); - scrollTo15000Pixels = () => this.verticalListRef.current.scrollTo(15000); - scrollToRow200Auto = () => this.verticalListRef.current.scrollToItem(200); + handleToggleText = () => + this.setState(prevState => ({ + showText: !prevState.showText, + })); render() { - const { halfSize } = this.state; + const { halfSize, showText } = this.state; return (

Dynamic Size List

- -
+ {Column} From 00cb13ff0aff0931a4ebaca6206289a62a8d3acd Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Fri, 12 Oct 2018 17:40:52 +0800 Subject: [PATCH 14/37] Package version bump for alpha --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 304a60e3..99db929a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-window", - "version": "1.2.1", + "version": "1.3.0", "description": "React components for efficiently rendering large, scrollable lists and tabular data", "author": From da981bba58984aceb0586cbd584b2b19e47e459d Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Fri, 12 Oct 2018 17:50:12 +0800 Subject: [PATCH 15/37] Added alpha suffix --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 99db929a..e12946f0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-window", - "version": "1.3.0", + "version": "1.3.0-alpha.0", "description": "React components for efficiently rendering large, scrollable lists and tabular data", "author": From 343f3c6b524e37af2d0f1ffce404e2447d93a92f Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Fri, 12 Oct 2018 21:07:04 +0800 Subject: [PATCH 16/37] Added example sandboxes for dynamic list --- .../dynamic-size-list-horizontal/index.js | 33 +++++++++++++++++++ .../dynamic-size-list-horizontal/package.json | 10 ++++++ .../dynamic-size-list-horizontal/styles.css | 22 +++++++++++++ .../dynamic-size-list-vertical/index.js | 32 ++++++++++++++++++ .../dynamic-size-list-vertical/package.json | 10 ++++++ .../dynamic-size-list-vertical/styles.css | 18 ++++++++++ .../src/routes/examples/DynamicSizeList.js | 10 ++++-- 7 files changed, 133 insertions(+), 2 deletions(-) create mode 100644 website/sandboxes/dynamic-size-list-horizontal/index.js create mode 100644 website/sandboxes/dynamic-size-list-horizontal/package.json create mode 100644 website/sandboxes/dynamic-size-list-horizontal/styles.css create mode 100644 website/sandboxes/dynamic-size-list-vertical/index.js create mode 100644 website/sandboxes/dynamic-size-list-vertical/package.json create mode 100644 website/sandboxes/dynamic-size-list-vertical/styles.css diff --git a/website/sandboxes/dynamic-size-list-horizontal/index.js b/website/sandboxes/dynamic-size-list-horizontal/index.js new file mode 100644 index 00000000..b01cbd71 --- /dev/null +++ b/website/sandboxes/dynamic-size-list-horizontal/index.js @@ -0,0 +1,33 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import { DynamicSizeList as List } from 'react-window'; +import loremIpsum from 'lorem-ipsum'; + +import './styles.css'; + +// This example uses an array of random strings, +// But the list can also contain data that's async loaded, like images. +const items = new Array(500) + .fill(true) + .map(() => loremIpsum({ units: 'words', count: 3 })); + +const Column = ({ data, index, style }) => ( +
+ {data[index]} +
+); + +const Example = () => ( + + {Column} + +); + +ReactDOM.render(, document.getElementById('root')); diff --git a/website/sandboxes/dynamic-size-list-horizontal/package.json b/website/sandboxes/dynamic-size-list-horizontal/package.json new file mode 100644 index 00000000..9c99c1ad --- /dev/null +++ b/website/sandboxes/dynamic-size-list-horizontal/package.json @@ -0,0 +1,10 @@ +{ + "description": "Demo of react-window horizontal VariableSizeList", + "main": "src/index.js", + "dependencies": { + "lorem-ipsum": "^1", + "react": "^16", + "react-dom": "^16", + "react-window": "^1" + } +} diff --git a/website/sandboxes/dynamic-size-list-horizontal/styles.css b/website/sandboxes/dynamic-size-list-horizontal/styles.css new file mode 100644 index 00000000..9a5ae5cb --- /dev/null +++ b/website/sandboxes/dynamic-size-list-horizontal/styles.css @@ -0,0 +1,22 @@ +html { + font-family: sans-serif; + font-size: 12px; +} + +.List { + border: 1px solid #d9dddd; +} + +.ListItemEven, +.ListItemOdd { + display: flex; + align-items: center; + white-space: pre; + word-wrap: nowrap; + padding: 0.5rem; + box-sizing: border-box; +} + +.ListItemEven { + background-color: #f8f8f0; +} diff --git a/website/sandboxes/dynamic-size-list-vertical/index.js b/website/sandboxes/dynamic-size-list-vertical/index.js new file mode 100644 index 00000000..5de26e49 --- /dev/null +++ b/website/sandboxes/dynamic-size-list-vertical/index.js @@ -0,0 +1,32 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import { DynamicSizeList as List } from 'react-window'; +import loremIpsum from 'lorem-ipsum'; + +import './styles.css'; + +// This example uses an array of random strings, +// But the list can also contain data that's async loaded, like images. +const items = new Array(500) + .fill(true) + .map(() => loremIpsum({ units: 'paragraphs' })); + +const Row = ({ data, index, style }) => ( +
+ {data[index]} +
+); + +const Example = () => ( + + {Row} + +); + +ReactDOM.render(, document.getElementById('root')); diff --git a/website/sandboxes/dynamic-size-list-vertical/package.json b/website/sandboxes/dynamic-size-list-vertical/package.json new file mode 100644 index 00000000..3848754b --- /dev/null +++ b/website/sandboxes/dynamic-size-list-vertical/package.json @@ -0,0 +1,10 @@ +{ + "description": "Demo of react-window vertical VariableSizeList", + "main": "src/index.js", + "dependencies": { + "lorem-ipsum": "^1", + "react": "^16", + "react-dom": "^16", + "react-window": "^1" + } +} diff --git a/website/sandboxes/dynamic-size-list-vertical/styles.css b/website/sandboxes/dynamic-size-list-vertical/styles.css new file mode 100644 index 00000000..6f4f5f0d --- /dev/null +++ b/website/sandboxes/dynamic-size-list-vertical/styles.css @@ -0,0 +1,18 @@ +html { + font-family: sans-serif; + font-size: 12px; +} + +.List { + border: 1px solid #d9dddd; +} + +.ListItemEven, +.ListItemOdd { + padding: 0.5rem; + box-sizing: border-box; +} + +.ListItemEven { + background-color: #f8f8f0; +} diff --git a/website/src/routes/examples/DynamicSizeList.js b/website/src/routes/examples/DynamicSizeList.js index 855b33d0..ec5aea59 100644 --- a/website/src/routes/examples/DynamicSizeList.js +++ b/website/src/routes/examples/DynamicSizeList.js @@ -135,7 +135,10 @@ export default class DynamicSizeList extends PureComponent {

Dynamic Size List

- +
- +
); } diff --git a/website/src/routes/examples/VariableSizeList.js b/website/src/routes/examples/VariableSizeList.js index af0bc0fd..748ce6b9 100644 --- a/website/src/routes/examples/VariableSizeList.js +++ b/website/src/routes/examples/VariableSizeList.js @@ -17,14 +17,14 @@ const rowSizes = new Array(1000) class Item extends PureComponent { render() { - const { index, style } = this.props; + const { data, index, style } = this.props; return (
- Item {index} + {data} {index}
); } @@ -43,6 +43,7 @@ export default function() { className={styles.List} height={150} itemCount={1000} + itemData="Row" itemSize={index => rowSizes[index]} width={300} > @@ -63,6 +64,7 @@ export default function() { direction="horizontal" height={75} itemCount={1000} + itemData="Column" itemSize={index => columnSizes[index]} width={300} > From a0bb8489cff93e8b10d18eb430f1ced81751b30d Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Fri, 7 Dec 2018 10:34:29 -0800 Subject: [PATCH 26/37] Changed scroll offset adjustment to prevent interrupting smooth scroll --- src/DynamicSizeList.js | 79 ++++++++++++++++++++++++++++++-------- src/createListComponent.js | 28 +++++++++----- 2 files changed, 83 insertions(+), 24 deletions(-) diff --git a/src/DynamicSizeList.js b/src/DynamicSizeList.js index 9334d033..5501fdf6 100644 --- a/src/DynamicSizeList.js +++ b/src/DynamicSizeList.js @@ -5,7 +5,7 @@ import { createElement } from 'react'; import createListComponent, { defaultItemKey } from './createListComponent'; import ItemMeasurer from './ItemMeasurer'; -import type { Props, ScrollToAlign } from './createListComponent'; +import type { Props, ScrollToAlign, State } from './createListComponent'; const DEFAULT_ESTIMATED_ITEM_SIZE = 50; @@ -277,7 +277,7 @@ const DynamicSizeList = createListComponent({ let sizeDeltaTotal = 0; // This method is called after mount and update. - instance._commitHook = () => { + instance._commitHook = (prevProps?: Props, prevState?: State) => { if (hasNewMeasurements) { hasNewMeasurements = false; @@ -305,12 +305,19 @@ const DynamicSizeList = createListComponent({ prevState.scrollDirection === 'backward' && !prevState.scrollUpdateWasRequested ) { - // TODO This messes with Firefox's smooth scrolling. - // Is there a way to queue up these changes and only apply them when: - // 1: The user stops scrolling (after a debounced delay) - // 2: The list scrolls to offset or index 0? + // TRICKY + // If item(s) have changed size since they were last displayed, content will appear to jump. + // To avoid this, we may need to make small adjustments as a user scrolls to preserve apparent position. + // The most intuitive way to do this would be to adjust the scroll offset directly, + // but changing scroll offsets interrupts smooth scrolling for some browser's (e.g. Firefox). + // Instead we temporarily offset item positions by adjusting top/left margin on the container. + // Then after a delay/debounce (once scrolling has stopped) we remove this and adjust the offset. + // If we get to close to offset 0 (or item 0) we'll need to force this update, + // but that seems acceptable since the scrolling animation would be interrupted in that event anyway. return { scrollOffset: prevState.scrollOffset + sizeDeltaForStateUpdate, + scrollOffsetDelta: + prevState.scrollOffsetDelta + sizeDeltaForStateUpdate, }; } else { // There's no state to update, @@ -323,21 +330,63 @@ const DynamicSizeList = createListComponent({ () => { if (shouldForceUpdate) { instance.forceUpdate(); - } else { - const { scrollOffset } = instance.state; - const { direction } = instance.props; + } + sizeDeltaTotal -= sizeDeltaForStateUpdate; + } + ); + } else if (prevState != null) { + if ( + !instance.state.isScrolling && + prevState.isScrolling && + prevState.scrollOffsetDelta !== 0 + ) { + const { scrollOffset, scrollOffsetDelta } = instance.state; + + // TRICKY + // Clean up any temporary offset adjustments (made above) once scrolling has stopped. + // Be sure to update the scrollOffset in state before doing this, + // or the "scroll" event it triggers will cause a new batch of items to be rendered. + instance.setState( + { + scrollOffset: scrollOffset + scrollOffsetDelta, + scrollOffsetDelta: 0, + }, + () => { const element = ((instance._outerRef: any): HTMLDivElement); - if (direction === 'horizontal') { - element.scrollLeft = scrollOffset; + if (instance.props.direction === 'horizontal') { + element.scrollLeft = scrollOffset + scrollOffsetDelta; } else { - element.scrollTop = scrollOffset; + element.scrollTop = scrollOffset + scrollOffsetDelta; } } - - sizeDeltaTotal -= sizeDeltaForStateUpdate; + ); + } else { + // eslint-disable-next-line no-unused-vars + const [_, __, startIndex] = instance._getRangeToRender(); + const { scrollOffset, scrollOffsetDelta } = instance.state; + + // TRICKY + // If we get to close to the start of the list (either offset 0 or the first item) + // we should synchronously adjustour offsets without waiting for the debounce. + // This is still an awkward scrolling UX but it's hopefully not a common case. + if (startIndex === 0 || scrollOffset + scrollOffsetDelta <= 0) { + instance.setState( + { + scrollOffset: scrollOffset + scrollOffsetDelta, + scrollOffsetDelta: 0, + }, + () => { + const element = ((instance._outerRef: any): HTMLDivElement); + if (instance.props.direction === 'horizontal') { + element.scrollLeft = scrollOffset + scrollOffsetDelta; + } else { + element.scrollTop = scrollOffset + scrollOffsetDelta; + } + } + ); } - ); + } } }; diff --git a/src/createListComponent.js b/src/createListComponent.js index 9cb23192..413f0c77 100644 --- a/src/createListComponent.js +++ b/src/createListComponent.js @@ -55,10 +55,11 @@ export type Props = {| width: number | string, |}; -type State = {| +export type State = {| isScrolling: boolean, scrollDirection: ScrollDirection, scrollOffset: number, + scrollOffsetDelta: number, scrollUpdateWasRequested: boolean, |}; @@ -140,6 +141,7 @@ export default function createListComponent({ typeof this.props.initialScrollOffset === 'number' ? this.props.initialScrollOffset : 0, + scrollOffsetDelta: 0, scrollUpdateWasRequested: false, }; @@ -200,7 +202,7 @@ export default function createListComponent({ this._commitHook(); } - componentDidUpdate() { + componentDidUpdate(prevProps: Props, prevState: State) { const { direction } = this.props; const { scrollOffset, scrollUpdateWasRequested } = this.state; @@ -214,7 +216,7 @@ export default function createListComponent({ } this._callPropsCallbacks(); - this._commitHook(); + this._commitHook(prevProps, prevState); } componentWillUnmount() { @@ -236,7 +238,7 @@ export default function createListComponent({ style, width, } = this.props; - const { isScrolling } = this.state; + const { isScrolling, scrollOffsetDelta } = this.state; const onScroll = direction === 'vertical' @@ -259,7 +261,6 @@ export default function createListComponent({ onScroll, ref: this._outerRefSetter, style: { - position: 'relative', height, width, overflow: 'auto', @@ -272,7 +273,16 @@ export default function createListComponent({ children: items, ref: innerRef, style: { + position: 'relative', height: direction === 'horizontal' ? '100%' : estimatedTotalSize, + marginLeft: + direction === 'horizontal' + ? `-${scrollOffsetDelta}px` + : undefined, + marginTop: + direction === 'horizontal' + ? undefined + : `${-scrollOffsetDelta}px`, pointerEvents: isScrolling ? 'none' : '', width: direction === 'horizontal' ? estimatedTotalSize : '100%', }, @@ -354,7 +364,7 @@ export default function createListComponent({ // This method is called after mount and update. // List implementations can override this method to be notified. - _commitHook() {} + _commitHook(prevProps?: Props, prevState?: State) {} // This method is called before unmounting. // List implementations can override this method to be notified. @@ -414,7 +424,7 @@ export default function createListComponent({ _getRangeToRender(): [number, number, number, number] { const { itemCount, overscanCount } = this.props; - const { scrollDirection, scrollOffset } = this.state; + const { scrollDirection, scrollOffset, scrollOffsetDelta } = this.state; if (itemCount === 0) { return [0, 0, 0, 0]; @@ -422,13 +432,13 @@ export default function createListComponent({ const startIndex = getStartIndexForOffset( this.props, - scrollOffset, + scrollOffset + scrollOffsetDelta, this._instanceProps ); const stopIndex = getStopIndexForStartIndex( this.props, startIndex, - scrollOffset, + scrollOffset + scrollOffsetDelta, this._instanceProps ); From 7978b6f886ce15edca1d52a8f5e711a963e164a0 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Fri, 7 Dec 2018 10:39:09 -0800 Subject: [PATCH 27/37] Fixed a dypo in example code --- website/src/code/DynamicSizeListVertical.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/src/code/DynamicSizeListVertical.js b/website/src/code/DynamicSizeListVertical.js index a13b1a8d..801b1fa6 100644 --- a/website/src/code/DynamicSizeListVertical.js +++ b/website/src/code/DynamicSizeListVertical.js @@ -1,7 +1,7 @@ import { DynamicSizeList as List } from 'react-window'; // This example assumes 'items' is an array of strings. -// // Your application may render a more complex item, +// Your application may render a more complex item, // Like the one shown in this demo. const Row = ({ index, style }) => (
items[index]
From cdf3a5a26ee3ac4e517e0fe7366d0af140a498e2 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Fri, 7 Dec 2018 10:51:16 -0800 Subject: [PATCH 28/37] Fixed small regression caught by unit test --- src/DynamicSizeList.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/DynamicSizeList.js b/src/DynamicSizeList.js index 5501fdf6..76be9208 100644 --- a/src/DynamicSizeList.js +++ b/src/DynamicSizeList.js @@ -370,7 +370,10 @@ const DynamicSizeList = createListComponent({ // If we get to close to the start of the list (either offset 0 or the first item) // we should synchronously adjustour offsets without waiting for the debounce. // This is still an awkward scrolling UX but it's hopefully not a common case. - if (startIndex === 0 || scrollOffset + scrollOffsetDelta <= 0) { + if ( + scrollOffsetDelta > 0 && + (startIndex === 0 || scrollOffset + scrollOffsetDelta <= 0) + ) { instance.setState( { scrollOffset: scrollOffset + scrollOffsetDelta, From 4714399bc0bfdd048d3a24a38725a8a9a5b99cba Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Fri, 7 Dec 2018 10:52:24 -0800 Subject: [PATCH 29/37] Update package JSON alpha version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index eba09096..4f2bb7dd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-window", - "version": "1.3.1", + "version": "1.4.0-alpha.0", "description": "React components for efficiently rendering large, scrollable lists and tabular data", "author": From 6bab06b5265e0f321af33efa1dc672416610e2b4 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Fri, 7 Dec 2018 11:07:02 -0800 Subject: [PATCH 30/37] Fixed small CSS bug --- package.json | 2 +- src/createListComponent.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 4f2bb7dd..93b9800d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-window", - "version": "1.4.0-alpha.0", + "version": "1.4.0-alpha.1", "description": "React components for efficiently rendering large, scrollable lists and tabular data", "author": diff --git a/src/createListComponent.js b/src/createListComponent.js index 413f0c77..8dea628d 100644 --- a/src/createListComponent.js +++ b/src/createListComponent.js @@ -277,7 +277,7 @@ export default function createListComponent({ height: direction === 'horizontal' ? '100%' : estimatedTotalSize, marginLeft: direction === 'horizontal' - ? `-${scrollOffsetDelta}px` + ? `${-scrollOffsetDelta}px` : undefined, marginTop: direction === 'horizontal' From fe88aebbee004c0a1e6bf4368d000fb77880f8df Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Fri, 7 Dec 2018 13:04:43 -0800 Subject: [PATCH 31/37] Replaced margin top/left hack with scrollBy() --- src/DynamicSizeList.js | 88 ++++++++++---------------------------- src/createListComponent.js | 28 ++++-------- 2 files changed, 32 insertions(+), 84 deletions(-) diff --git a/src/DynamicSizeList.js b/src/DynamicSizeList.js index 76be9208..6ec34f0f 100644 --- a/src/DynamicSizeList.js +++ b/src/DynamicSizeList.js @@ -5,7 +5,7 @@ import { createElement } from 'react'; import createListComponent, { defaultItemKey } from './createListComponent'; import ItemMeasurer from './ItemMeasurer'; -import type { Props, ScrollToAlign, State } from './createListComponent'; +import type { Props, ScrollToAlign } from './createListComponent'; const DEFAULT_ESTIMATED_ITEM_SIZE = 50; @@ -277,7 +277,7 @@ const DynamicSizeList = createListComponent({ let sizeDeltaTotal = 0; // This method is called after mount and update. - instance._commitHook = (prevProps?: Props, prevState?: State) => { + instance._commitHook = () => { if (hasNewMeasurements) { hasNewMeasurements = false; @@ -307,17 +307,10 @@ const DynamicSizeList = createListComponent({ ) { // TRICKY // If item(s) have changed size since they were last displayed, content will appear to jump. - // To avoid this, we may need to make small adjustments as a user scrolls to preserve apparent position. - // The most intuitive way to do this would be to adjust the scroll offset directly, - // but changing scroll offsets interrupts smooth scrolling for some browser's (e.g. Firefox). - // Instead we temporarily offset item positions by adjusting top/left margin on the container. - // Then after a delay/debounce (once scrolling has stopped) we remove this and adjust the offset. - // If we get to close to offset 0 (or item 0) we'll need to force this update, - // but that seems acceptable since the scrolling animation would be interrupted in that event anyway. + // To avoid this, we need to make small adjustments as a user scrolls to preserve apparent position. + // This also ensures that the first item eventually aligns with scroll offset 0. return { scrollOffset: prevState.scrollOffset + sizeDeltaForStateUpdate, - scrollOffsetDelta: - prevState.scrollOffsetDelta + sizeDeltaForStateUpdate, }; } else { // There's no state to update, @@ -330,66 +323,31 @@ const DynamicSizeList = createListComponent({ () => { if (shouldForceUpdate) { instance.forceUpdate(); - } + } else { + const { scrollOffset } = instance.state; + const { direction } = instance.props; - sizeDeltaTotal -= sizeDeltaForStateUpdate; - } - ); - } else if (prevState != null) { - if ( - !instance.state.isScrolling && - prevState.isScrolling && - prevState.scrollOffsetDelta !== 0 - ) { - const { scrollOffset, scrollOffsetDelta } = instance.state; - - // TRICKY - // Clean up any temporary offset adjustments (made above) once scrolling has stopped. - // Be sure to update the scrollOffset in state before doing this, - // or the "scroll" event it triggers will cause a new batch of items to be rendered. - instance.setState( - { - scrollOffset: scrollOffset + scrollOffsetDelta, - scrollOffsetDelta: 0, - }, - () => { + // Adjusting scroll offset directly interrupts smooth scrolling for some browsers (e.g. Firefox). + // The relative scrollBy() method doesn't interrupt (or at least it won't as of Firefox v65). + // Other browsers (e.g. Chrome, Safari) seem to handle both adjustments equally well. + // See https://bugzilla.mozilla.org/show_bug.cgi?id=1502059 const element = ((instance._outerRef: any): HTMLDivElement); - if (instance.props.direction === 'horizontal') { - element.scrollLeft = scrollOffset + scrollOffsetDelta; + // $FlowFixMe Property scrollBy is missing in HTMLDivElement + if (typeof element.scrollBy === 'function') { + element.scrollBy( + direction === 'horizontal' ? sizeDeltaForStateUpdate : 0, + direction === 'horizontal' ? 0 : sizeDeltaForStateUpdate + ); + } else if (direction === 'horizontal') { + element.scrollLeft = scrollOffset; } else { - element.scrollTop = scrollOffset + scrollOffsetDelta; + element.scrollTop = scrollOffset; } } - ); - } else { - // eslint-disable-next-line no-unused-vars - const [_, __, startIndex] = instance._getRangeToRender(); - const { scrollOffset, scrollOffsetDelta } = instance.state; - - // TRICKY - // If we get to close to the start of the list (either offset 0 or the first item) - // we should synchronously adjustour offsets without waiting for the debounce. - // This is still an awkward scrolling UX but it's hopefully not a common case. - if ( - scrollOffsetDelta > 0 && - (startIndex === 0 || scrollOffset + scrollOffsetDelta <= 0) - ) { - instance.setState( - { - scrollOffset: scrollOffset + scrollOffsetDelta, - scrollOffsetDelta: 0, - }, - () => { - const element = ((instance._outerRef: any): HTMLDivElement); - if (instance.props.direction === 'horizontal') { - element.scrollLeft = scrollOffset + scrollOffsetDelta; - } else { - element.scrollTop = scrollOffset + scrollOffsetDelta; - } - } - ); + + sizeDeltaTotal -= sizeDeltaForStateUpdate; } - } + ); } }; diff --git a/src/createListComponent.js b/src/createListComponent.js index 8dea628d..bf6d3aaf 100644 --- a/src/createListComponent.js +++ b/src/createListComponent.js @@ -55,11 +55,10 @@ export type Props = {| width: number | string, |}; -export type State = {| +type State = {| isScrolling: boolean, scrollDirection: ScrollDirection, scrollOffset: number, - scrollOffsetDelta: number, scrollUpdateWasRequested: boolean, |}; @@ -141,7 +140,6 @@ export default function createListComponent({ typeof this.props.initialScrollOffset === 'number' ? this.props.initialScrollOffset : 0, - scrollOffsetDelta: 0, scrollUpdateWasRequested: false, }; @@ -202,7 +200,7 @@ export default function createListComponent({ this._commitHook(); } - componentDidUpdate(prevProps: Props, prevState: State) { + componentDidUpdate() { const { direction } = this.props; const { scrollOffset, scrollUpdateWasRequested } = this.state; @@ -216,7 +214,7 @@ export default function createListComponent({ } this._callPropsCallbacks(); - this._commitHook(prevProps, prevState); + this._commitHook(); } componentWillUnmount() { @@ -238,7 +236,7 @@ export default function createListComponent({ style, width, } = this.props; - const { isScrolling, scrollOffsetDelta } = this.state; + const { isScrolling } = this.state; const onScroll = direction === 'vertical' @@ -264,6 +262,7 @@ export default function createListComponent({ height, width, overflow: 'auto', + position: 'relative', WebkitOverflowScrolling: 'touch', willChange: 'transform', ...style, @@ -273,16 +272,7 @@ export default function createListComponent({ children: items, ref: innerRef, style: { - position: 'relative', height: direction === 'horizontal' ? '100%' : estimatedTotalSize, - marginLeft: - direction === 'horizontal' - ? `${-scrollOffsetDelta}px` - : undefined, - marginTop: - direction === 'horizontal' - ? undefined - : `${-scrollOffsetDelta}px`, pointerEvents: isScrolling ? 'none' : '', width: direction === 'horizontal' ? estimatedTotalSize : '100%', }, @@ -364,7 +354,7 @@ export default function createListComponent({ // This method is called after mount and update. // List implementations can override this method to be notified. - _commitHook(prevProps?: Props, prevState?: State) {} + _commitHook() {} // This method is called before unmounting. // List implementations can override this method to be notified. @@ -424,7 +414,7 @@ export default function createListComponent({ _getRangeToRender(): [number, number, number, number] { const { itemCount, overscanCount } = this.props; - const { scrollDirection, scrollOffset, scrollOffsetDelta } = this.state; + const { scrollDirection, scrollOffset } = this.state; if (itemCount === 0) { return [0, 0, 0, 0]; @@ -432,13 +422,13 @@ export default function createListComponent({ const startIndex = getStartIndexForOffset( this.props, - scrollOffset + scrollOffsetDelta, + scrollOffset, this._instanceProps ); const stopIndex = getStopIndexForStartIndex( this.props, startIndex, - scrollOffset + scrollOffsetDelta, + scrollOffset, this._instanceProps ); From 670991285a3e92e5fa69942bf7967bc16ef970ee Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Fri, 7 Dec 2018 15:26:05 -0800 Subject: [PATCH 32/37] Only use findDOMNode as a fallback (with a warning) --- src/ItemMeasurer.js | 57 +++++++++++++++++-- src/__tests__/DynamicSizeList.js | 45 ++++++++++++++- .../src/routes/examples/DynamicSizeList.js | 17 ++++-- 3 files changed, 107 insertions(+), 12 deletions(-) diff --git a/src/ItemMeasurer.js b/src/ItemMeasurer.js index ccab79b5..86ad6c8d 100644 --- a/src/ItemMeasurer.js +++ b/src/ItemMeasurer.js @@ -1,6 +1,6 @@ // @flow -import { Component } from 'react'; +import { cloneElement, Component } from 'react'; import { findDOMNode } from 'react-dom'; import type { Direction } from './createListComponent'; @@ -44,13 +44,39 @@ type ItemMeasurerProps = {| size: number, |}; +let findDOMNodeWarningsSet = ((null: any): Set); +if (process.env.NODE_ENV !== 'production') { + findDOMNodeWarningsSet = new Set(); +} + export default class ItemMeasurer extends Component { + _didProvideValidRef: boolean = false; _node: HTMLElement | null = null; _resizeObserver: ResizeObserver | null = null; componentDidMount() { - const node = ((findDOMNode(this): any): HTMLElement); - this._node = node; + if (process.env.NODE_ENV !== 'production') { + if (!this._didProvideValidRef) { + const { item } = this.props; + + const displayName = + item && item.type + ? item.type.displayName || item.type.name || '(unknown)' + : '(unknown)'; + + if (!findDOMNodeWarningsSet.has(displayName)) { + findDOMNodeWarningsSet.add(displayName); + + console.warn( + 'DynamicSizeList item renderers should attach a ref to the topmost HTMLElement they render. ' + + `The item renderer "${displayName}" did not attach a ref to a valid HTMLElement. ` + + 'findDOMNode() will be used as a fallback, but is slower and more error prone than using a ref.\n\n' + + 'Learn more about ref forwarding: ' + + 'https://reactjs.org/docs/forwarding-refs.html#forwarding-refs-to-dom-components' + ); + } + } + } // Force sync measure for the initial mount. // This is necessary to support the DynamicSizeList layout logic. @@ -60,7 +86,9 @@ export default class ItemMeasurer extends Component { // Watch for resizes due to changed content, // Or changes in the size of the parent container. this._resizeObserver = new ResizeObserver(this._onResize); - this._resizeObserver.observe(node); + if (this._node !== null) { + this._resizeObserver.observe(this._node); + } } } @@ -71,7 +99,9 @@ export default class ItemMeasurer extends Component { } render() { - return this.props.item; + return cloneElement(this.props.item, { + ref: this._refSetter, + }); } _measureItem = (isCommitPhase: boolean) => { @@ -101,6 +131,23 @@ export default class ItemMeasurer extends Component { } }; + _refSetter = (ref: any) => { + if (this._resizeObserver !== null && this._node !== null) { + this._resizeObserver.unobserve(this._node); + } + + if (ref instanceof HTMLElement) { + this._didProvideValidRef = true; + this._node = ref; + } else if (ref !== null) { + this._node = ((findDOMNode(ref): any): HTMLElement); + } + + if (this._resizeObserver !== null && this._node !== null) { + this._resizeObserver.observe(this._node); + } + }; + _onResize = () => { this._measureItem(false); }; diff --git a/src/__tests__/DynamicSizeList.js b/src/__tests__/DynamicSizeList.js index 1c334e17..98bc0bb1 100644 --- a/src/__tests__/DynamicSizeList.js +++ b/src/__tests__/DynamicSizeList.js @@ -1,5 +1,6 @@ import React, { createRef, + forwardRef, PureComponent, unstable_Profiler as Profiler, } from 'react'; @@ -39,19 +40,25 @@ describe('DynamicSizeList', () => { } } + const RefForwarder = forwardRef((props, ref) => ( + + )); + beforeEach(() => { jest.useFakeTimers(); container = document.createElement('div'); itemSizes = [20, 25, 30, 35, 40]; - itemRenderer = jest.fn(({ style, ...rest }) => ( -
{JSON.stringify(rest, null, 2)}
+ itemRenderer = jest.fn(({ forwardedRef, style, ...rest }) => ( +
+ {JSON.stringify(rest, null, 2)} +
)); onItemsRendered = jest.fn(); innerRef = createRef(); defaultProps = { - children: PureItemRenderer, + children: RefForwarder, estimatedItemSize: 25, height: 100, innerRef, @@ -100,4 +107,36 @@ describe('DynamicSizeList', () => { expect(onRender.mock.calls).toHaveLength(2); }); + + describe('ref forwarding', () => { + it('should warn if ref is not forwarded', () => { + class ItemRenderer extends PureComponent { + render() { + const { index, style } = this.props; + return
{index}
; + } + } + + console.warn = jest.fn(); + render( + + {ItemRenderer} + , + container + ); + expect(console.warn).toHaveBeenCalledTimes(1); + expect(console.warn.mock.calls[0][0]).toContain( + 'The item renderer "ItemRenderer" did not attach a ref' + ); + + // It should only warn once per item renderer type to avoid spamming the console. + render( + + {ItemRenderer} + , + container + ); + expect(console.warn).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/website/src/routes/examples/DynamicSizeList.js b/website/src/routes/examples/DynamicSizeList.js index e2acbd9d..ae5742f7 100644 --- a/website/src/routes/examples/DynamicSizeList.js +++ b/website/src/routes/examples/DynamicSizeList.js @@ -49,12 +49,13 @@ class Row extends PureComponent { }; render() { - const { index, style } = this.props; + const { index, forwardedRef, style } = this.props; const item = items[index]; return (
( + +)); +const RefForwardedRow = React.forwardRef((props, ref) => ( + +)); + class Column extends PureComponent { toggleExpanded = () => { const { index } = this.props; @@ -85,7 +93,7 @@ class Column extends PureComponent { }; render() { - const { data: showText, index, style } = this.props; + const { data: showText, forwardedRef, index, style } = this.props; const item = items[index]; return ( @@ -93,6 +101,7 @@ class Column extends PureComponent { className={ index % 2 ? styles.DynamicColumnOdd : styles.DynamicColumnEven } + ref={forwardedRef} style={style} >
- {Row} + {RefForwardedRow}
@@ -197,7 +206,7 @@ export default class DynamicSizeList extends PureComponent { itemData={showText} width={300} > - {Column} + {RefForwardedColumn}
From e69da689679db9c5edb1af0acd6121519add052d Mon Sep 17 00:00:00 2001 From: Damian Pieczynski Date: Thu, 14 Mar 2019 09:52:37 +0100 Subject: [PATCH 33/37] Merge master --- CHANGELOG.md | 16 ++ LICENSE.md | 21 ++ README.md | 43 +++- package.json | 12 +- rollup.config.js | 1 + src/DynamicSizeList.js | 29 ++- src/FixedSizeGrid.js | 10 +- src/FixedSizeList.js | 12 +- src/ItemMeasurer.js | 6 +- src/VariableSizeGrid.js | 32 ++- src/VariableSizeList.js | 18 +- src/__tests__/FixedSizeGrid.js | 191 +++++++++++++++++- src/__tests__/FixedSizeList.js | 135 +++++++++++-- src/__tests__/VariableSizeGrid.js | 137 ++++++++++++- src/__tests__/VariableSizeList.js | 43 +++- src/createGridComponent.js | 151 +++++++++++--- src/createListComponent.js | 187 ++++++++++++----- src/domHelpers.js | 22 ++ .../dynamic-size-list-horizontal/index.js | 2 +- .../fixed-size-list-horizontal/index.js | 2 +- .../memoized-list-items/package.json | 2 +- .../variable-size-list-horizontal/index.js | 2 +- website/src/App.js | 6 + website/src/code/DynamicSizeListHorizontal.js | 2 +- website/src/code/FixedSizeGridRtl.js | 21 ++ website/src/code/FixedSizeListHorizontal.js | 2 +- .../src/code/FixedSizeListHorizontalRtl.js | 18 ++ .../src/code/VariableSizeListHorizontal.js | 2 +- website/src/components/ProfiledExample.js | 2 +- website/src/routes/api/FixedSizeGrid.js | 25 +++ website/src/routes/api/FixedSizeList.js | 37 +++- .../src/routes/examples/DynamicSizeList.js | 2 +- website/src/routes/examples/FixedSizeList.js | 2 +- .../examples/MemoizedListItemsExample.js | 15 +- website/src/routes/examples/RTLLayout.js | 99 +++++++++ website/src/routes/examples/ScrollToItem.js | 9 +- .../src/routes/examples/VariableSizeGrid.js | 9 +- .../src/routes/examples/VariableSizeList.js | 11 +- website/src/utils.js | 7 + website/yarn.lock | 7 +- yarn.lock | 133 +++++++----- 41 files changed, 1239 insertions(+), 244 deletions(-) create mode 100644 LICENSE.md create mode 100644 src/domHelpers.js create mode 100644 website/src/code/FixedSizeGridRtl.js create mode 100644 website/src/code/FixedSizeListHorizontalRtl.js create mode 100644 website/src/routes/examples/RTLLayout.js create mode 100644 website/src/utils.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e85b535..05b52c13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,22 @@ Changelog ------------ +### 1.6.2 +* 🐛 Bugfix for RTL when scrolling back towards the beginning (right) of the list. + +### 1.6.1 +* 🐛 Bugfix to account for differences between Chrome and non-Chrome browsers with regard to RTL and "scroll" events. + +### 1.6.0 +* 🎉 RTL support added for lists and grids. Special thanks to [davidgarsan](https://github.com/davidgarsan) for his support. - [#156](https://github.com/bvaughn/react-window/pull/156) +* 🐛 Grid `scrollToItem` methods take scrollbar size into account when aligning items - [#153](https://github.com/bvaughn/react-window/issues/153) + +### 1.5.2 +* 🐛 Edge case bug fix for `VariableSizeList` and `VariableSizeGrid` when the number of items decreases while a scroll is in progress. - ([iamsolankiamit](https://github.com/iamsolankiamit) - [#138](https://github.com/bvaughn/react-window/pull/138)) + +### 1.5.1 +* 🐛 Updated `getDerivedState` Flow annotations to address a warning in a newer version of Flow. + ### 1.5.0 * 🎉 Added advanced memoization helpers methods `areEqual` and `shouldComponentUpdate` for item renderers. - [#114](https://github.com/bvaughn/react-window/issues/114) diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 00000000..0569d0a7 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2018 Brian Vaughn + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 87da7279..f5dcb998 100644 --- a/README.md +++ b/README.md @@ -16,11 +16,16 @@ npm install --save react-window ## Usage -Learn more at [react-window.now.sh](https://react-window.now.sh/). +Learn more at [react-window.now.sh](https://react-window.now.sh/): + +## Related libraries + +* [`react-virtualized-auto-sizer`](https://npmjs.com/package/react-virtualized-auto-sizer): HOC that grows to fit all of the available space and passes the width and height values to its child. +* [`react-window-infinite-loader`](https://npmjs.com/package/react-window-infinite-loader): Helps break large data sets down into chunks that can be just-in-time loaded as they are scrolled into view. It can also be used to create infinite loading lists (e.g. Facebook or Twitter). ## Frequently asked questions -#### How is `react-window` different from `react-virtualized`? +### How is `react-window` different from `react-virtualized`? I wrote `react-virtualized` several years ago. At the time, I was new to both React and the concept of windowing. Because of this, I made a few API decisions that I later came to regret. One of these was adding too many non-essential features and components. Once you add something to an open source project, removing it is pretty painful for users. `react-window` is a complete rewrite of `react-virtualized`. I didn't try to solve as many problems or support as many use cases. Instead I focused on making the package **smaller**1 and **faster**. I also put a lot of thought into making the API (and documentation) as beginner-friendly as possible (with the caveat that windowing is still kind of an advanced use case). @@ -32,6 +37,40 @@ If `react-window` provides the functionality your project needs, I would strongl 1 - Adding a `react-virtualized` list to a CRA project increases the (gzipped) build size by ~33.5 KB. Adding a `react-window` list to a CRA project increases the (gzipped) build size by <2 KB. +### Can a list or a grid fill 100% the width or height of a page? + +Yes. I recommend using the [`react-virtualized-auto-sizer` package](https://npmjs.com/package/react-virtualized-auto-sizer): + +screen shot 2019-03-07 at 7 29 08 pm + +Here's a [Code Sandbox demo](https://codesandbox.io/s/3vnx878jk5). + +### Why is my list blank when I scroll? + +If your list looks something like this... + + + +...then you probably forgot to use the `style` parameter! Libraries like react-window work by absolutely positioning the list items (via an inline style), so don't forget to attach it to the DOM element you render! + +screen shot 2019-03-07 at 7 21 48 pm + +### Can I lazy load data for my list? + +Yes. I recommend using the [`react-window-infinite-loader` package](https://npmjs.com/package/react-window-infinite-loader): + +screen shot 2019-03-07 at 7 32 32 pm + +Here's a [Code Sandbox demo](https://codesandbox.io/s/5wqo7z2np4). + +### Can I attach custom properties or event handlers? + +Yes, using the `outerElementType` prop. + +Screen Shot 2019-03-12 at 8 58 09 AM + +Here's a [Code Sandbox demo](https://codesandbox.io/s/4zqx79nww0). + ## License MIT © [bvaughn](https://github.com/bvaughn) diff --git a/package.json b/package.json index f56c7095..7906a59c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-window", - "version": "1.6.0-alpha.1", + "version": "1.7.0-alpha.1", "description": "React components for efficiently rendering large, scrollable lists and tabular data", "author": @@ -61,7 +61,7 @@ }, "dependencies": { "@babel/runtime": "^7.0.0", - "memoize-one": "^3.1.1" + "memoize-one": ">=3.1.1 <6" }, "peerDependencies": { "react": "^15.0.0 || ^16.0.0", @@ -99,9 +99,9 @@ "react-dom": "^16.7.0", "react-scripts": "^1.1.1", "react-test-renderer": "^16.7.0", - "rollup": "^0.65.0", - "rollup-plugin-babel": "^4.0.2", - "rollup-plugin-commonjs": "^8.2.1", - "rollup-plugin-node-resolve": "^3.0.2" + "rollup": "^1.4.1", + "rollup-plugin-babel": "^4.3.2", + "rollup-plugin-commonjs": "^9.2.1", + "rollup-plugin-node-resolve": "^4.0.1" } } diff --git a/rollup.config.js b/rollup.config.js index 5d167d14..c05d722a 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -1,6 +1,7 @@ import babel from 'rollup-plugin-babel'; import commonjs from 'rollup-plugin-commonjs'; import nodeResolve from 'rollup-plugin-node-resolve'; + import pkg from './package.json'; const input = './src/index.js'; diff --git a/src/DynamicSizeList.js b/src/DynamicSizeList.js index 6ec34f0f..26149e65 100644 --- a/src/DynamicSizeList.js +++ b/src/DynamicSizeList.js @@ -152,7 +152,7 @@ const DynamicSizeList = createListComponent({ scrollOffset: number, instanceProps: InstanceProps ): number => { - const { direction, height, width } = props; + const { direction, layout, height, width } = props; if (process.env.NODE_ENV !== 'production') { const { lastMeasuredIndex } = instanceProps; @@ -164,7 +164,9 @@ const DynamicSizeList = createListComponent({ } } - const size = (((direction === 'horizontal' ? width : height): any): number); + const size = (((direction === 'horizontal' || layout === 'horizontal' + ? width + : height): any): number); const itemMetadata = getItemMetadata(props, index, instanceProps); // Get estimated total size after ItemMetadata is computed, @@ -225,9 +227,11 @@ const DynamicSizeList = createListComponent({ scrollOffset: number, instanceProps: InstanceProps ): number => { - const { direction, height, itemCount, width } = props; + const { direction, layout, height, itemCount, width } = props; - const size = (((direction === 'horizontal' ? width : height): any): number); + const size = (((direction === 'horizontal' || layout === 'horizontal' + ? width + : height): any): number); const itemMetadata = getItemMetadata(props, startIndex, instanceProps); const maxOffset = scrollOffset + size; @@ -325,7 +329,7 @@ const DynamicSizeList = createListComponent({ instance.forceUpdate(); } else { const { scrollOffset } = instance.state; - const { direction } = instance.props; + const { direction, layout } = instance.props; // Adjusting scroll offset directly interrupts smooth scrolling for some browsers (e.g. Firefox). // The relative scrollBy() method doesn't interrupt (or at least it won't as of Firefox v65). @@ -335,10 +339,17 @@ const DynamicSizeList = createListComponent({ // $FlowFixMe Property scrollBy is missing in HTMLDivElement if (typeof element.scrollBy === 'function') { element.scrollBy( - direction === 'horizontal' ? sizeDeltaForStateUpdate : 0, - direction === 'horizontal' ? 0 : sizeDeltaForStateUpdate + direction === 'horizontal' || layout === 'horizontal' + ? sizeDeltaForStateUpdate + : 0, + direction === 'horizontal' || layout === 'horizontal' + ? 0 + : sizeDeltaForStateUpdate ); - } else if (direction === 'horizontal') { + } else if ( + direction === 'horizontal' || + layout === 'horizontal' + ) { element.scrollLeft = scrollOffset; } else { element.scrollTop = scrollOffset; @@ -418,6 +429,7 @@ const DynamicSizeList = createListComponent({ const { children, direction, + layout, itemCount, itemData, itemKey = defaultItemKey, @@ -451,6 +463,7 @@ const DynamicSizeList = createListComponent({ items.push( createElement(ItemMeasurer, { direction, + layout, handleNewMeasurements, index, item, diff --git a/src/FixedSizeGrid.js b/src/FixedSizeGrid.js index f446acb8..6224f71d 100644 --- a/src/FixedSizeGrid.js +++ b/src/FixedSizeGrid.js @@ -27,7 +27,9 @@ const FixedSizeGrid = createGridComponent({ { columnCount, columnWidth, width }: Props, columnIndex: number, align: ScrollToAlign, - scrollLeft: number + scrollLeft: number, + instanceProps: typeof undefined, + scrollbarSize: number ): number => { const maxOffset = Math.max( 0, @@ -40,6 +42,7 @@ const FixedSizeGrid = createGridComponent({ 0, columnIndex * ((columnWidth: any): number) - width + + scrollbarSize + ((columnWidth: any): number) ); @@ -66,7 +69,9 @@ const FixedSizeGrid = createGridComponent({ { rowHeight, height, rowCount }: Props, rowIndex: number, align: ScrollToAlign, - scrollTop: number + scrollTop: number, + instanceProps: typeof undefined, + scrollbarSize: number ): number => { const maxOffset = Math.max( 0, @@ -79,6 +84,7 @@ const FixedSizeGrid = createGridComponent({ 0, rowIndex * ((rowHeight: any): number) - height + + scrollbarSize + ((rowHeight: any): number) ); diff --git a/src/FixedSizeList.js b/src/FixedSizeList.js index 7491d91d..498a8c9b 100644 --- a/src/FixedSizeList.js +++ b/src/FixedSizeList.js @@ -15,12 +15,14 @@ const FixedSizeList = createListComponent({ ((itemSize: any): number) * itemCount, getOffsetForIndexAndAlignment: ( - { direction, height, itemCount, itemSize, width }: Props, + { direction, height, itemCount, itemSize, layout, width }: Props, index: number, align: ScrollToAlign, scrollOffset: number ): number => { - const size = (((direction === 'horizontal' ? width : height): any): number); + // TODO Deprecate direction "horizontal" + const isHorizontal = direction === 'horizontal' || layout === 'horizontal'; + const size = (((isHorizontal ? width : height): any): number); const maxOffset = Math.max( 0, Math.min( @@ -62,12 +64,14 @@ const FixedSizeList = createListComponent({ ), getStopIndexForStartIndex: ( - { direction, height, itemCount, itemSize, width }: Props, + { direction, height, itemCount, itemSize, layout, width }: Props, startIndex: number, scrollOffset: number ): number => { + // TODO Deprecate direction "horizontal" + const isHorizontal = direction === 'horizontal' || layout === 'horizontal'; const offset = startIndex * ((itemSize: any): number); - const size = (((direction === 'horizontal' ? width : height): any): number); + const size = (((isHorizontal ? width : height): any): number); return Math.max( 0, Math.min( diff --git a/src/ItemMeasurer.js b/src/ItemMeasurer.js index 86ad6c8d..763e1aba 100644 --- a/src/ItemMeasurer.js +++ b/src/ItemMeasurer.js @@ -3,7 +3,7 @@ import { cloneElement, Component } from 'react'; import { findDOMNode } from 'react-dom'; -import type { Direction } from './createListComponent'; +import type { Direction, Layout } from './createListComponent'; import type { HandleNewMeasurements } from './DynamicSizeList'; class DOMRectReadOnly { @@ -38,6 +38,7 @@ declare class ResizeObserver { type ItemMeasurerProps = {| direction: Direction, + layout: Layout, handleNewMeasurements: HandleNewMeasurements, index: number, item: React$Element, @@ -107,6 +108,7 @@ export default class ItemMeasurer extends Component { _measureItem = (isCommitPhase: boolean) => { const { direction, + layout, handleNewMeasurements, index, size: oldSize, @@ -121,7 +123,7 @@ export default class ItemMeasurer extends Component { node instanceof node.ownerDocument.defaultView.HTMLElement ) { const newSize = - direction === 'horizontal' + direction === 'horizontal' || layout === 'horizontal' ? Math.ceil(node.offsetWidth) : Math.ceil(node.offsetHeight); diff --git a/src/VariableSizeGrid.js b/src/VariableSizeGrid.js index bf31f207..061b3064 100644 --- a/src/VariableSizeGrid.js +++ b/src/VariableSizeGrid.js @@ -35,6 +35,12 @@ const getEstimatedTotalHeight = ( ) => { let totalSizeOfMeasuredRows = 0; + // Edge case check for when the number of items decreases while a scroll is in progress. + // https://github.com/bvaughn/react-window/pull/138 + if (lastMeasuredRowIndex >= rowCount) { + lastMeasuredRowIndex = rowCount - 1; + } + if (lastMeasuredRowIndex >= 0) { const itemMetadata = rowMetadataMap[lastMeasuredRowIndex]; totalSizeOfMeasuredRows = itemMetadata.offset + itemMetadata.size; @@ -56,6 +62,12 @@ const getEstimatedTotalWidth = ( ) => { let totalSizeOfMeasuredRows = 0; + // Edge case check for when the number of items decreases while a scroll is in progress. + // https://github.com/bvaughn/react-window/pull/138 + if (lastMeasuredColumnIndex >= columnCount) { + lastMeasuredColumnIndex = columnCount - 1; + } + if (lastMeasuredColumnIndex >= 0) { const itemMetadata = columnMetadataMap[lastMeasuredColumnIndex]; totalSizeOfMeasuredRows = itemMetadata.offset + itemMetadata.size; @@ -221,7 +233,8 @@ const getOffsetForIndexAndAlignment = ( index: number, align: ScrollToAlign, scrollOffset: number, - instanceProps: InstanceProps + instanceProps: InstanceProps, + scrollbarSize: number ): number => { const size = itemType === 'column' ? props.width : props.height; const itemMetadata = getItemMetadata(itemType, props, index, instanceProps); @@ -237,7 +250,10 @@ const getOffsetForIndexAndAlignment = ( 0, Math.min(estimatedTotalSize - size, itemMetadata.offset) ); - const minOffset = Math.max(0, itemMetadata.offset - size + itemMetadata.size); + const minOffset = Math.max( + 0, + itemMetadata.offset - size + scrollbarSize + itemMetadata.size + ); switch (align) { case 'start': @@ -312,7 +328,8 @@ const VariableSizeGrid = createGridComponent({ index: number, align: ScrollToAlign, scrollOffset: number, - instanceProps: InstanceProps + instanceProps: InstanceProps, + scrollbarSize: number ): number => getOffsetForIndexAndAlignment( 'column', @@ -320,7 +337,8 @@ const VariableSizeGrid = createGridComponent({ index, align, scrollOffset, - instanceProps + instanceProps, + scrollbarSize ), getOffsetForRowAndAlignment: ( @@ -328,7 +346,8 @@ const VariableSizeGrid = createGridComponent({ index: number, align: ScrollToAlign, scrollOffset: number, - instanceProps: InstanceProps + instanceProps: InstanceProps, + scrollbarSize: number ): number => getOffsetForIndexAndAlignment( 'row', @@ -336,7 +355,8 @@ const VariableSizeGrid = createGridComponent({ index, align, scrollOffset, - instanceProps + instanceProps, + scrollbarSize ), getRowOffset: ( diff --git a/src/VariableSizeList.js b/src/VariableSizeList.js index 4bec6b78..806beac1 100644 --- a/src/VariableSizeList.js +++ b/src/VariableSizeList.js @@ -146,6 +146,12 @@ const getEstimatedTotalSize = ( ) => { let totalSizeOfMeasuredItems = 0; + // Edge case check for when the number of items decreases while a scroll is in progress. + // https://github.com/bvaughn/react-window/pull/138 + if (lastMeasuredIndex >= itemCount) { + lastMeasuredIndex = itemCount - 1; + } + if (lastMeasuredIndex >= 0) { const itemMetadata = itemMetadataMap[lastMeasuredIndex]; totalSizeOfMeasuredItems = itemMetadata.offset + itemMetadata.size; @@ -179,9 +185,11 @@ const VariableSizeList = createListComponent({ scrollOffset: number, instanceProps: InstanceProps ): number => { - const { direction, height, width } = props; + const { direction, height, layout, width } = props; - const size = (((direction === 'horizontal' ? width : height): any): number); + // TODO Deprecate direction "horizontal" + const isHorizontal = direction === 'horizontal' || layout === 'horizontal'; + const size = (((isHorizontal ? width : height): any): number); const itemMetadata = getItemMetadata(props, index, instanceProps); // Get estimated total size after ItemMetadata is computed, @@ -228,9 +236,11 @@ const VariableSizeList = createListComponent({ scrollOffset: number, instanceProps: InstanceProps ): number => { - const { direction, height, itemCount, width } = props; + const { direction, height, itemCount, layout, width } = props; - const size = (((direction === 'horizontal' ? width : height): any): number); + // TODO Deprecate direction "horizontal" + const isHorizontal = direction === 'horizontal' || layout === 'horizontal'; + const size = (((isHorizontal ? width : height): any): number); const itemMetadata = getItemMetadata(props, startIndex, instanceProps); const maxOffset = scrollOffset + size; diff --git a/src/__tests__/FixedSizeGrid.js b/src/__tests__/FixedSizeGrid.js index 14e0de1f..dbc4303d 100644 --- a/src/__tests__/FixedSizeGrid.js +++ b/src/__tests__/FixedSizeGrid.js @@ -3,6 +3,7 @@ import ReactDOM from 'react-dom'; import ReactTestRenderer from 'react-test-renderer'; import ReactTestUtils from 'react-dom/test-utils'; import { FixedSizeGrid } from '..'; +import * as domHelpers from '../domHelpers'; const findScrollContainer = rendered => rendered.root.children[0].children[0]; @@ -13,7 +14,7 @@ const simulateScroll = (instance, { scrollLeft, scrollTop }) => { }; describe('FixedSizeGrid', () => { - let itemRenderer, defaultProps, onItemsRendered; + let defaultProps, getScrollbarSize, itemRenderer, onItemsRendered; // Use PureComponent to test memoization. // Pass through to itemRenderer mock for easier test assertions. @@ -26,6 +27,9 @@ describe('FixedSizeGrid', () => { beforeEach(() => { jest.useFakeTimers(); + // Mock the DOM helper util for testing purposes. + getScrollbarSize = domHelpers.getScrollbarSize = jest.fn(() => 0); + onItemsRendered = jest.fn(); itemRenderer = jest.fn(({ style, ...rest }) => ( @@ -172,6 +176,41 @@ describe('FixedSizeGrid', () => { }); }); + describe('direction', () => { + it('should set the appropriate CSS direction style', () => { + const renderer = ReactTestRenderer.create( + + ); + expect(renderer.toJSON().props.style.direction).toBe('ltr'); + renderer.update(); + expect(renderer.toJSON().props.style.direction).toBe('rtl'); + }); + + it('should position items correctly', () => { + const renderer = ReactTestRenderer.create( + + ); + + let params = itemRenderer.mock.calls[0][0]; + expect(params.columnIndex).toBe(0); + expect(params.rowIndex).toBe(0); + let style = params.style; + expect(style.left).toBe(0); + expect(style.right).toBeUndefined(); + + itemRenderer.mockClear(); + + renderer.update(); + + params = itemRenderer.mock.calls[0][0]; + expect(params.columnIndex).toBe(0); + expect(params.rowIndex).toBe(0); + style = params.style; + expect(style.left).toBeUndefined(); + expect(style.right).toBe(0); + }); + }); + describe('overscanColumnsCount and overscanRowsCount', () => { it('should require a minimum of 1 overscan to support tabbing', () => { ReactTestRenderer.create( @@ -248,7 +287,8 @@ describe('FixedSizeGrid', () => { describe('overscanCount', () => { it('should warn about deprecated overscanCount prop', () => { spyOn(console, 'warn'); - ReactTestRenderer.create( + + const renderer = ReactTestRenderer.create( ); expect(console.warn).toHaveBeenCalledTimes(1); @@ -256,10 +296,16 @@ describe('FixedSizeGrid', () => { 'The overscanCount prop has been deprecated. ' + 'Please use the overscanColumnsCount and overscanRowsCount props instead.' ); + + renderer.update(); + + // But it should only warn once. + expect(console.warn).toHaveBeenCalledTimes(1); }); it('should use overscanColumnsCount if both it and overscanCount are provided', () => { spyOn(console, 'warn'); + ReactTestRenderer.create( { it('should use overscanRowsCount if both it and overscanCount are provided', () => { spyOn(console, 'warn'); + ReactTestRenderer.create( { it('should support deprecated overscanCount', () => { spyOn(console, 'warn'); + ReactTestRenderer.create( { instance.scrollToItem({ columnIndex: 15, rowIndex: 20 }); expect(itemRenderer.mock.calls[0][0].isScrolling).toBe(false); }); + + it('should account for scrollbar size', () => { + const onScroll = jest.fn(); + const rendered = ReactTestRenderer.create( + + ); + + onScroll.mockClear(); + rendered + .getInstance() + .scrollToItem({ columnIndex: 15, rowIndex: 10, align: 'end' }); + + // With hidden scrollbars (size === 0) we would expect... + expect(onScroll).toHaveBeenCalledWith({ + horizontalScrollDirection: 'forward', + scrollLeft: 1300, + scrollTop: 125, + scrollUpdateWasRequested: true, + verticalScrollDirection: 'forward', + }); + + getScrollbarSize.mockImplementation(() => 20); + + onScroll.mockClear(); + rendered + .getInstance() + .scrollToItem({ columnIndex: 15, rowIndex: 10, align: 'end' }); + + // With scrollbars of size 20 we would expect those values ot increase by 20px + expect(onScroll).toHaveBeenCalledWith({ + horizontalScrollDirection: 'forward', + scrollLeft: 1320, + scrollTop: 145, + scrollUpdateWasRequested: true, + verticalScrollDirection: 'forward', + }); + }); + + it('should not account for scrollbar size when no scrollbar is visible for a particular direction', () => { + getScrollbarSize.mockImplementation(() => 20); + + const onScroll = jest.fn(); + const rendered = ReactTestRenderer.create( + + ); + + onScroll.mockClear(); + rendered + .getInstance() + .scrollToItem({ columnIndex: 0, rowIndex: 10, align: 'end' }); + + // Since there aren't enough columns to require horizontal scrolling, + // the additional 20px for the scrollbar should not be taken into consideration. + expect(onScroll).toHaveBeenCalledWith({ + horizontalScrollDirection: 'backward', + scrollLeft: 0, + scrollTop: 125, + scrollUpdateWasRequested: true, + verticalScrollDirection: 'forward', + }); + + rendered.update( + + ); + + onScroll.mockClear(); + rendered + .getInstance() + .scrollToItem({ columnIndex: 15, rowIndex: 0, align: 'end' }); + + // Since there aren't enough rows to require vertical scrolling, + // the additional 20px for the scrollbar should not be taken into consideration. + expect(onScroll).toHaveBeenCalledWith({ + horizontalScrollDirection: 'forward', + scrollLeft: 1300, + scrollTop: 0, + scrollUpdateWasRequested: true, + verticalScrollDirection: 'backward', + }); + }); }); // onItemsRendered is pretty well covered by other snapshot tests @@ -721,19 +872,29 @@ describe('FixedSizeGrid', () => { it('should warn if legacy innerTagName or outerTagName props are used', () => { spyOn(console, 'warn'); - ReactDOM.render( + const renderer = ReactTestRenderer.create( , - document.createElement('div') + /> ); expect(console.warn).toHaveBeenCalledTimes(1); expect(console.warn).toHaveBeenLastCalledWith( 'The innerTagName and outerTagName props have been deprecated. ' + 'Please use the innerElementType and outerElementType props instead.' ); + + renderer.update( + + ); + + // But it should only warn once. + expect(console.warn).toHaveBeenCalledTimes(1); }); }); @@ -814,10 +975,22 @@ describe('FixedSizeGrid', () => { ); }); + it('should fail if an invalid direction is provided', () => { + expect(() => + ReactTestRenderer.create( + + ) + ).toThrow( + 'An invalid "direction" prop has been specified. ' + + 'Value should be either "ltr" or "rtl". ' + + '"null" was specified.' + ); + }); + it('should fail if a string height is provided', () => { expect(() => ReactTestRenderer.create( - + ) ).toThrow( 'An invalid "height" prop has been specified. ' + @@ -829,11 +1002,7 @@ describe('FixedSizeGrid', () => { it('should fail if a string width is provided', () => { expect(() => ReactTestRenderer.create( - + ) ).toThrow( 'An invalid "width" prop has been specified. ' + diff --git a/src/__tests__/FixedSizeList.js b/src/__tests__/FixedSizeList.js index d71b9f39..f591c3d0 100644 --- a/src/__tests__/FixedSizeList.js +++ b/src/__tests__/FixedSizeList.js @@ -58,13 +58,32 @@ describe('FixedSizeList', () => { it('should render a list of columns', () => { ReactTestRenderer.create( - + ); expect(itemRenderer).toHaveBeenCalledTimes(5); expect(onItemsRendered.mock.calls).toMatchSnapshot(); }); + it('should re-render items if layout changes', () => { + const rendered = ReactTestRenderer.create( + + ); + expect(itemRenderer).toHaveBeenCalled(); + itemRenderer.mockClear(); + + // Re-rendering should not affect pure sCU children: + rendered.update(); + expect(itemRenderer).not.toHaveBeenCalled(); + + // Re-rendering with new layout should re-render children: + rendered.update(); + expect(itemRenderer).toHaveBeenCalled(); + }); + + // TODO Deprecate direction "horizontal" it('should re-render items if direction changes', () => { + spyOn(console, 'warn'); // Ingore legacy prop warning + const rendered = ReactTestRenderer.create( ); @@ -75,7 +94,7 @@ describe('FixedSizeList', () => { rendered.update(); expect(itemRenderer).not.toHaveBeenCalled(); - // Re-rendering with new direction should re-render children: + // Re-rendering with new layout should re-render children: rendered.update(); expect(itemRenderer).toHaveBeenCalled(); }); @@ -97,7 +116,7 @@ describe('FixedSizeList', () => { ReactDOM.render( , document.createElement('div') @@ -202,6 +221,39 @@ describe('FixedSizeList', () => { }); }); + describe('direction', () => { + it('should set the appropriate CSS direction style', () => { + const renderer = ReactTestRenderer.create( + + ); + expect(renderer.toJSON().props.style.direction).toBe('ltr'); + renderer.update(); + expect(renderer.toJSON().props.style.direction).toBe('rtl'); + }); + + it('should position items correctly', () => { + const renderer = ReactTestRenderer.create( + + ); + + let params = itemRenderer.mock.calls[0][0]; + expect(params.index).toBe(0); + let style = params.style; + expect(style.left).toBe(0); + expect(style.right).toBeUndefined(); + + itemRenderer.mockClear(); + + renderer.update(); + + params = itemRenderer.mock.calls[0][0]; + expect(params.index).toBe(0); + style = params.style; + expect(style.left).toBeUndefined(); + expect(style.right).toBe(0); + }); + }); + describe('overscanCount', () => { it('should require a minimum of 1 overscan to support tabbing', () => { ReactTestRenderer.create( @@ -583,19 +635,68 @@ describe('FixedSizeList', () => { it('should warn if legacy innerTagName or outerTagName props are used', () => { spyOn(console, 'warn'); - ReactDOM.render( + + const renderer = ReactTestRenderer.create( , - document.createElement('div') + /> ); expect(console.warn).toHaveBeenCalledTimes(1); expect(console.warn).toHaveBeenLastCalledWith( 'The innerTagName and outerTagName props have been deprecated. ' + 'Please use the innerElementType and outerElementType props instead.' ); + + renderer.update( + + ); + + // But it should only warn once. + expect(console.warn).toHaveBeenCalledTimes(1); + }); + + it('should warn if legacy direction "horizontal" value is used', () => { + spyOn(console, 'warn'); + + const renderer = ReactTestRenderer.create( + + ); + expect(console.warn).toHaveBeenCalledTimes(1); + expect(console.warn).toHaveBeenLastCalledWith( + 'The direction prop should be either "ltr" (default) or "rtl". ' + + 'Please use the layout prop to specify "vertical" (default) or "horizontal" orientation.' + ); + + renderer.update( + + ); + + // But it should only warn once. + expect(console.warn).toHaveBeenCalledTimes(1); + }); + + it('should warn if legacy direction "vertical" value is used', () => { + spyOn(console, 'warn'); + + const renderer = ReactTestRenderer.create( + + ); + expect(console.warn).toHaveBeenCalledTimes(1); + expect(console.warn).toHaveBeenLastCalledWith( + 'The direction prop should be either "ltr" (default) or "rtl". ' + + 'Please use the layout prop to specify "vertical" (default) or "horizontal" orientation.' + ); + + renderer.update(); + + // But it should only warn once. + expect(console.warn).toHaveBeenCalledTimes(1); }); }); @@ -664,6 +765,18 @@ describe('FixedSizeList', () => { ); }); + it('should fail if an invalid layout is provided', () => { + expect(() => + ReactTestRenderer.create( + + ) + ).toThrow( + 'An invalid "layout" prop has been specified. ' + + 'Value should be either "horizontal" or "vertical". ' + + '"null" was specified.' + ); + }); + it('should fail if an invalid direction is provided', () => { expect(() => ReactTestRenderer.create( @@ -671,7 +784,7 @@ describe('FixedSizeList', () => { ) ).toThrow( 'An invalid "direction" prop has been specified. ' + - 'Value should be either "horizontal" or "vertical". ' + + 'Value should be either "ltr" or "rtl". ' + '"null" was specified.' ); }); @@ -679,7 +792,7 @@ describe('FixedSizeList', () => { it('should fail if a string height is provided for a vertical list', () => { expect(() => ReactTestRenderer.create( - + ) ).toThrow( 'An invalid "height" prop has been specified. ' + @@ -691,11 +804,7 @@ describe('FixedSizeList', () => { it('should fail if a string width is provided for a horizontal list', () => { expect(() => ReactTestRenderer.create( - + ) ).toThrow( 'An invalid "width" prop has been specified. ' + diff --git a/src/__tests__/VariableSizeGrid.js b/src/__tests__/VariableSizeGrid.js index 7fdd0175..de665b13 100644 --- a/src/__tests__/VariableSizeGrid.js +++ b/src/__tests__/VariableSizeGrid.js @@ -1,11 +1,25 @@ -import React, { PureComponent } from 'react'; +import React, { createRef, PureComponent } from 'react'; +import { render } from 'react-dom'; +import { Simulate } from 'react-dom/test-utils'; import ReactTestRenderer from 'react-test-renderer'; import { VariableSizeGrid } from '..'; +import * as domHelpers from '../domHelpers'; + +const simulateScroll = (instance, { scrollLeft, scrollTop }) => { + instance._outerRef.scrollLeft = scrollLeft; + instance._outerRef.scrollTop = scrollTop; + Simulate.scroll(instance._outerRef); +}; const findScrollContainer = rendered => rendered.root.children[0].children[0]; describe('VariableSizeGrid', () => { - let columnWidth, defaultProps, itemRenderer, onItemsRendered, rowHeight; + let columnWidth, + defaultProps, + getScrollbarSize, + itemRenderer, + onItemsRendered, + rowHeight; // Use PureComponent to test memoization. // Pass through to itemRenderer mock for easier test assertions. @@ -26,6 +40,9 @@ describe('VariableSizeGrid', () => { beforeEach(() => { jest.useFakeTimers(); + // Mock the DOM helper util for testing purposes. + getScrollbarSize = domHelpers.getScrollbarSize = jest.fn(() => 0); + itemRenderer = jest.fn(({ style, ...rest }) => (
{JSON.stringify(rest, null, 2)}
)); @@ -247,6 +264,90 @@ describe('VariableSizeGrid', () => { .scrollToItem({ columnIndex: 9, rowIndex: 19, align: 'center' }); expect(onItemsRendered.mock.calls).toMatchSnapshot(); }); + + it('should account for scrollbar size', () => { + const onScroll = jest.fn(); + const rendered = ReactTestRenderer.create( + + ); + + onScroll.mockClear(); + rendered + .getInstance() + .scrollToItem({ columnIndex: 15, rowIndex: 10, align: 'end' }); + + // With hidden scrollbars (size === 0) we would expect... + expect(onScroll).toHaveBeenCalledWith({ + horizontalScrollDirection: 'forward', + scrollLeft: 720, + scrollTop: 230, + scrollUpdateWasRequested: true, + verticalScrollDirection: 'forward', + }); + + getScrollbarSize.mockImplementation(() => 20); + + onScroll.mockClear(); + rendered + .getInstance() + .scrollToItem({ columnIndex: 15, rowIndex: 10, align: 'end' }); + + // With scrollbars of size 20 we would expect those values ot increase by 20px + expect(onScroll).toHaveBeenCalledWith({ + horizontalScrollDirection: 'forward', + scrollLeft: 740, + scrollTop: 250, + scrollUpdateWasRequested: true, + verticalScrollDirection: 'forward', + }); + }); + + it('should not account for scrollbar size when no scrollbar is visible for a particular direction', () => { + getScrollbarSize.mockImplementation(() => 20); + + const onScroll = jest.fn(); + const rendered = ReactTestRenderer.create( + + ); + + onScroll.mockClear(); + rendered + .getInstance() + .scrollToItem({ columnIndex: 0, rowIndex: 10, align: 'end' }); + + // Since there aren't enough columns to require horizontal scrolling, + // the additional 20px for the scrollbar should not be taken into consideration. + expect(onScroll).toHaveBeenCalledWith({ + horizontalScrollDirection: 'backward', + scrollLeft: 0, + scrollTop: 230, + scrollUpdateWasRequested: true, + verticalScrollDirection: 'forward', + }); + + rendered.update( + + ); + + onScroll.mockClear(); + rendered + .getInstance() + .scrollToItem({ columnIndex: 15, rowIndex: 0, align: 'end' }); + + // Since there aren't enough rows to require vertical scrolling, + // the additional 20px for the scrollbar should not be taken into consideration. + expect(onScroll).toHaveBeenCalledWith({ + horizontalScrollDirection: 'forward', + scrollLeft: 720, + scrollTop: 0, + scrollUpdateWasRequested: true, + verticalScrollDirection: 'backward', + }); + }); }); describe('resetAfterIndex method', () => { @@ -408,4 +509,36 @@ describe('VariableSizeGrid', () => { ); }); }); + + // https://github.com/bvaughn/react-window/pull/138 + it('should descrease scroll size when itemCount decreases', () => { + const innerRef = createRef(); + const gridRef = createRef(); + + class Wrapper extends PureComponent { + state = { columnCount: 100, rowCount: 200 }; + render() { + return ( + + ); + } + } + + // Use ReactDOM renderer so "scroll" events work correctly. + const instance = render(, document.createElement('div')); + + // Simulate scrolling past several rows. + simulateScroll(gridRef.current, { scrollLeft: 3000, scrollTop: 4000 }); + + // Decrease itemCount a lot and verify the scroll height is descreased as well. + instance.setState({ columnCount: 2, rowCount: 4 }); + expect(innerRef.current.style.height).toEqual('106px'); + expect(innerRef.current.style.width).toEqual('101px'); + }); }); diff --git a/src/__tests__/VariableSizeList.js b/src/__tests__/VariableSizeList.js index b84bd999..ccfcf592 100644 --- a/src/__tests__/VariableSizeList.js +++ b/src/__tests__/VariableSizeList.js @@ -1,7 +1,18 @@ -import React, { PureComponent } from 'react'; +import React, { createRef, PureComponent } from 'react'; +import { render } from 'react-dom'; +import { Simulate } from 'react-dom/test-utils'; import ReactTestRenderer from 'react-test-renderer'; import { VariableSizeList } from '..'; +const simulateScroll = (instance, scrollOffset, direction = 'vertical') => { + if (direction === 'horizontal') { + instance._outerRef.scrollLeft = scrollOffset; + } else { + instance._outerRef.scrollTop = scrollOffset; + } + Simulate.scroll(instance._outerRef); +}; + const findScrollContainer = rendered => rendered.root.children[0].children[0]; describe('VariableSizeList', () => { @@ -295,4 +306,34 @@ describe('VariableSizeList', () => { ); }); }); + + // https://github.com/bvaughn/react-window/pull/138 + it('should descrease scroll size when itemCount decreases', () => { + const innerRef = createRef(); + const listRef = createRef(); + + class Wrapper extends PureComponent { + state = { itemCount: 100 }; + render() { + return ( + + ); + } + } + + // Use ReactDOM renderer so "scroll" events work correctly. + const instance = render(, document.createElement('div')); + + // Simulate scrolling past several rows. + simulateScroll(listRef.current, 3000); + + // Decrease itemCount a lot and verify the scroll height is descreased as well. + instance.setState({ itemCount: 3 }); + expect(innerRef.current.style.height).toEqual('78px'); + }); }); diff --git a/src/createGridComponent.js b/src/createGridComponent.js index e0d704f5..ddf24178 100644 --- a/src/createGridComponent.js +++ b/src/createGridComponent.js @@ -3,9 +3,11 @@ import memoizeOne from 'memoize-one'; import { createElement, PureComponent } from 'react'; import { cancelTimeout, requestTimeout } from './timer'; +import { getScrollbarSize } from './domHelpers'; import type { TimeoutID } from './timer'; +type Direction = 'ltr' | 'rtl'; export type ScrollToAlign = 'auto' | 'center' | 'start' | 'end'; type itemSize = number | ((index: number) => number); @@ -49,6 +51,7 @@ export type Props = {| className?: string, columnCount: number, columnWidth: itemSize, + direction: Direction, height: number, initialScrollLeft?: number, initialScrollTop?: number, @@ -77,6 +80,7 @@ export type Props = {| |}; type State = {| + instance: any, isScrolling: boolean, horizontalScrollDirection: ScrollDirection, scrollLeft: number, @@ -101,7 +105,8 @@ type GetOffsetForItemAndAlignment = ( index: number, align: ScrollToAlign, scrollOffset: number, - instanceProps: any + instanceProps: any, + scrollbarSize: number ) => number; type GetStartIndexForOffset = ( props: Props, @@ -122,6 +127,17 @@ const IS_SCROLLING_DEBOUNCE_INTERVAL = 150; const defaultItemKey = ({ columnIndex, data, rowIndex }) => `${rowIndex}:${columnIndex}`; +// In DEV mode, this Set helps us only log a warning once per component instace. +// This avoids spamming the console every time a render happens. +let devWarningsOverscanCount = null; +let devWarningsTagName = null; +if (process.env.NODE_ENV !== 'production') { + if (typeof window.WeakSet !== 'undefined') { + devWarningsOverscanCount = new WeakSet(); + devWarningsTagName = new WeakSet(); + } +} + export default function createGridComponent({ getColumnOffset, getColumnStartIndexForOffset, @@ -161,11 +177,13 @@ export default function createGridComponent({ _outerRef: ?HTMLDivElement; static defaultProps = { + direction: 'ltr', itemData: undefined, useIsScrolling: false, }; state: State = { + instance: this, isScrolling: false, horizontalScrollDirection: 'forward', scrollLeft: @@ -190,8 +208,8 @@ export default function createGridComponent({ static getDerivedStateFromProps( nextProps: Props, prevState: State - ): $Shape { - validateSharedProps(nextProps); + ): $Shape | null { + validateSharedProps(nextProps, prevState); validateProps(nextProps); return null; } @@ -232,7 +250,26 @@ export default function createGridComponent({ columnIndex: number, rowIndex: number, }): void { + const { height, width } = this.props; const { scrollLeft, scrollTop } = this.state; + const scrollbarSize = getScrollbarSize(); + + const estimatedTotalHeight = getEstimatedTotalHeight( + this.props, + this._instanceProps + ); + const estimatedTotalWidth = getEstimatedTotalWidth( + this.props, + this._instanceProps + ); + + // The scrollbar size should be considered when scrolling an item into view, + // to ensure it's fully visible. + // But we only need to account for its size when it's actually visible. + const horizontalScrollbarSize = + estimatedTotalWidth > width ? scrollbarSize : 0; + const verticalScrollbarSize = + estimatedTotalHeight > height ? scrollbarSize : 0; this.scrollTo({ scrollLeft: getOffsetForColumnAndAlignment( @@ -240,14 +277,16 @@ export default function createGridComponent({ columnIndex, align, scrollLeft, - this._instanceProps + this._instanceProps, + verticalScrollbarSize ), scrollTop: getOffsetForRowAndAlignment( this.props, rowIndex, align, scrollTop, - this._instanceProps + this._instanceProps, + horizontalScrollbarSize ), }); } @@ -285,6 +324,7 @@ export default function createGridComponent({ children, className, columnCount, + direction, height, innerRef, innerElementType, @@ -356,6 +396,7 @@ export default function createGridComponent({ overflow: 'auto', WebkitOverflowScrolling: 'touch', willChange: 'transform', + direction, ...style, }, }, @@ -482,20 +523,27 @@ export default function createGridComponent({ // So that List can clear cached styles and force item re-render if necessary. _getItemStyle: (rowIndex: number, columnIndex: number) => Object; _getItemStyle = (rowIndex: number, columnIndex: number): Object => { - const key = `${rowIndex}:${columnIndex}`; + const { columnWidth, direction, rowHeight } = this.props; const itemStyleCache = this._getItemStyleCache( - shouldResetStyleCacheOnItemSizeChange && this.props.columnWidth, - shouldResetStyleCacheOnItemSizeChange && this.props.rowHeight + shouldResetStyleCacheOnItemSizeChange && columnWidth, + shouldResetStyleCacheOnItemSizeChange && direction, + shouldResetStyleCacheOnItemSizeChange && rowHeight ); + const key = `${rowIndex}:${columnIndex}`; + let style; if (itemStyleCache.hasOwnProperty(key)) { style = itemStyleCache[key]; } else { itemStyleCache[key] = style = { position: 'absolute', - left: getColumnOffset(this.props, columnIndex, this._instanceProps), + [direction === 'rtl' ? 'right' : 'left']: getColumnOffset( + this.props, + columnIndex, + this._instanceProps + ), top: getRowOffset(this.props, rowIndex, this._instanceProps), height: getRowHeight(this.props, rowIndex, this._instanceProps), width: getColumnWidth(this.props, columnIndex, this._instanceProps), @@ -505,11 +553,8 @@ export default function createGridComponent({ return style; }; - // TODO This memoized getter doesn't make much sense. - // If all that's really needed is for the impl to be able to reset the cache, - // Then we could expose a better API for that. - _getItemStyleCache: (_: any, __: any) => ItemStyleCache; - _getItemStyleCache = memoizeOne((_: any, __: any) => ({})); + _getItemStyleCache: (_: any, __: any, ___: any) => ItemStyleCache; + _getItemStyleCache = memoizeOne((_: any, __: any, ___: any) => ({})); _getHorizontalRangeToRender(): [number, number, number, number] { const { @@ -606,7 +651,12 @@ export default function createGridComponent({ } _onScroll = (event: ScrollEvent): void => { - const { scrollLeft, scrollTop } = event.currentTarget; + const { + clientWidth, + scrollLeft, + scrollTop, + scrollWidth, + } = event.currentTarget; this.setState(prevState => { if ( prevState.scrollLeft === scrollLeft && @@ -618,11 +668,25 @@ export default function createGridComponent({ return null; } + const { direction } = this.props; + + // HACK According to the spec, scrollLeft should be negative for RTL aligned elements. + // Chrome does not seem to adhere; its scrolLeft values are positive (measured relative to the left). + // See https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollLeft + let calculatedScrollLeft = scrollLeft; + if (direction === 'rtl') { + if (scrollLeft <= 0) { + calculatedScrollLeft = -scrollLeft; + } else { + calculatedScrollLeft = scrollWidth - clientWidth - scrollLeft; + } + } + return { isScrolling: true, horizontalScrollDirection: prevState.scrollLeft < scrollLeft ? 'forward' : 'backward', - scrollLeft, + scrollLeft: calculatedScrollLeft, scrollTop, verticalScrollDirection: prevState.scrollTop < scrollTop ? 'forward' : 'backward', @@ -670,27 +734,37 @@ export default function createGridComponent({ }; } -const validateSharedProps = ({ - children, - height, - innerTagName, - outerTagName, - overscanCount, - width, -}: Props): void => { +const validateSharedProps = ( + { + children, + direction, + height, + innerTagName, + outerTagName, + overscanCount, + width, + }: Props, + { instance }: State +): void => { if (process.env.NODE_ENV !== 'production') { if (typeof overscanCount === 'number') { - console.warn( - 'The overscanCount prop has been deprecated. ' + - 'Please use the overscanColumnsCount and overscanRowsCount props instead.' - ); + if (devWarningsOverscanCount && !devWarningsOverscanCount.has(instance)) { + devWarningsOverscanCount.add(instance); + console.warn( + 'The overscanCount prop has been deprecated. ' + + 'Please use the overscanColumnsCount and overscanRowsCount props instead.' + ); + } } if (innerTagName != null || outerTagName != null) { - console.warn( - 'The innerTagName and outerTagName props have been deprecated. ' + - 'Please use the innerElementType and outerElementType props instead.' - ); + if (devWarningsTagName && !devWarningsTagName.has(instance)) { + devWarningsTagName.add(instance); + console.warn( + 'The innerTagName and outerTagName props have been deprecated. ' + + 'Please use the innerElementType and outerElementType props instead.' + ); + } } if (children == null) { @@ -701,6 +775,19 @@ const validateSharedProps = ({ ); } + switch (direction) { + case 'ltr': + case 'rtl': + // Valid values + break; + default: + throw Error( + 'An invalid "direction" prop has been specified. ' + + 'Value should be either "ltr" or "rtl". ' + + `"${direction}" was specified.` + ); + } + if (typeof width !== 'number') { throw Error( 'An invalid "width" prop has been specified. ' + diff --git a/src/createListComponent.js b/src/createListComponent.js index 18249c87..7f056f20 100644 --- a/src/createListComponent.js +++ b/src/createListComponent.js @@ -9,7 +9,9 @@ import type { TimeoutID } from './timer'; export type ScrollToAlign = 'auto' | 'center' | 'start' | 'end'; type itemSize = number | ((index: number) => number); -export type Direction = 'horizontal' | 'vertical'; +// TODO Deprecate directions "horizontal" and "vertical" +export type Direction = 'ltr' | 'rtl' | 'horizontal' | 'vertical'; +export type Layout = 'horizontal' | 'vertical'; export type RenderComponentProps = {| data: T, @@ -49,6 +51,7 @@ export type Props = {| itemData: T, itemKey?: (index: number, data: T) => any, itemSize: itemSize, + layout: Layout, onItemsRendered?: onItemsRenderedCallback, onScroll?: onScrollCallback, outerRef?: any, @@ -61,6 +64,7 @@ export type Props = {| |}; type State = {| + instance: any, isScrolling: boolean, scrollDirection: ScrollDirection, scrollOffset: number, @@ -103,6 +107,17 @@ const IS_SCROLLING_DEBOUNCE_INTERVAL = 150; export const defaultItemKey = (index: number, data: any) => index; +// In DEV mode, this Set helps us only log a warning once per component instace. +// This avoids spamming the console every time a render happens. +let devWarningsDirection = null; +let devWarningsTagName = null; +if (process.env.NODE_ENV !== 'production') { + if (typeof window.WeakSet !== 'undefined') { + devWarningsDirection = new WeakSet(); + devWarningsTagName = new WeakSet(); + } +} + export default function createListComponent({ getItemOffset, getEstimatedTotalSize, @@ -130,13 +145,15 @@ export default function createListComponent({ _resetIsScrollingTimeoutId: TimeoutID | null = null; static defaultProps = { - direction: 'vertical', + direction: 'ltr', itemData: undefined, + layout: 'vertical', overscanCount: 2, useIsScrolling: false, }; state: State = { + instance: this, isScrolling: false, scrollDirection: 'forward', scrollOffset: @@ -154,11 +171,11 @@ export default function createListComponent({ } static getDerivedStateFromProps( - props: Props, - state: State - ): $Shape { - validateSharedProps(props); - validateProps(props); + nextProps: Props, + prevState: State + ): $Shape | null { + validateSharedProps(nextProps, prevState); + validateProps(nextProps); return null; } @@ -188,14 +205,16 @@ export default function createListComponent({ } componentDidMount() { - const { initialScrollOffset, direction } = this.props; + const { direction, initialScrollOffset, layout } = this.props; if (typeof initialScrollOffset === 'number' && this._outerRef !== null) { - const element = ((this._outerRef: any): HTMLDivElement); - if (direction === 'horizontal') { - element.scrollLeft = initialScrollOffset; + // TODO Deprecate direction "horizontal" + if (direction === 'horizontal' || layout === 'horizontal') { + ((this + ._outerRef: any): HTMLDivElement).scrollLeft = initialScrollOffset; } else { - element.scrollTop = initialScrollOffset; + ((this + ._outerRef: any): HTMLDivElement).scrollTop = initialScrollOffset; } } @@ -204,15 +223,15 @@ export default function createListComponent({ } componentDidUpdate() { - const { direction } = this.props; + const { direction, layout } = this.props; const { scrollOffset, scrollUpdateWasRequested } = this.state; if (scrollUpdateWasRequested && this._outerRef !== null) { - const element = ((this._outerRef: any): HTMLDivElement); - if (direction === 'horizontal') { - element.scrollLeft = scrollOffset; + // TODO Deprecate direction "horizontal" + if (direction === 'horizontal' || layout === 'horizontal') { + ((this._outerRef: any): HTMLDivElement).scrollLeft = scrollOffset; } else { - element.scrollTop = scrollOffset; + ((this._outerRef: any): HTMLDivElement).scrollTop = scrollOffset; } } @@ -236,6 +255,7 @@ export default function createListComponent({ innerRef, innerElementType, innerTagName, + layout, outerElementType, outerTagName, style, @@ -243,10 +263,13 @@ export default function createListComponent({ } = this.props; const { isScrolling } = this.state; - const onScroll = - direction === 'vertical' - ? this._onScrollVertical - : this._onScrollHorizontal; + // TODO Deprecate direction "horizontal" + const isHorizontal = + direction === 'horizontal' || layout === 'horizontal'; + + const onScroll = isHorizontal + ? this._onScrollHorizontal + : this._onScrollVertical; const items = this._renderItems(); @@ -270,6 +293,7 @@ export default function createListComponent({ position: 'relative', WebkitOverflowScrolling: 'touch', willChange: 'transform', + direction, ...style, }, }, @@ -277,9 +301,9 @@ export default function createListComponent({ children: items, ref: innerRef, style: { - height: direction === 'horizontal' ? '100%' : estimatedTotalSize, + height: isHorizontal ? '100%' : estimatedTotalSize, pointerEvents: isScrolling ? 'none' : '', - width: direction === 'horizontal' ? estimatedTotalSize : '100%', + width: isHorizontal ? estimatedTotalSize : '100%', }, }) ); @@ -371,10 +395,11 @@ export default function createListComponent({ // So that List can clear cached styles and force item re-render if necessary. _getItemStyle: (index: number) => Object; _getItemStyle = (index: number): Object => { - const { direction, itemSize } = this.props; + const { direction, itemSize, layout } = this.props; const itemStyleCache = this._getItemStyleCache( shouldResetStyleCacheOnItemSizeChange && itemSize, + shouldResetStyleCacheOnItemSizeChange && layout, shouldResetStyleCacheOnItemSizeChange && direction ); @@ -385,12 +410,16 @@ export default function createListComponent({ const offset = getItemOffset(this.props, index, this._instanceProps); const size = getItemSize(this.props, index, this._instanceProps); + // TODO Deprecate direction "horizontal" + const isHorizontal = + direction === 'horizontal' || layout === 'horizontal'; + itemStyleCache[index] = style = { position: 'absolute', - left: direction === 'horizontal' ? offset : 0, - top: direction === 'vertical' ? offset : 0, - height: direction === 'vertical' ? size : '100%', - width: direction === 'horizontal' ? size : '100%', + [direction === 'rtl' ? 'right' : 'left']: isHorizontal ? offset : 0, + top: !isHorizontal ? offset : 0, + height: !isHorizontal ? size : '100%', + width: isHorizontal ? size : '100%', }; } @@ -402,11 +431,11 @@ export default function createListComponent({ // TODO This memoized getter doesn't make much sense. // If all that's really needed is for the impl to be able to reset the cache, // Then we could expose a better API for that. - _getItemStyleCache: (_: any, __: any) => ItemStyleCache; - _getItemStyleCache = memoizeOne((_, __) => { + _getItemStyleCache: (_: any, __: any, ___: any) => ItemStyleCache; + _getItemStyleCache = memoizeOne((_: any, __: any, ___: any) => { this._itemStyleCache = {}; - return this._itemStyleCache; + return this._itemStyleCache; }); _getRangeToRender(): [number, number, number, number] { @@ -478,7 +507,7 @@ export default function createListComponent({ } _onScrollHorizontal = (event: ScrollEvent): void => { - const { scrollLeft } = event.currentTarget; + const { clientWidth, scrollLeft, scrollWidth } = event.currentTarget; this.setState(prevState => { if (prevState.scrollOffset === scrollLeft) { // Scroll position may have been updated by cDM/cDU, @@ -487,11 +516,25 @@ export default function createListComponent({ return null; } + const { direction } = this.props; + + // HACK According to the spec, scrollLeft should be negative for RTL aligned elements. + // Chrome does not seem to adhere; its scrolLeft values are positive (measured relative to the left). + // See https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollLeft + let scrollOffset = scrollLeft; + if (direction === 'rtl') { + if (scrollLeft <= 0) { + scrollOffset = -scrollOffset; + } else { + scrollOffset = scrollWidth - clientWidth - scrollLeft; + } + } + return { isScrolling: true, scrollDirection: prevState.scrollOffset < scrollLeft ? 'forward' : 'backward', - scrollOffset: scrollLeft, + scrollOffset, scrollUpdateWasRequested: false, }; }, this._resetIsScrollingDebounced); @@ -566,28 +609,66 @@ export default function createListComponent({ // I assume people already do this (render function returning a class component), // So my doing it would just unnecessarily double the wrappers. -const validateSharedProps = ({ - children, - direction, - height, - innerTagName, - outerTagName, - width, -}: Props): void => { +const validateSharedProps = ( + { + children, + direction, + height, + layout, + innerTagName, + outerTagName, + width, + }: Props, + { instance }: State +): void => { if (process.env.NODE_ENV !== 'production') { if (innerTagName != null || outerTagName != null) { - console.warn( - 'The innerTagName and outerTagName props have been deprecated. ' + - 'Please use the innerElementType and outerElementType props instead.' - ); + if (devWarningsTagName && !devWarningsTagName.has(instance)) { + devWarningsTagName.add(instance); + console.warn( + 'The innerTagName and outerTagName props have been deprecated. ' + + 'Please use the innerElementType and outerElementType props instead.' + ); + } } - if (direction !== 'horizontal' && direction !== 'vertical') { - throw Error( - 'An invalid "direction" prop has been specified. ' + - 'Value should be either "horizontal" or "vertical". ' + - `"${direction}" was specified.` - ); + // TODO Deprecate direction "horizontal" + const isHorizontal = direction === 'horizontal' || layout === 'horizontal'; + + switch (direction) { + case 'horizontal': + case 'vertical': + if (devWarningsDirection && !devWarningsDirection.has(instance)) { + devWarningsDirection.add(instance); + console.warn( + 'The direction prop should be either "ltr" (default) or "rtl". ' + + 'Please use the layout prop to specify "vertical" (default) or "horizontal" orientation.' + ); + } + break; + case 'ltr': + case 'rtl': + // Valid values + break; + default: + throw Error( + 'An invalid "direction" prop has been specified. ' + + 'Value should be either "ltr" or "rtl". ' + + `"${direction}" was specified.` + ); + } + + switch (layout) { + case 'horizontal': + case 'vertical': + // Valid values + break; + default: + throw Error( + 'An invalid "layout" prop has been specified. ' + + 'Value should be either "horizontal" or "vertical". ' + + `"${layout}" was specified.` + ); } if (children == null) { @@ -598,13 +679,13 @@ const validateSharedProps = ({ ); } - if (direction === 'horizontal' && typeof width !== 'number') { + if (isHorizontal && typeof width !== 'number') { throw Error( 'An invalid "width" prop has been specified. ' + 'Horizontal lists must specify a number for width. ' + `"${width === null ? 'null' : typeof width}" was specified.` ); - } else if (direction === 'vertical' && typeof height !== 'number') { + } else if (!isHorizontal && typeof height !== 'number') { throw Error( 'An invalid "height" prop has been specified. ' + 'Vertical lists must specify a number for height. ' + diff --git a/src/domHelpers.js b/src/domHelpers.js new file mode 100644 index 00000000..830aba68 --- /dev/null +++ b/src/domHelpers.js @@ -0,0 +1,22 @@ +// @flow + +let size: number = -1; + +// This utility copied from "dom-helpers" package. +export function getScrollbarSize(recalculate?: boolean = false): number { + if (size === -1 || recalculate) { + const div = document.createElement('div'); + const style = div.style; + style.width = '50px'; + style.height = '50px'; + style.overflow = 'scroll'; + + ((document.body: any): HTMLBodyElement).appendChild(div); + + size = div.offsetWidth - div.clientWidth; + + ((document.body: any): HTMLBodyElement).removeChild(div); + } + + return size; +} diff --git a/website/sandboxes/dynamic-size-list-horizontal/index.js b/website/sandboxes/dynamic-size-list-horizontal/index.js index 935c95df..3fe0620f 100644 --- a/website/sandboxes/dynamic-size-list-horizontal/index.js +++ b/website/sandboxes/dynamic-size-list-horizontal/index.js @@ -26,7 +26,7 @@ const Example = () => ( ( const Example = () => ( {Column} diff --git a/website/sandboxes/memoized-list-items/package.json b/website/sandboxes/memoized-list-items/package.json index 1d2a045c..6110232c 100644 --- a/website/sandboxes/memoized-list-items/package.json +++ b/website/sandboxes/memoized-list-items/package.json @@ -2,7 +2,7 @@ "description": "Demo of react-window with advanced memoization techniques", "main": "src/index.js", "dependencies": { - "memoize-one": "^4", + "memoize-one": "^5", "react": "^16.6", "react-dom": "^16.6", "react-window": "^1" diff --git a/website/sandboxes/variable-size-list-horizontal/index.js b/website/sandboxes/variable-size-list-horizontal/index.js index 89f1d54f..cc69d7d1 100644 --- a/website/sandboxes/variable-size-list-horizontal/index.js +++ b/website/sandboxes/variable-size-list-horizontal/index.js @@ -21,10 +21,10 @@ const Column = ({ index, style }) => ( const Example = () => ( {Column} diff --git a/website/src/App.js b/website/src/App.js index f02b9f95..170cf955 100644 --- a/website/src/App.js +++ b/website/src/App.js @@ -11,6 +11,7 @@ import FixedSizeGridApi from './routes/api/FixedSizeGrid'; import FixedSizeGridExample from './routes/examples/FixedSizeGrid'; import FixedSizeListApi from './routes/api/FixedSizeList'; import FixedSizeListExample from './routes/examples/FixedSizeList'; +import RTLLayoutExample from './routes/examples/RTLLayout'; import ListWithScrollingIndicatorExample from './routes/examples/ListWithScrollingIndicator'; import MemoizedListItemsExample from './routes/examples/MemoizedListItemsExample'; import ScrollToItemExample from './routes/examples/ScrollToItem'; @@ -102,6 +103,11 @@ const EXAMPLE_ROUTES = [ title: 'Memoized List items', component: MemoizedListItemsExample, }, + { + path: '/examples/list/fixed-size-rtl', + title: 'RTL layout', + component: RTLLayoutExample, + }, ]; const COMPONENTS_ROUTES = [ diff --git a/website/src/code/DynamicSizeListHorizontal.js b/website/src/code/DynamicSizeListHorizontal.js index e1e69e79..a76b502e 100644 --- a/website/src/code/DynamicSizeListHorizontal.js +++ b/website/src/code/DynamicSizeListHorizontal.js @@ -10,7 +10,7 @@ const Column = ({ index, style }) => ( // Note that no itemSize is required for dyanmic lists! const Example = () => ( ( +
+ بند {rowIndex},{columnIndex} +
+); + +const Example = () => ( + + {Cell} + +); \ No newline at end of file diff --git a/website/src/code/FixedSizeListHorizontal.js b/website/src/code/FixedSizeListHorizontal.js index 72cdefd1..654196df 100644 --- a/website/src/code/FixedSizeListHorizontal.js +++ b/website/src/code/FixedSizeListHorizontal.js @@ -6,10 +6,10 @@ const Column = ({ index, style }) => ( const Example = () => ( {Column} diff --git a/website/src/code/FixedSizeListHorizontalRtl.js b/website/src/code/FixedSizeListHorizontalRtl.js new file mode 100644 index 00000000..b43071e0 --- /dev/null +++ b/website/src/code/FixedSizeListHorizontalRtl.js @@ -0,0 +1,18 @@ +import { FixedSizeList as List } from 'react-window'; + +const Column = ({ index, style }) => ( +
عمود {index}
+); + +const Example = () => ( + + {Column} + +); diff --git a/website/src/code/VariableSizeListHorizontal.js b/website/src/code/VariableSizeListHorizontal.js index 8548dc79..654d7e17 100644 --- a/website/src/code/VariableSizeListHorizontal.js +++ b/website/src/code/VariableSizeListHorizontal.js @@ -14,10 +14,10 @@ const Column = ({ index, style }) => ( const Example = () => ( {Column} diff --git a/website/src/components/ProfiledExample.js b/website/src/components/ProfiledExample.js index cf108584..54f405c2 100644 --- a/website/src/components/ProfiledExample.js +++ b/website/src/components/ProfiledExample.js @@ -13,7 +13,7 @@ type Props = {| style?: Object, |}; -const isProfilingEnabled = window.location.hash.includes('profile=true'); +const isProfilingEnabled = window.location.hash.indexOf('profile=true') >= 0; export default class ProfiledExample extends PureComponent { _averageTimeRef = createRef(); diff --git a/website/src/routes/api/FixedSizeGrid.js b/website/src/routes/api/FixedSizeGrid.js index fab2bb2c..c84555d5 100644 --- a/website/src/routes/api/FixedSizeGrid.js +++ b/website/src/routes/api/FixedSizeGrid.js @@ -74,6 +74,31 @@ const PROPS = [ name: 'columnWidth', type: 'number', }, + { + defaultValue: '"ltr"', + description: ( + +

Determines the direction of text and horizontal scrolling.

+
    +
  • ltr (default)
  • +
  • rtl
  • +
+

+ This property also automatically sets the{' '} + + CSS direction style + {' '} + for the grid component. +

+
+ ), + name: 'direction', + type: 'string', + }, { description: (

diff --git a/website/src/routes/api/FixedSizeList.js b/website/src/routes/api/FixedSizeList.js index dea1aed3..70be84ca 100644 --- a/website/src/routes/api/FixedSizeList.js +++ b/website/src/routes/api/FixedSizeList.js @@ -58,17 +58,24 @@ const PROPS = [ type: 'string', }, { - defaultValue: '"vertical"', + defaultValue: '"ltr"', description: ( -

Primary scroll direction of the list. Acceptable values are:

+

Determines the direction of text and horizontal scrolling.

    -
  • vertical (default) - Up/down scrolling.
  • -
  • horizontal - Left/right scrolling.
  • +
  • ltr (default)
  • +
  • rtl

- Note that lists may scroll in both directions (depending on CSS) but - content will only be windowed in the primary direction. + This property also automatically sets the{' '} + + CSS direction style + {' '} + for the list component.

), @@ -207,6 +214,24 @@ const PROPS = [ name: 'itemSize', type: 'number', }, + { + defaultValue: '"vertical"', + description: ( + +

Layout/orientation of the list. Acceptable values are:

+
    +
  • vertical (default) - Up/down scrolling.
  • +
  • horizontal - Left/right scrolling.
  • +
+

+ Note that lists may scroll in both directions (depending on CSS) but + content will only be windowed in the layout direction specified. +

+
+ ), + name: 'layout', + type: 'string', + }, { description: ( diff --git a/website/src/routes/examples/DynamicSizeList.js b/website/src/routes/examples/DynamicSizeList.js index ae5742f7..c5bd1114 100644 --- a/website/src/routes/examples/DynamicSizeList.js +++ b/website/src/routes/examples/DynamicSizeList.js @@ -200,7 +200,7 @@ export default class DynamicSizeList extends PureComponent { {Item} diff --git a/website/src/routes/examples/MemoizedListItemsExample.js b/website/src/routes/examples/MemoizedListItemsExample.js index 6eb60e0c..ebbde055 100644 --- a/website/src/routes/examples/MemoizedListItemsExample.js +++ b/website/src/routes/examples/MemoizedListItemsExample.js @@ -3,20 +3,19 @@ import React, { PureComponent, memo } from 'react'; import { FixedSizeList, areEqual } from 'react-window'; import CodeBlock from '../../components/CodeBlock'; import ProfiledExample from '../../components/ProfiledExample'; +import { fillArray } from '../../utils'; import CODE from '../../code/MemoizedListItems.js'; import styles from './shared.module.css'; const generateItems = numItems => - Array(numItems) - .fill(true) - .map(_ => ({ - isActive: false, - label: Math.random() - .toString(36) - .substr(2), - })); + fillArray(numItems, () => ({ + isActive: false, + label: Math.random() + .toString(36) + .substr(2), + })); const Row = memo(({ data, index, style }) => { const { items, toggleItemActive } = data; diff --git a/website/src/routes/examples/RTLLayout.js b/website/src/routes/examples/RTLLayout.js new file mode 100644 index 00000000..26d533f0 --- /dev/null +++ b/website/src/routes/examples/RTLLayout.js @@ -0,0 +1,99 @@ +import React, { PureComponent } from 'react'; +import { FixedSizeGrid, FixedSizeList } from 'react-window'; +import CodeBlock from '../../components/CodeBlock'; +import ProfiledExample from '../../components/ProfiledExample'; + +import CODE_GRID from '../../code/FixedSizeGridRtl.js'; +import CODE_LIST from '../../code/FixedSizeListHorizontalRtl.js'; + +import styles from './shared.module.css'; + +class Cell extends PureComponent { + render() { + const { columnIndex, rowIndex, style } = this.props; + + return ( +
+ بند {rowIndex}, {columnIndex} +
+ ); + } +} + +class Item extends PureComponent { + render() { + const { index, style } = this.props; + + return ( +
+ عمود {index} +
+ ); + } +} + +export default function() { + return ( +
+

RTL List

+
+ + + {Item} + + +
+ +
+
+

RTL Grid

+
+ + + {Cell} + + +
+ +
+
+
+ ); +} diff --git a/website/src/routes/examples/ScrollToItem.js b/website/src/routes/examples/ScrollToItem.js index a2132f1f..fcbd30c5 100644 --- a/website/src/routes/examples/ScrollToItem.js +++ b/website/src/routes/examples/ScrollToItem.js @@ -4,18 +4,15 @@ import { unstable_trace as trace } from 'scheduler/tracing'; import CodeBlock from '../../components/CodeBlock'; import ProfiledExample from '../../components/ProfiledExample'; +import { fillArray } from '../../utils'; import CODE_GRID from '../../code/ScrollToItemGrid.js'; import CODE_LIST from '../../code/ScrollToItemList.js'; import styles from './shared.module.css'; -const columnWidths = new Array(1000) - .fill(true) - .map(() => 75 + Math.round(Math.random() * 50)); -const rowHeights = new Array(1000) - .fill(true) - .map(() => 25 + Math.round(Math.random() * 50)); +const columnWidths = fillArray(1000, () => 75 + Math.round(Math.random() * 50)); +const rowHeights = fillArray(1000, () => 25 + Math.round(Math.random() * 50)); class ListItemRenderer extends PureComponent { render() { diff --git a/website/src/routes/examples/VariableSizeGrid.js b/website/src/routes/examples/VariableSizeGrid.js index 6322fb63..84c1ded8 100644 --- a/website/src/routes/examples/VariableSizeGrid.js +++ b/website/src/routes/examples/VariableSizeGrid.js @@ -2,17 +2,14 @@ import React, { PureComponent } from 'react'; import { VariableSizeGrid } from 'react-window'; import CodeBlock from '../../components/CodeBlock'; import ProfiledExample from '../../components/ProfiledExample'; +import { fillArray } from '../../utils'; import CODE from '../../code/VariableSizeGrid.js'; import styles from './shared.module.css'; -const columnWidths = new Array(1000) - .fill(true) - .map(() => 75 + Math.round(Math.random() * 50)); -const rowHeights = new Array(1000) - .fill(true) - .map(() => 25 + Math.round(Math.random() * 50)); +const columnWidths = fillArray(1000, () => 75 + Math.round(Math.random() * 50)); +const rowHeights = fillArray(1000, () => 25 + Math.round(Math.random() * 50)); class Cell extends PureComponent { render() { diff --git a/website/src/routes/examples/VariableSizeList.js b/website/src/routes/examples/VariableSizeList.js index 748ce6b9..c5e4b9ef 100644 --- a/website/src/routes/examples/VariableSizeList.js +++ b/website/src/routes/examples/VariableSizeList.js @@ -2,18 +2,15 @@ import React, { PureComponent } from 'react'; import { VariableSizeList } from 'react-window'; import CodeBlock from '../../components/CodeBlock'; import ProfiledExample from '../../components/ProfiledExample'; +import { fillArray } from '../../utils'; import CODE_HORIZONTAL from '../../code/VariableSizeListHorizontal.js'; import CODE_VERTICAL from '../../code/VariableSizeListVertical.js'; import styles from './shared.module.css'; -const columnSizes = new Array(1000) - .fill(true) - .map(() => 75 + Math.round(Math.random() * 50)); -const rowSizes = new Array(1000) - .fill(true) - .map(() => 25 + Math.round(Math.random() * 50)); +const columnSizes = fillArray(1000, () => 75 + Math.round(Math.random() * 50)); +const rowSizes = fillArray(1000, () => 25 + Math.round(Math.random() * 50)); class Item extends PureComponent { render() { @@ -61,11 +58,11 @@ export default function() { > columnSizes[index]} + layout="horizontal" width={300} > {Item} diff --git a/website/src/utils.js b/website/src/utils.js new file mode 100644 index 00000000..9ffba561 --- /dev/null +++ b/website/src/utils.js @@ -0,0 +1,7 @@ +export function fillArray(length, fillFunction): Array { + const array = new Array(length); + for (let i = 0; i < length; i++) { + array[i] = fillFunction(i); + } + return array; +} diff --git a/website/yarn.lock b/website/yarn.lock index 70c13bf9..6adf1a55 100644 --- a/website/yarn.lock +++ b/website/yarn.lock @@ -6167,9 +6167,10 @@ mem@^1.1.0: dependencies: mimic-fn "^1.0.0" -memoize-one@^3.1.1: - version "3.1.1" - resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-3.1.1.tgz#ef609811e3bc28970eac2884eece64d167830d17" +memoize-one@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.0.0.tgz#d55007dffefb8de7546659a1722a5d42e128286e" + integrity sha512-7g0+ejkOaI9w5x6LvQwmj68kUj6rxROywPSCqmclG/HBacmFnZqhVscQ8kovkn9FBCNJmOz6SY42+jnvZzDWdw== memory-fs@^0.4.0, memory-fs@~0.4.1: version "0.4.1" diff --git a/yarn.lock b/yarn.lock index f81bc3cb..b55bb3a9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -586,10 +586,12 @@ "@types/estree@0.0.39": version "0.0.39" resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.39.tgz#e177e699ee1b8c22d23174caaa7422644389509f" + integrity sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw== -"@types/node@*": - version "10.1.2" - resolved "https://registry.yarnpkg.com/@types/node/-/node-10.1.2.tgz#1b928a0baa408fc8ae3ac012cc81375addc147c6" +"@types/node@^11.9.5": + version "11.10.4" + resolved "https://registry.yarnpkg.com/@types/node/-/node-11.10.4.tgz#3f5fc4f0f322805f009e00ab35a2ff3d6b778e42" + integrity sha512-wa09itaLE8L705aXd8F80jnFpxz3Y1/KRHfKsYL2bPc0XF+wEWu8sR9n5bmeu8Ba1N9z2GRNzm/YdHcghLkLKg== abab@^1.0.3, abab@^1.0.4: version "1.0.4" @@ -638,10 +640,15 @@ acorn@^4.0.3, acorn@^4.0.4: version "4.0.13" resolved "https://registry.yarnpkg.com/acorn/-/acorn-4.0.13.tgz#105495ae5361d697bd195c825192e1ad7f253787" -acorn@^5.0.0, acorn@^5.2.1, acorn@^5.3.0, acorn@^5.5.0: +acorn@^5.0.0, acorn@^5.3.0, acorn@^5.5.0: version "5.5.3" resolved "https://registry.yarnpkg.com/acorn/-/acorn-5.5.3.tgz#f473dd47e0277a08e28e9bec5aeeb04751f0b8c9" +acorn@^6.1.1: + version "6.1.1" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-6.1.1.tgz#7d25ae05bb8ad1f9b699108e1094ecd7884adc1f" + integrity sha512-jPTiwtOxaHNaAPg/dmrJ/beuzLRnXtB0kQPQ8JpotKJgTB6rX6c8mlf315941pyjBSaPg8NHXS9fhP4u17DpGA== + address@1.0.3, address@^1.0.1: version "1.0.3" resolved "https://registry.yarnpkg.com/address/-/address-1.0.3.tgz#b5f50631f8d6cec8bd20c963963afb55e06cbce9" @@ -1888,9 +1895,10 @@ builtin-modules@^1.0.0, builtin-modules@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/builtin-modules/-/builtin-modules-1.1.1.tgz#270f076c5a72c02f5b65a47df94c5fe3a278892f" -builtin-modules@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/builtin-modules/-/builtin-modules-2.0.0.tgz#60b7ef5ae6546bd7deefa74b08b62a43a232648e" +builtin-modules@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/builtin-modules/-/builtin-modules-3.0.0.tgz#1e587d44b006620d90286cc7a9238bbc6129cab1" + integrity sha512-hMIeU4K2ilbXV6Uv93ZZ0Avg/M91RaKXucQ+4me2Do1txxBDyDZWCBa5bJSLqoNTRpXTLwEzIk1KmloenDDjhg== builtin-status-codes@^3.0.0: version "3.0.0" @@ -3293,14 +3301,15 @@ estraverse@^4.0.0, estraverse@^4.1.0, estraverse@^4.1.1, estraverse@^4.2.0: version "4.2.0" resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-4.2.0.tgz#0dee3fed31fcd469618ce7342099fc1afa0bdb13" -estree-walker@^0.3.0: - version "0.3.1" - resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-0.3.1.tgz#e6b1a51cf7292524e7237c312e5fe6660c1ce1aa" - -estree-walker@^0.5.0, estree-walker@^0.5.2: +estree-walker@^0.5.2: version "0.5.2" resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-0.5.2.tgz#d3850be7529c9580d815600b53126515e146dd39" +estree-walker@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-0.6.0.tgz#5d865327c44a618dde5699f763891ae31f257dae" + integrity sha512-peq1RfVAVzr3PU/jL31RaOjUKLoZJpObQWJJ+LgfcxDUifyLZ1RjPQZTl0pzj2uJ45b7A7XpyppXvxdEqzo4rw== + esutils@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.2.tgz#0abf4f1caa5bcb1f7a9d8acc6dea4faaa04bac9b" @@ -5543,11 +5552,12 @@ macaddress@^0.2.8: version "0.2.8" resolved "https://registry.yarnpkg.com/macaddress/-/macaddress-0.2.8.tgz#5904dc537c39ec6dbefeae902327135fa8511f12" -magic-string@^0.22.4: - version "0.22.5" - resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.22.5.tgz#8e9cf5afddf44385c1da5bc2a6a0dbd10b03657e" +magic-string@^0.25.1: + version "0.25.2" + resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.25.2.tgz#139c3a729515ec55e96e69e82a11fe890a293ad9" + integrity sha512-iLs9mPjh9IuTtRsqqhNGYcZXGei0Nh/A4xirrsqW7c+QhKVFL2vm7U09ru6cHRD22azaP/wMDgI+HCqbETMTtg== dependencies: - vlq "^0.2.2" + sourcemap-codec "^1.4.4" make-dir@^1.0.0: version "1.2.0" @@ -5596,9 +5606,10 @@ mem@^1.1.0: dependencies: mimic-fn "^1.0.0" -memoize-one@^3.1.1: - version "3.1.1" - resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-3.1.1.tgz#ef609811e3bc28970eac2884eece64d167830d17" +"memoize-one@>=3.1.1 <6": + version "5.0.0" + resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.0.0.tgz#d55007dffefb8de7546659a1722a5d42e128286e" + integrity sha512-7g0+ejkOaI9w5x6LvQwmj68kUj6rxROywPSCqmclG/HBacmFnZqhVscQ8kovkn9FBCNJmOz6SY42+jnvZzDWdw== memory-fs@^0.4.0, memory-fs@~0.4.1: version "0.4.1" @@ -5652,7 +5663,7 @@ micromatch@^2.1.5, micromatch@^2.3.11: parse-glob "^3.0.4" regex-cache "^0.4.2" -micromatch@^3.1.4, micromatch@^3.1.8: +micromatch@^3.1.10, micromatch@^3.1.4, micromatch@^3.1.8: version "3.1.10" resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-3.1.10.tgz#70859bc95c9840952f359a068a3fc49f9ecfac23" dependencies: @@ -6273,6 +6284,11 @@ path-parse@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.5.tgz#3c1adf871ea9cd6c9431b6ea2bd74a0ff055c4c1" +path-parse@^1.0.6: + version "1.0.6" + resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.6.tgz#d62dbb5679405d72c4737ec58600e9ddcf06d24c" + integrity sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw== + path-to-regexp@0.1.7: version "0.1.7" resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.7.tgz#df604178005f522f15eb4490e7247a1bfaa67f8c" @@ -7297,7 +7313,14 @@ resolve@1.6.0: dependencies: path-parse "^1.0.5" -resolve@^1.1.6, resolve@^1.3.2, resolve@^1.3.3, resolve@^1.4.0, resolve@^1.5.0, resolve@^1.6.0: +resolve@^1.10.0: + version "1.10.0" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.10.0.tgz#3bdaaeaf45cc07f375656dfd2e54ed0810b101ba" + integrity sha512-3sUr9aq5OfSg2S9pNtPA9hL1FVEAjvfOC4leW0SNf/mpnaakz2a9femSd6LqAww2RaFctwyf1lCqnTHuF1rxDg== + dependencies: + path-parse "^1.0.6" + +resolve@^1.3.2, resolve@^1.3.3, resolve@^1.5.0, resolve@^1.6.0: version "1.7.1" resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.7.1.tgz#aadd656374fd298aee895bc026b8297418677fd3" dependencies: @@ -7346,37 +7369,32 @@ ripemd160@^2.0.0, ripemd160@^2.0.1: hash-base "^3.0.0" inherits "^2.0.1" -rollup-plugin-babel@^4.0.2: - version "4.0.2" - resolved "https://registry.yarnpkg.com/rollup-plugin-babel/-/rollup-plugin-babel-4.0.2.tgz#c073eeb0cc246324e6f6feaedbb90093841a138c" +rollup-plugin-babel@^4.3.2: + version "4.3.2" + resolved "https://registry.yarnpkg.com/rollup-plugin-babel/-/rollup-plugin-babel-4.3.2.tgz#8c0e1bd7aa9826e90769cf76895007098ffd1413" + integrity sha512-KfnizE258L/4enADKX61ozfwGHoqYauvoofghFJBhFnpH9Sb9dNPpWg8QHOaAfVASUYV8w0mCx430i9z0LJoJg== dependencies: "@babel/helper-module-imports" "^7.0.0" rollup-pluginutils "^2.3.0" -rollup-plugin-commonjs@^8.2.1: - version "8.4.1" - resolved "https://registry.yarnpkg.com/rollup-plugin-commonjs/-/rollup-plugin-commonjs-8.4.1.tgz#5c9cea2b2c3de322f5fbccd147e07ed5e502d7a0" +rollup-plugin-commonjs@^9.2.1: + version "9.2.1" + resolved "https://registry.yarnpkg.com/rollup-plugin-commonjs/-/rollup-plugin-commonjs-9.2.1.tgz#bb151ca8fa23600c7a03e25f9f0a45b1ee922dac" + integrity sha512-X0A/Cp/t+zbONFinBhiTZrfuUaVwRIp4xsbKq/2ohA2CDULa/7ONSJTelqxon+Vds2R2t2qJTqJQucKUC8GKkw== dependencies: - acorn "^5.2.1" - estree-walker "^0.5.0" - magic-string "^0.22.4" - resolve "^1.4.0" - rollup-pluginutils "^2.0.1" + estree-walker "^0.5.2" + magic-string "^0.25.1" + resolve "^1.10.0" + rollup-pluginutils "^2.3.3" -rollup-plugin-node-resolve@^3.0.2: - version "3.3.0" - resolved "https://registry.yarnpkg.com/rollup-plugin-node-resolve/-/rollup-plugin-node-resolve-3.3.0.tgz#c26d110a36812cbefa7ce117cadcd3439aa1c713" +rollup-plugin-node-resolve@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/rollup-plugin-node-resolve/-/rollup-plugin-node-resolve-4.0.1.tgz#f95765d174e5daeef9ea6268566141f53aa9d422" + integrity sha512-fSS7YDuCe0gYqKsr5OvxMloeZYUSgN43Ypi1WeRZzQcWtHgFayV5tUSPYpxuaioIIWaBXl6NrVk0T2/sKwueLg== dependencies: - builtin-modules "^2.0.0" + builtin-modules "^3.0.0" is-module "^1.0.0" - resolve "^1.1.6" - -rollup-pluginutils@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/rollup-pluginutils/-/rollup-pluginutils-2.0.1.tgz#7ec95b3573f6543a46a6461bd9a7c544525d0fc0" - dependencies: - estree-walker "^0.3.0" - micromatch "^2.3.11" + resolve "^1.10.0" rollup-pluginutils@^2.3.0: version "2.3.1" @@ -7385,12 +7403,22 @@ rollup-pluginutils@^2.3.0: estree-walker "^0.5.2" micromatch "^2.3.11" -rollup@^0.65.0: - version "0.65.0" - resolved "https://registry.yarnpkg.com/rollup/-/rollup-0.65.0.tgz#280db1252169b68fc3043028346b337dde453fba" +rollup-pluginutils@^2.3.3: + version "2.4.1" + resolved "https://registry.yarnpkg.com/rollup-pluginutils/-/rollup-pluginutils-2.4.1.tgz#de43ab54965bbf47843599a7f3adceb723de38db" + integrity sha512-wesMQ9/172IJDIW/lYWm0vW0LiKe5Ekjws481R7z9WTRtmO59cqyM/2uUlxvf6yzm/fElFmHUobeQOYz46dZJw== + dependencies: + estree-walker "^0.6.0" + micromatch "^3.1.10" + +rollup@^1.4.1: + version "1.4.1" + resolved "https://registry.yarnpkg.com/rollup/-/rollup-1.4.1.tgz#cc03ef6fb49dd72a878e3da0131c0a3696de14a7" + integrity sha512-YWf5zeR6SWtqZmCnuYs4a+ZJetj8NT4yfBMPXekWHW4L3144jM+J2AWagQVejB0FwCqjEUP9l8o4hg1rPDfQlg== dependencies: "@types/estree" "0.0.39" - "@types/node" "*" + "@types/node" "^11.9.5" + acorn "^6.1.1" run-async@^2.2.0: version "2.3.0" @@ -7717,6 +7745,11 @@ source-map@^0.6.0, source-map@^0.6.1, source-map@~0.6.1: version "0.6.1" resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" +sourcemap-codec@^1.4.4: + version "1.4.4" + resolved "https://registry.yarnpkg.com/sourcemap-codec/-/sourcemap-codec-1.4.4.tgz#c63ea927c029dd6bd9a2b7fa03b3fec02ad56e9f" + integrity sha512-CYAPYdBu34781kLHkaW3m6b/uUSyMOC2R61gcYMWooeuaGtjof86ZA/8T+qVPPt7np1085CR9hmMGrySwEc8Xg== + spdx-correct@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/spdx-correct/-/spdx-correct-3.0.0.tgz#05a5b4d7153a195bc92c3c425b69f3b2a9524c82" @@ -8423,10 +8456,6 @@ verror@1.10.0: core-util-is "1.0.2" extsprintf "^1.2.0" -vlq@^0.2.2: - version "0.2.3" - resolved "https://registry.yarnpkg.com/vlq/-/vlq-0.2.3.tgz#8f3e4328cf63b1540c0d67e1b2778386f8975b26" - vm-browserify@0.0.4: version "0.0.4" resolved "https://registry.yarnpkg.com/vm-browserify/-/vm-browserify-0.0.4.tgz#5d7ea45bbef9e4a6ff65f95438e0a87c357d5a73" From 8fd2adab125c80d8ab960e940d926a6feb0587ed Mon Sep 17 00:00:00 2001 From: Casey Langen <34461160+clangen-nw@users.noreply.github.com> Date: Thu, 9 May 2019 23:48:33 -0700 Subject: [PATCH 34/37] Fixed a memory leak in ItemMeasurer. If the resulting ref was null, the old reference wasn't getting reset, and we'd end up re-observing the original node. --- src/ItemMeasurer.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/ItemMeasurer.js b/src/ItemMeasurer.js index 763e1aba..7823aa1c 100644 --- a/src/ItemMeasurer.js +++ b/src/ItemMeasurer.js @@ -96,6 +96,7 @@ export default class ItemMeasurer extends Component { componentWillUnmount() { if (this._resizeObserver !== null) { this._resizeObserver.disconnect(); + this._resizeObserver = null; } } @@ -141,8 +142,10 @@ export default class ItemMeasurer extends Component { if (ref instanceof HTMLElement) { this._didProvideValidRef = true; this._node = ref; - } else if (ref !== null) { + } else if (ref) { this._node = ((findDOMNode(ref): any): HTMLElement); + } else { + this._node = null; } if (this._resizeObserver !== null && this._node !== null) { From d020ba4878b51928f9bc9953b72ab6470842cec1 Mon Sep 17 00:00:00 2001 From: Edgars Date: Tue, 21 May 2019 14:12:00 +0300 Subject: [PATCH 35/37] Fix item data not passed to itemKey Fix item data not passed to DynamicSizeList itemKey --- src/DynamicSizeList.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/DynamicSizeList.js b/src/DynamicSizeList.js index 26149e65..9eb2f9df 100644 --- a/src/DynamicSizeList.js +++ b/src/DynamicSizeList.js @@ -467,7 +467,7 @@ const DynamicSizeList = createListComponent({ handleNewMeasurements, index, item, - key: itemKey(index), + key: itemKey(index, itemData), size, }) ); From 13d48ad93ba1f65a51ef4a580f6ca8805c07719e Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Mon, 1 Jul 2019 09:27:42 -0700 Subject: [PATCH 36/37] Fixed edge case bug where all items are exactly the same size as estimated --- src/ItemMeasurer.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/ItemMeasurer.js b/src/ItemMeasurer.js index 7823aa1c..843fd6bb 100644 --- a/src/ItemMeasurer.js +++ b/src/ItemMeasurer.js @@ -81,7 +81,7 @@ export default class ItemMeasurer extends Component { // Force sync measure for the initial mount. // This is necessary to support the DynamicSizeList layout logic. - this._measureItem(true); + this._measureItem(true, true); if (typeof ResizeObserver !== 'undefined') { // Watch for resizes due to changed content, @@ -106,7 +106,7 @@ export default class ItemMeasurer extends Component { }); } - _measureItem = (isCommitPhase: boolean) => { + _measureItem = (isCommitPhase: boolean, isMount: boolean) => { const { direction, layout, @@ -128,7 +128,7 @@ export default class ItemMeasurer extends Component { ? Math.ceil(node.offsetWidth) : Math.ceil(node.offsetHeight); - if (oldSize !== newSize) { + if (oldSize !== newSize || isMount) { handleNewMeasurements(index, newSize, isCommitPhase); } } @@ -154,6 +154,6 @@ export default class ItemMeasurer extends Component { }; _onResize = () => { - this._measureItem(false); + this._measureItem(false, false); }; } From 93e796804243b0886d7ad6ce5428aef288dd18ea Mon Sep 17 00:00:00 2001 From: Trent Hogan Date: Tue, 9 Jul 2019 13:25:23 +1200 Subject: [PATCH 37/37] Fix umd global for react-dom --- rollup.config.js | 1 + 1 file changed, 1 insertion(+) diff --git a/rollup.config.js b/rollup.config.js index b9421aa9..51bd44a7 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -19,6 +19,7 @@ const babelConfigEsModules = babel({ const umdGlobals = { react: 'React', + 'react-dom': 'ReactDOM', }; export default [