diff --git a/.flowconfig b/.flowconfig new file mode 100644 index 000000000..4a58bdcde --- /dev/null +++ b/.flowconfig @@ -0,0 +1,7 @@ +[ignore] + +[include] + +[libs] + +[options] diff --git a/README.md b/README.md index 687d63b7f..5ccc23958 100644 --- a/README.md +++ b/README.md @@ -95,17 +95,21 @@ ES6, CommonJS, and UMD builds are available with each distribution. For example: ```js -// If you're using the Table component you'll need to include the default styles. +// Most of react-virtualized's styles are functional (eg position, size). +// Functional styles are applied directly to DOM elements. +// The Table component ships with a few presentational styles as well. +// They are optional, but if you want them you will need to also import the CSS file. // This only needs to be done once; probably during your application's bootstrapping process. -// Grid and List base styles are purely functional and so they're all inline. import 'react-virtualized/styles.css' -// Then you can import any react-virtualized components you need. -// Tree-shaking is supported with ES6 modules (`jsnext:main` package target). -import { Table } from 'react-virtualized' +// You can import any component you want as a named export from 'react-virtualized', eg +import { Column, Table } from 'react-virtualized' + +// Or for better tree-shaking support you can use deep imports, eg +import { List } from 'react-virtualized/dist/commonjs/List' ``` -Alternately you can load a global-friendly UMD build: +You can also use a global-friendly UMD build: ```html @@ -121,7 +125,6 @@ Dependencies React Virtualized has very few dependencies and most are managed by NPM automatically. However the following peer dependencies must be specified by your project in order to avoid version conflicts: [`react`](https://www.npmjs.com/package/react), -[`react-addons-shallow-compare`](https://www.npmjs.com/package/react-addons-shallow-compare), and [`react-dom`](https://www.npmjs.com/package/react-dom). NPM will not automatically install these for you but it will show you a warning message with instructions on how to install them. @@ -164,7 +167,8 @@ There are also a couple of how-to guides: * [Displaying items in reverse order](docs/reverseList.md) * [Using AutoSizer](docs/usingAutoSizer.md) * [Creating an infinite-loading list](docs/creatingAnInfiniteLoadingList.md) -* [Displaying a reverse list](docs/reverseList.md) +* [Natural sort Table](docs/tableWithNaturalSort.md) + Examples --------------- diff --git a/docs/CellMeasurer.md b/docs/CellMeasurer.md index 86b7f606f..1c3bc7abd 100644 --- a/docs/CellMeasurer.md +++ b/docs/CellMeasurer.md @@ -3,222 +3,144 @@ CellMeasurer High-order component that automatically measures a cell's contents by temporarily rendering it in a way that is not visible to the user. Specify a fixed width to measure dynamic height (or vice versa). - This is an advanced component and has some limitations and performance considerations. [See below for more information](#limitations-and-performance-considerations). -`CellMeasurer` is intended for use with `Grid` components but [can be adapted to work with `List` as well](#using-cellmeasurer-with-list). +`CellMeasurer` can be used with `Grid`, `List`, and `Table` components. It is not intended to be used with the `Collection` component. ### Prop Types | Property | Type | Required? | Description | |:---|:---|:---:|:---| -| cellRenderer | Function | ✓ | Renders a cell given its indices. `({ columnIndex: number, rowIndex: number, index: number }): PropTypes.node`.
**NOTE**: `index` is just an alias to `rowIndex` | -| cellSizeCache | Object | | Optional, custom caching strategy for cell sizes. Learn more [here](#cellsizecache). | -| children | Function | ✓ | Function responsible for rendering a virtualized component; `({ getColumnWidth: Function, getRowHeight: Function, resetMeasurements: Function }) => PropTypes.element` | -| columnCount | number | ✓ | Number of columns in the `Grid`; in order to measure a row's height, all of that row's columns must be rendered. | -| container | | | A Node, Component instance, or function that returns either. If this property is not specified the document body will be used. | -| height | number | | Fixed height; specify this property to measure cell-width only. | -| rowCount | number | ✓ | Number of rows in the `Grid`; in order to measure a column's width, all of that column's rows must be rendered. | -| width | number | | Fixed width; specify this property to measure cell-height only. | - -### Children function - -The child function is passed the following named parameters: - -| Parameter | Type | Description | -|:---|:---|:---| -| getColumnWidth | Function | Callback to set as the `columnWidth` property of a `Grid` | -| getRowHeight | Function | Callback to set as the `rowHeight` property of a `Grid` | -| resetMeasurementForColumn(index) | Function | Use this function to clear cached measurements for specific column in `CellRenderer`; its size will be remeasured the next time it is requested. | -| resetMeasurementForRow(index) | Function | Use this function to clear cached measurements for specific row in `CellRenderer`; its size will be remeasured the next time it is requested. | -| resetMeasurements | Function | Use this function to clear cached measurements in `CellRenderer`; each cell will be remeasured the next time its size is requested. | - -### CellSizeCache - -If you choose to override the `cellSizeCache` property your cache should support the following operations: - -```js -class CellSizeCache { - clearAllColumnWidths (): void; - clearAllRowHeights (): void; - clearColumnWidth (index: number): void; - clearRowHeight (index: number): void; - getColumnWidth (index: number): number | undefined | null; - getRowHeight (index: number): number | undefined | null; - setColumnWidth (index: number, width: number): void; - setRowHeight (index: number, height: number): void; -} -``` +| cache | `CellMeasurerCache` | ✓ | Cache to be shared between `CellMeasurer` and its parent `Grid`. Learn more [here](#cellmeasurercache). | +| children | Element or Function | ✓ | Either a React element as a child (eg `
`) or a function (eg. `({ measure }) =>
`). See [below](#using-cellmeasurer-with-images) for more detailed examples. | +| columnIndex | number | ✓ | Index of column being measured (within the parent `Grid`) or 0 (if used within a `List` or `Table`). | +| parent | `Grid` | ✓ | Reference to the parent `Grid`; this value is passed by `Grid` to the `cellRenderer` and should be passed along as-is. | +| rowIndex | number | ✓ | Index of row being measured (within the parent `Grid`). | -The [default caching strategy](https://github.com/bvaughn/react-virtualized/blob/master/source/CellMeasurer/defaultCellSizeCache.js) is exported as `defaultCellMeasurerCellSizeCache` should you wish to decorate it. -You can also pass `uniformRowHeight` and/or `uniformColumnWidth` named parameters to the constructor for lists with a uniform (yet unknown) cell sizes. +### CellMeasurerCache -An [id-based caching strategy](#id-based-cell-size-cache) is also available for data that may be sorted. -This strategy maps data ids to cell sizes rathe than index so that the sorting order of the data does not invalidate sizes. +The `CellMeasurerCache` stores `CellMeasurer` measurements and shares them with a parent `Grid`. +It should be configured based on the type of measurements you need. It accepts the following parameters: + +### Prop Types +| Property | Type | Required? | Description | +|:---|:---|:---:|:---| +| defaultHeight | number | | Umeasured cells will initially report this height | +| defaultWidth | number | | Umeasured cells will initially report this width | +| fixedHeight | boolean | | Rendered cells will have a fixed height, dynamic width | +| fixedWidth | boolean | | Rendered cells will have a fixed width, dynamic height | +| minHeight | number | | Derived row height (of multiple cells) should not be less than this value | +| minWidth | number | | Derived column width (of multiple cells) should not be less than this value | +| keyMapper | KeyMapper | | Enables more intelligent mapping of a given column and row index to an item ID. This prevents a cell cache from being invalidated when its parent collection is modified. `(rowIndex: number, columnIndex: number) => any` | + +Note that while all of the individual parameters above are optional, you must supply at least some of them. +`CellMeasurerCache` is not meant to measure cells that are both dyanmic width _and_ height. +It would be unefficient to do so since the size of a row (or column) is equal to the largest cell within that row. +See [below](#limitations-and-performance-considerations) for more information. ### Examples -###### Default `cellSizeCache` +###### Grid This example shows a `Grid` with fixed row heights and dynamic column widths. For more examples check out the component [demo page](https://bvaughn.github.io/react-virtualized/#/components/CellMeasurer). ```jsx import React from 'react'; -import ReactDOM from 'react-dom'; -import { CellMeasurer, Grid } from 'react-virtualized'; -import 'react-virtualized/styles.css'; // only needs to be imported once - -ReactDOM.render( - - {({ getColumnWidth }) => ( - - )} - , - document.getElementById('example') -); +import { CellMeasurer, CellMeasurerCache, Grid } from 'react-virtualized'; + +// In this example, average cell width is assumed to be about 100px. +// This value will be used for the initial `Grid` layout. +// Cell measurements smaller than 75px should also be rounded up. +// Height is not dynamic. +const cache = new CellMeasurerCache({ + defaultWidth: 100, + minWidth: 75, + fixedHeight: true +}); + +function cellRenderer ({ columnIndex, key, parent, rowIndex, style }) { + const content // Derive this from your data somehow + + return ( + + {content} + + ); +} + +function renderGrid (props) { + return ( + + ); +} ``` -#### ID-based cell size cache +###### Using `CellMeasurer` with images -`CellMeasurer` measures each cell once and then caches the measurements so it doesn't have to measure it again. -By default this caching is done using the cell's row and column indices. -Certain things (eg insertions, sorting) can invalidate this type of cache though. -If your list is dynamic- you may consider using an id-based caching strategy instead. -The `idCellMeasurerCellSizeCache` exists for this purpose: +This example shows how you might use the `CellMeasurer` component along with the `List` component in order to display dynamic-height rows. +The difference between this example and the above example is that the height of the row is not determined until image data has loaded. +To support this, a function-child is passed to `CellMeasurer` which then receives a `measure` parameter. +`measure` should be called when cell content is ready to be measured (in this case, when the image has loaded). ```jsx import React from 'react'; -import ReactDOM from 'react-dom'; -import { CellMeasurer, Grid, idCellMeasurerCellSizeCache } from 'react-virtualized'; -import 'react-virtualized/styles.css'; // only needs to be imported once - -ReactDOM.render( - - {({ getColumnWidth, getRowHeight }) => ( - - )} - , - document.getElementById('example') -); -``` +import { CellMeasurer, CellMeasurerCache, Grid } from 'react-virtualized'; -###### Customizing `cellSizeCache` +// In this example, average cell height is assumed to be about 50px. +// This value will be used for the initial `Grid` layout. +// Width is not dynamic. +const cache = new CellMeasurerCache({ + defaultHeight: 50, + fixedWidth: true +}); -The cell size cache can be optimized when width and/or height is uniform across cells. -In this case the cache will allow only a single cell width/height measurement and then return that value for all other cells. -You can use it like so: +function rowRenderer ({ index, isScrolling, key, parent, style }) { + const source // This comes from your list data -```jsx -import { - CellMeasurer, - defaultCellMeasurerCellSizeCache as CellSizeCache, - Grid -} from 'react-virtualized'; - -// Column widths vary but row heights are uniform -const cellSizeCache = new CellSizeCache({ - uniformRowHeight: true, - uniformColumnWidth: false -}) - -function render () { return ( - {({ getColumnWidth, getRowHeight }) => ( - ( + )} - ) + ); } -``` - -###### Using `CellMeasurer` with `List` - -`CellMeasurer` is intended for use with `Grid` components but can be adapted to work with `List` as well. -Doing this is just a matter of renaming the `rowIndex` property specified by `CellMeasurer` to an `index` property expected by `rowRenderer`. -```jsx - listProps.rowRenderer({ index: rowIndex, ...rest }) - } - columnCount={1} - rowCount={listProps.rowCount} - width={listProps.width} -> - {({ getRowHeight }) => ( +function renderList (props) { + return ( - )} - + ); +} ``` ### Limitations and Performance Considerations -###### Stateful Components - -The current implementation of `CellMeasurer` creates cells on demand, measures them, and then throws them away. -Future versions of this component may try to clone or in some other way share cells with their parent `Grid` in order to improve performance. -However until that happens, be wary of using `CellMeasurer` to measure stateful components. -Since cells are just-in-time created for measuring purposes they will only be measured with their default state. -To avoid this issue for now, use controlled props (instead of state) for cell rendering behavior. - -###### Styling - -Cells may be measured outside of the context of their intended `Grid` (or `List`). -This means that they will not inherit the parent styles while being measured. -Take care not rely on inherited styles for things that will affect measurement (eg `font-size`). -(See [issue 352](https://github.com/bvaughn/react-virtualized/issues/352) for more background information.) - -Certain box-sizing settings (eg `box-sizing: border-box`) may cause slight discrepancies if borders are applied to a `Grid` whose cells are being measured. -For this reason, it is recommended that you avoid placing borders on a `Grid` that uses a `CellMeasurer` and instead style its parent container. -(See [issue 338](https://github.com/bvaughn/react-virtualized/issues/338) for more background information.) - ###### Performance Measuring a column's width requires measuring all rows in order to determine the widest occurrence of that column. diff --git a/docs/Grid.md b/docs/Grid.md index 1151c655c..2524490c3 100644 --- a/docs/Grid.md +++ b/docs/Grid.md @@ -14,6 +14,7 @@ A windowed grid of elements. `Grid` only renders cells necessary to fill itself | columnCount | Number | ✓ | Number of columns in grid. | | columnWidth | Number or Function | ✓ | Either a fixed column width (number) or a function that returns the width of a column given its index: `({ index: number }): number` | | containerStyle | Object | | Optional custom inline style to attach to inner cell-container element. | +| deferredMeasurementCache | `CellMeasurer` | | If CellMeasurer is used to measure this Grid's children, this should be a pointer to its CellMeasurerCache. A shared CellMeasurerCache reference enables Grid and CellMeasurer to share measurement data. | | estimatedColumnSize | Number | | Used to estimate the total width of a `Grid` before all of its columns have actually been measured. The estimated total width is adjusted as columns are rendered. | | estimatedRowSize | Number | | Used to estimate the total height of a `Grid` before all of its rows have actually been measured. The estimated total height is adjusted as rows are rendered. | | height | Number | ✓ | Height of Grid; this property determines the number of visible (vs virtualized) rows. | @@ -163,9 +164,10 @@ function cellRenderer ({ isScrolling, // The Grid is currently being scrolled isVisible, // This cell is visible within the grid (eg it is not an overscanned cell) key, // Unique key within array of cells + parent, // Reference to the parent Grid (instance) rowIndex, // Vertical (row) index of cell style // Style object to be applied to cell (to position it); - // this must be passed through to the rendered cell element + // This must be passed through to the rendered cell element. }) { // Grid data is a 2d array in this example... const user = list[rowIndex][columnIndex] @@ -178,11 +180,11 @@ function cellRenderer ({ // Style is required since it specifies how the cell is to be sized and positioned, // and React Virtualized depends on this sizing/positioning for proper scrolling behavior. // By default, the grid component specifies, calculates, and initializes the following style properties: - // height - // width + // position // left // top - // position + // height + // width // You can add additional class names or style properties as you would like. // Key is also required by React to more efficiently manage the array of cells. return ( diff --git a/docs/InfiniteLoader.md b/docs/InfiniteLoader.md index b86d0cd1a..544517012 100644 --- a/docs/InfiniteLoader.md +++ b/docs/InfiniteLoader.md @@ -41,6 +41,9 @@ import ReactDOM from 'react-dom'; import { InfiniteLoader, List } from 'react-virtualized'; import 'react-virtualized/styles.css'; // only needs to be imported once +// This example assumes you have a way to know/load this information +const remoteRowCount + const list = []; function isRowLoaded ({ index }) { @@ -77,7 +80,7 @@ ReactDOM.render( height={200} onRowsRendered={onRowsRendered} ref={registerChild} - rowCount={list.length} + rowCount={remoteRowCount} rowHeight={20} rowRenderer={rowRenderer} width={300} diff --git a/docs/MultiGrid.md b/docs/MultiGrid.md index b4faef6ae..38db0e0e8 100644 --- a/docs/MultiGrid.md +++ b/docs/MultiGrid.md @@ -18,6 +18,20 @@ Some properties (eg `columnCount`, `rowCount`) are adjusted slightly to supporte | styleTopLeftGrid | object | | Optional custom inline style to attach to top-left `Grid` element. | | styleTopRightGrid | object | | Optional custom inline style to attach to top-right `Grid` element. | +### Public Methods + +##### forceUpdateGrids + +Pass-thru that calls `forceUpdate` on all child `Grid`s. + +##### measureAllCells + +Pass-thru that calls `measureAllCells` on all child `Grid`s. + +##### recomputeGridSize + +Pass-thru that calls `recomputeGridSize` on all child `Grid`s. + ### Examples ```jsx diff --git a/package.json b/package.json index c2a78c7d7..06fba25bf 100644 --- a/package.json +++ b/package.json @@ -3,8 +3,7 @@ "description": "React components for efficiently rendering large, scrollable lists and tabular data", "author": "Brian Vaughn ", "user": "bvaughn", - "version": "8.11.4", - "next": "8.9.0", + "version": "9.0.0", "homepage": "https://github.com/bvaughn/react-virtualized", "main": "dist/commonjs/index.js", "module": "dist/es/index.js", @@ -81,7 +80,8 @@ "fit", "getComputedStyle", "it", - "jest" + "jest", + "spyOn" ] }, "jest": { @@ -128,11 +128,11 @@ "postcss": "^5.0.14", "postcss-cli": "^2.3.3", "postcss-loader": "^0.9.1", - "react": "^15.3.1", - "react-addons-shallow-compare": "^15.3.1", - "react-addons-test-utils": "^15.3.1", + "raf": "^3.3.0", + "react": "16.0.0-alpha.2", + "react-addons-test-utils": "16.0.0-alpha.2", "react-codemirror": "^0.2.6", - "react-dom": "^15.3.1", + "react-dom": "16.0.0-alpha.2", "react-router": "^4.0.0-alpha.5", "react-transform-catch-errors": "^1.0.2", "react-transform-hmr": "^1.0.2", @@ -149,12 +149,12 @@ "babel-runtime": "^6.11.6", "classnames": "^2.2.3", "dom-helpers": "^2.4.0 || ^3.0.0", + "flow-bin": "^0.39.0", "loose-envify": "^1.3.0" }, "peerDependencies": { - "react": "^0.14.0 || ^15.0.0", - "react-addons-shallow-compare": "^0.14.0 || ^15.0.0", - "react-dom": "^0.14.0 || ^15.0.0" + "react": "^15.3.0 || ^16.0.0-alpha", + "react-dom": "^15.3.0 || ^16.0.0-alpha" }, "browserify": { "transform": [ diff --git a/source/ArrowKeyStepper/ArrowKeyStepper.example.js b/source/ArrowKeyStepper/ArrowKeyStepper.example.js index 53fe84623..c82dc8653 100644 --- a/source/ArrowKeyStepper/ArrowKeyStepper.example.js +++ b/source/ArrowKeyStepper/ArrowKeyStepper.example.js @@ -1,14 +1,13 @@ /** @flow */ -import React, { Component } from 'react' +import React, { PureComponent } from 'react' import { ContentBox, ContentBoxHeader, ContentBoxParagraph } from '../demo/ContentBox' import ArrowKeyStepper from './ArrowKeyStepper' import AutoSizer from '../AutoSizer' import Grid from '../Grid' -import shallowCompare from 'react-addons-shallow-compare' import cn from 'classnames' import styles from './ArrowKeyStepper.example.css' -export default class ArrowKeyStepperExample extends Component { +export default class ArrowKeyStepperExample extends PureComponent { constructor (props) { super(props) @@ -102,10 +101,6 @@ export default class ArrowKeyStepperExample extends Component { ) } - shouldComponentUpdate (nextProps, nextState) { - return shallowCompare(this, nextProps, nextState) - } - _getColumnWidth ({ index }) { return (1 + (index % 3)) * 60 } diff --git a/source/ArrowKeyStepper/ArrowKeyStepper.js b/source/ArrowKeyStepper/ArrowKeyStepper.js index 3a5d54729..8d36e20ab 100644 --- a/source/ArrowKeyStepper/ArrowKeyStepper.js +++ b/source/ArrowKeyStepper/ArrowKeyStepper.js @@ -1,11 +1,10 @@ /** @flow */ -import React, { Component, PropTypes } from 'react' -import shallowCompare from 'react-addons-shallow-compare' +import React, { PropTypes, PureComponent } from 'react' /** * This HOC decorates a virtualized component and responds to arrow-key events by scrolling one row or column at a time. */ -export default class ArrowKeyStepper extends Component { +export default class ArrowKeyStepper extends PureComponent { static defaultProps = { disabled: false, mode: 'edges', @@ -71,10 +70,6 @@ export default class ArrowKeyStepper extends Component { ) } - shouldComponentUpdate (nextProps, nextState) { - return shallowCompare(this, nextProps, nextState) - } - _onKeyDown (event) { const { columnCount, disabled, mode, rowCount } = this.props diff --git a/source/AutoSizer/AutoSizer.example.js b/source/AutoSizer/AutoSizer.example.js index 31ea8a938..8138017c6 100644 --- a/source/AutoSizer/AutoSizer.example.js +++ b/source/AutoSizer/AutoSizer.example.js @@ -1,13 +1,12 @@ /** @flow */ import Immutable from 'immutable' -import React, { Component, PropTypes } from 'react' +import React, { PropTypes, PureComponent } from 'react' import { ContentBox, ContentBoxHeader, ContentBoxParagraph } from '../demo/ContentBox' import AutoSizer from './AutoSizer' import List from '../List' -import shallowCompare from 'react-addons-shallow-compare' import styles from './AutoSizer.example.css' -export default class AutoSizerExample extends Component { +export default class AutoSizerExample extends PureComponent { static contextTypes = { list: PropTypes.instanceOf(Immutable.List).isRequired } @@ -78,10 +77,6 @@ export default class AutoSizerExample extends Component { ) } - shouldComponentUpdate (nextProps, nextState) { - return shallowCompare(this, nextProps, nextState) - } - _rowRenderer ({ index, key, style }) { const { list } = this.context const row = list.get(index) diff --git a/source/AutoSizer/AutoSizer.js b/source/AutoSizer/AutoSizer.js index e454db122..f1e6ae967 100644 --- a/source/AutoSizer/AutoSizer.js +++ b/source/AutoSizer/AutoSizer.js @@ -1,6 +1,5 @@ /** @flow */ -import React, { Component, PropTypes } from 'react' -import shallowCompare from 'react-addons-shallow-compare' +import React, { PropTypes, PureComponent } from 'react' import createDetectElementResize from '../vendor/detectElementResize' /** @@ -8,7 +7,7 @@ import createDetectElementResize from '../vendor/detectElementResize' * Child component should not be declared as a child but should rather be specified by a `ChildComponent` property. * All other properties will be passed through to the child component. */ -export default class AutoSizer extends Component { +export default class AutoSizer extends PureComponent { static propTypes = { /** * Function responsible for rendering children. @@ -90,10 +89,6 @@ export default class AutoSizer extends Component { ) } - shouldComponentUpdate (nextProps, nextState) { - return shallowCompare(this, nextProps, nextState) - } - _onResize () { const { onResize } = this.props diff --git a/source/CellMeasurer/CellMeasurer.DynamiHeightGrid.example.js b/source/CellMeasurer/CellMeasurer.DynamiHeightGrid.example.js new file mode 100644 index 000000000..50f20ca68 --- /dev/null +++ b/source/CellMeasurer/CellMeasurer.DynamiHeightGrid.example.js @@ -0,0 +1,75 @@ +/** @flow */ +import Immutable from 'immutable' +import React, { PropTypes, PureComponent } from 'react' +import CellMeasurer from './CellMeasurer' +import CellMeasurerCache from './CellMeasurerCache' +import Grid from '../Grid' +import styles from './CellMeasurer.example.css' + +export default class DynamiHeightGrid extends PureComponent { + static propTypes = { + getClassName: PropTypes.func.isRequired, + getContent: PropTypes.func.isRequired, + list: PropTypes.instanceOf(Immutable.List).isRequired, + width: PropTypes.number.isRequired + } + + constructor (props, context) { + super(props, context) + + this._cache = new CellMeasurerCache({ + defaultWidth: 150, + fixedWidth: true + }) + + this._cellRenderer = this._cellRenderer.bind(this) + } + + render () { + const { width } = this.props + + return ( + + ) + } + + _cellRenderer ({ columnIndex, key, parent, rowIndex, style }) { + const { getClassName, getContent, list } = this.props + + const datum = list.get(rowIndex + columnIndex % list.size) + const classNames = getClassName({ columnIndex, rowIndex }) + const content = getContent({ index: rowIndex, datum }) + + return ( + +
+ {content} +
+
+ ) + } +} diff --git a/source/CellMeasurer/CellMeasurer.DynamicHeightList.example.js b/source/CellMeasurer/CellMeasurer.DynamicHeightList.example.js new file mode 100644 index 000000000..78d1875c2 --- /dev/null +++ b/source/CellMeasurer/CellMeasurer.DynamicHeightList.example.js @@ -0,0 +1,83 @@ +/** @flow */ +import Immutable from 'immutable' +import React, { PropTypes, PureComponent } from 'react' +import CellMeasurer from './CellMeasurer' +import CellMeasurerCache from './CellMeasurerCache' +import List from '../List' +import styles from './CellMeasurer.example.css' + +export default class DynamicHeightList extends PureComponent { + static propTypes = { + getClassName: PropTypes.func.isRequired, + list: PropTypes.instanceOf(Immutable.List).isRequired, + width: PropTypes.number.isRequired + } + + constructor (props, context) { + super(props, context) + + this._cache = new CellMeasurerCache({ + fixedWidth: true, + minHeight: 50 + }) + + this._rowRenderer = this._rowRenderer.bind(this) + } + + render () { + const { width } = this.props + + return ( + + ) + } + + _rowRenderer ({ index, isScrolling, key, parent, style }) { + const { getClassName, list } = this.props + + const datum = list.get(index % list.size) + const classNames = getClassName({ columnIndex: 0, rowIndex: index }) + + const imageWidth = 300 + const imageHeight = datum.size + + const source = `http://lorempixel.com/${imageWidth}/${imageHeight}/` + + return ( + + {({ measure }) => ( +
+ +
+ )} +
+ ) + } +} diff --git a/source/CellMeasurer/CellMeasurer.DynamicHeightTableColumn.example.js b/source/CellMeasurer/CellMeasurer.DynamicHeightTableColumn.example.js new file mode 100644 index 000000000..386880269 --- /dev/null +++ b/source/CellMeasurer/CellMeasurer.DynamicHeightTableColumn.example.js @@ -0,0 +1,97 @@ +/** @flow */ +import Immutable from 'immutable' +import React, { PropTypes, PureComponent } from 'react' +import CellMeasurer from './CellMeasurer' +import CellMeasurerCache from './CellMeasurerCache' +import { Column, Table } from '../Table' +import styles from './CellMeasurer.example.css' + +export default class DynamicHeightTableColumn extends PureComponent { + static propTypes = { + list: PropTypes.instanceOf(Immutable.List).isRequired, + width: PropTypes.number.isRequired + } + + constructor (props, context) { + super(props, context) + + this._cache = new CellMeasurerCache({ + fixedWidth: true, + minHeight: 25 + }) + + this._columnCellRenderer = this._columnCellRenderer.bind(this) + this._rowGetter = this._rowGetter.bind(this) + } + + render () { + const { width } = this.props + + return ( + + + + +
+ ) + } + + _columnCellRenderer ({ cellData, columnData, dataKey, parent, rowData, rowIndex }) { + const { list } = this.props + + const datum = list.get(rowIndex % list.size) + const content = rowIndex % 5 === 0 + ? '' + : datum.randomLong + + return ( + +
+ {content} +
+
+ ) + } + + _rowGetter ({ index }) { + const { list } = this.props + + return list.get(index % list.size) + } +} diff --git a/source/CellMeasurer/CellMeasurer.DynamicWidthGrid.example.js b/source/CellMeasurer/CellMeasurer.DynamicWidthGrid.example.js new file mode 100644 index 000000000..53f794d98 --- /dev/null +++ b/source/CellMeasurer/CellMeasurer.DynamicWidthGrid.example.js @@ -0,0 +1,76 @@ +/** @flow */ +import Immutable from 'immutable' +import React, { PropTypes, PureComponent } from 'react' +import CellMeasurer from './CellMeasurer' +import CellMeasurerCache from './CellMeasurerCache' +import Grid from '../Grid' +import styles from './CellMeasurer.example.css' + +export default class DynamicWidthGrid extends PureComponent { + static propTypes = { + getClassName: PropTypes.func.isRequired, + getContent: PropTypes.func.isRequired, + list: PropTypes.instanceOf(Immutable.List).isRequired, + width: PropTypes.number.isRequired + } + + constructor (props, context) { + super(props, context) + + this._cache = new CellMeasurerCache({ + defaultWidth: 100, + fixedHeight: true + }) + + this._cellRenderer = this._cellRenderer.bind(this) + } + + render () { + const { width } = this.props + + return ( + + ) + } + + _cellRenderer ({ columnIndex, key, parent, rowIndex, style }) { + const { getClassName, getContent, list } = this.props + + const datum = list.get(rowIndex + columnIndex % list.size) + const classNames = getClassName({ columnIndex, rowIndex }) + const content = getContent({ index: columnIndex, datum, long: false }) + + return ( + +
+ {content} +
+
+ ) + } +} diff --git a/source/CellMeasurer/CellMeasurer.example.css b/source/CellMeasurer/CellMeasurer.example.css index 8677f1dbb..861ee4717 100644 --- a/source/CellMeasurer/CellMeasurer.example.css +++ b/source/CellMeasurer/CellMeasurer.example.css @@ -31,7 +31,7 @@ display: flex; flex-direction: column; justify-content: center; - padding: 0 .5em; + padding: 0.5em 1em; } .cell { border-right: 1px solid #e0e0e0; @@ -44,3 +44,27 @@ text-overflow: ellipsis; overflow: hidden; } + +.tableRow { + border-bottom: 1px solid #eee; +} +.tableColumn { + padding: 5px 15px 5px 0; +} + +.Tab { + border: 1px solid #ddd; + border-radius: 4px; + padding: 4px 6px; + outline: none; + background: #eee; + margin: 4px; + cursor: pointer; +} + +.ActiveTab { + background-color: #4db6ac; + border: 1px solid #3ca59b; + color: rgba(255, 255, 255, 0.8); + cursor: default; +} \ No newline at end of file diff --git a/source/CellMeasurer/CellMeasurer.example.js b/source/CellMeasurer/CellMeasurer.example.js index 6ddd1dc90..b6f5201d2 100644 --- a/source/CellMeasurer/CellMeasurer.example.js +++ b/source/CellMeasurer/CellMeasurer.example.js @@ -1,20 +1,23 @@ /** @flow */ import Immutable from 'immutable' -import React, { Component, PropTypes } from 'react' +import React, { PropTypes, PureComponent } from 'react' import { ContentBox, ContentBoxHeader, ContentBoxParagraph } from '../demo/ContentBox' import AutoSizer from '../AutoSizer' -import CellMeasurer from './CellMeasurer' -import CellSizeCache from './defaultCellSizeCache' -import Grid from '../Grid' -import shallowCompare from 'react-addons-shallow-compare' import cn from 'classnames' import styles from './CellMeasurer.example.css' - -const COLUMN_WIDTH = 150 -const ROW_COUNT = 50 -const ROW_HEIGHT = 35 - -export default class CellMeasurerExample extends Component { +import DynamicWidthGrid from './CellMeasurer.DynamicWidthGrid.example.js' +import DynamiHeightGrid from './CellMeasurer.DynamiHeightGrid.example.js' +import DynamicHeightList from './CellMeasurer.DynamicHeightList.example.js' +import DynamicHeightTableColumn from './CellMeasurer.DynamicHeightTableColumn.example.js' + +const demoComponents = [ + DynamicWidthGrid, + DynamiHeightGrid, + DynamicHeightList, + DynamicHeightTableColumn +] + +export default class CellMeasurerExample extends PureComponent { static contextTypes = { list: PropTypes.instanceOf(Immutable.List).isRequired } @@ -22,16 +25,24 @@ export default class CellMeasurerExample extends Component { constructor (props, context) { super(props, context) - this._uniformSizeCellSizeCache = new CellSizeCache({ - uniformColumnWidth: true, - uniformRowHeight: true - }) + this.state = { + currentTab: 0 + } - this._cellRenderer = this._cellRenderer.bind(this) - this._uniformCellRenderer = this._uniformCellRenderer.bind(this) + this._onClick = this._onClick.bind(this) } render () { + const { list } = this.context + const { currentTab } = this.state + + const buttonProps = { + currentTab, + onClick: this._onClick + } + + const DemoComponent = demoComponents[currentTab] + return ( - This component renders content for a given column or row in order to determine the widest or tallest cell. - It can be used to just-in-time measure dynamic content (eg. messages in a chat interface). + This component can be used to just-in-time measure dynamic content (eg. messages in a chat interface). {({ width }) => (
-

Fixed height, dynamic width

- - {({ getColumnWidth }) => ( - - )} - - -

Fixed width, dynamic height

- - {({ getRowHeight }) => ( - - )} - - -

Uniform width and height

- - {({ getColumnWidth, getRowHeight }) => ( - - )} - +
+ Grid: + dynamic width text + dynamic height text + + List: + dynamic height image + + Table: + mixed fixed and dynamic height text +
+ +
)}
@@ -123,61 +83,43 @@ export default class CellMeasurerExample extends Component { ) } - shouldComponentUpdate (nextProps, nextState) { - return shallowCompare(this, nextProps, nextState) - } - - _cellRenderer ({ columnIndex, key, rowIndex, style }) { - const datum = this._getDatum(rowIndex) - const rowClass = this._getRowClassName(rowIndex) - const classNames = cn(rowClass, styles.cell, { - [styles.centeredCell]: columnIndex > 2 + _onClick (id) { + this.setState({ + currentTab: id }) - - let content - - switch (columnIndex % 3) { - case 0: - content = datum.color - break - case 1: - content = datum.name - break - case 2: - content = datum.random - break - } - - return ( -
- {content} -
- ) } +} - _getDatum (index) { - const { list } = this.context +function getClassName ({ columnIndex, rowIndex }) { + const rowClass = rowIndex % 2 === 0 ? styles.evenRow : styles.oddRow - return list.get(index % list.size) - } + return cn(rowClass, styles.cell, { + [styles.centeredCell]: columnIndex > 2 + }) +} - _getRowClassName (row) { - return row % 2 === 0 ? styles.evenRow : styles.oddRow +function getContent ({ index, datum, long = true }) { + switch (index % 3) { + case 0: + return datum.color + case 1: + return datum.name + case 2: + return long ? datum.randomLong : datum.random } +} - _uniformCellRenderer ({ columnIndex, key, rowIndex, style }) { - return ( -
- {rowIndex}, {columnIndex} -
- ) - } +function Tab ({ children, currentTab, id, onClick }) { + const classNames = cn(styles.Tab, { + [styles.ActiveTab]: currentTab === id + }) + + return ( + + ) } diff --git a/source/CellMeasurer/CellMeasurer.jest.js b/source/CellMeasurer/CellMeasurer.jest.js index 6136dafd0..49371e5cd 100644 --- a/source/CellMeasurer/CellMeasurer.jest.js +++ b/source/CellMeasurer/CellMeasurer.jest.js @@ -1,438 +1,215 @@ +/* global Element */ + import React from 'react' import { render } from '../TestUtils' import CellMeasurer from './CellMeasurer' -import CellSizeCache from './defaultCellSizeCache' - -const HEIGHTS = [75, 50, 125, 100, 150] -const WIDTHS = [125, 50, 200, 175, 100] +import CellMeasurerCache, { DEFAULT_HEIGHT, DEFAULT_WIDTH } from './CellMeasurerCache' // Accounts for the fact that JSDom doesn't support measurements. function mockClientWidthAndHeight ({ - cellMeasurer, height, width }) { - let clientHeightIndex = -1 - let clientWidthIndex = -1 - Object.defineProperty( - cellMeasurer._div, - 'clientHeight', + Element.prototype, + 'offsetHeight', { configurable: true, - get: () => height || HEIGHTS[++clientHeightIndex % HEIGHTS.length] + get: jest.fn().mockReturnValue(height) } ) Object.defineProperty( - cellMeasurer._div, - 'clientWidth', + Element.prototype, + 'offsetWidth', { configurable: true, - get: () => width || WIDTHS[++clientWidthIndex % WIDTHS.length] + get: jest.fn().mockReturnValue(width) } ) } -function createCellRenderer () { - const cellRendererParams = [] - const cellRenderer = (params) => { - cellRendererParams.push(params) - return ( -
- cell -
- ) - } - +function createParent ({ + cache, + invalidateCellSizeAfterRender = jest.fn() +} = {}) { return { - cellRenderer, - cellRendererParams + invalidateCellSizeAfterRender, + props: { + deferredMeasurementCache: cache + } } } function renderHelper ({ - cellRenderer, - cellSizeCache, - columnCount = 1, - columnWidth, - rowCount = 1, - rowHeight + cache = new CellMeasurerCache(), + children =
, + parent } = {}) { - let params render( -
- - {(paramsToSave) => { - params = paramsToSave - - return
foo
- }} -
-
+ + {children} + ) - - mockClientWidthAndHeight({ - cellMeasurer: params.cellMeasurer, - height: rowHeight, - width: columnWidth - }) - - return params } describe('CellMeasurer', () => { - it('should calculate the height of a single-column row', () => { - const { - cellRenderer, - cellRendererParams - } = createCellRenderer() - const { - getColumnWidth, - getRowHeight - } = renderHelper({ - cellRenderer, - columnWidth: 100 + it('componentDidMount() should measure content that is not already in the cache', () => { + const cache = new CellMeasurerCache() + const parent = createParent({ cache }) + + mockClientWidthAndHeight({ + height: 20, + width: 100 }) - expect(cellRendererParams).toEqual([]) - expect(getRowHeight({ index: 0 })).toEqual(75) - expect(cellRendererParams).toEqual([{ columnIndex: 0, index: 0, rowIndex: 0 }]) - expect(getColumnWidth({ index: 0 })).toEqual(100) + const offsetHeightMock = Object.getOwnPropertyDescriptor(Element.prototype, 'offsetHeight').get + const offsetWidthMock = Object.getOwnPropertyDescriptor(Element.prototype, 'offsetWidth').get - // For some reason this explicit unmount is necessary. - // Without it, Jasmine's :afterEach doesn't pick up and unmount the component correctly. - render.unmount() - }) + expect(offsetHeightMock.mock.calls).toHaveLength(0) + expect(offsetWidthMock.mock.calls).toHaveLength(0) + expect(cache.has(0, 0)).toBe(false) - it('should calculate the width of a single-row column', () => { - const { - cellRenderer, - cellRendererParams - } = createCellRenderer() - const { - getColumnWidth, - getRowHeight - } = renderHelper({ - cellRenderer, - rowHeight: 50 - }) + renderHelper({ cache, parent }) - expect(cellRendererParams).toEqual([]) - expect(getColumnWidth({ index: 0 })).toEqual(125) - expect(cellRendererParams).toEqual([{ columnIndex: 0, index: 0, rowIndex: 0 }]) - expect(getRowHeight({ index: 0 })).toEqual(50) + expect(parent.invalidateCellSizeAfterRender).toHaveBeenCalled() + expect(offsetHeightMock.mock.calls).toHaveLength(1) + expect(offsetWidthMock.mock.calls).toHaveLength(1) + expect(cache.has(0, 0)).toBe(true) + expect(cache.getWidth(0, 0)).toBe(100) + expect(cache.getHeight(0, 0)).toBe(20) }) - it('should calculate the height of a multi-column row based on the tallest column-cell', () => { - const { - cellRenderer, - cellRendererParams - } = createCellRenderer() - const { - getColumnWidth, - getRowHeight - } = renderHelper({ - cellRenderer, - columnCount: 5, - columnWidth: 100 - }) + it('componentDidMount() should not measure content that is already in the cache', () => { + const cache = new CellMeasurerCache() + cache.set(0, 0, 100, 20) - expect(cellRendererParams.length).toEqual(0) - expect(getRowHeight({ index: 0 })).toEqual(150) - expect(cellRendererParams.length).toEqual(5) - expect(getColumnWidth({ index: 0 })).toEqual(100) - }) + const parent = createParent({ cache }) - it('should calculate the width of a multi-row column based on the widest row-cell', () => { - const { - cellRenderer, - cellRendererParams - } = createCellRenderer() - const { - getColumnWidth, - getRowHeight - } = renderHelper({ - cellRenderer, - rowCount: 5, - rowHeight: 50 + mockClientWidthAndHeight({ + height: 20, + width: 100 }) - expect(cellRendererParams.length).toEqual(0) - expect(getColumnWidth({ index: 0 })).toEqual(200) - expect(cellRendererParams.length).toEqual(5) - expect(getRowHeight({ index: 0 })).toEqual(50) + expect(cache.has(0, 0)).toBe(true) + + renderHelper({ cache, parent }) + + const offsetHeightMock = Object.getOwnPropertyDescriptor(Element.prototype, 'offsetHeight').get + const offsetWidthMock = Object.getOwnPropertyDescriptor(Element.prototype, 'offsetWidth').get + + expect(parent.invalidateCellSizeAfterRender).not.toHaveBeenCalled() + expect(offsetHeightMock.mock.calls).toHaveLength(0) + expect(offsetWidthMock.mock.calls).toHaveLength(0) }) - it('should support :rowRenderer via :index param for easier List integration', () => { - const { - cellRenderer, - cellRendererParams - } = createCellRenderer() - const { getColumnWidth } = renderHelper({ - cellRenderer, - rowCount: 5, - rowHeight: 50 + it('componentDidUpdate() should measure content that is not already in the cache', () => { + const cache = new CellMeasurerCache() + const parent = createParent({ cache }) + + renderHelper({ cache, parent }) + + cache.clear(0, 0) + parent.invalidateCellSizeAfterRender.mockReset() + + expect(cache.has(0, 0)).toBe(false) + expect(cache.getWidth(0, 0)).toBe(DEFAULT_WIDTH) + expect(cache.getHeight(0, 0)).toBe(DEFAULT_HEIGHT) + + mockClientWidthAndHeight({ + height: 20, + width: 100 }) - getColumnWidth({ index: 0 }) - expect(cellRendererParams.length).toEqual(5) - for (let i = 0; i < 5; i++) { - expect(cellRendererParams[i].index).toEqual(i) - } - }) - it('should cache cell measurements once a cell has been rendered', () => { - const { - cellRenderer, - cellRendererParams - } = createCellRenderer() - const { - getRowHeight - } = renderHelper({ cellRenderer }) - - expect(cellRendererParams).toEqual([]) - getRowHeight({ index: 0 }) - getRowHeight({ index: 1 }) - expect(cellRendererParams).toEqual([ - { columnIndex: 0, index: 0, rowIndex: 0 }, - { columnIndex: 0, index: 1, rowIndex: 1 } - ]) - - getRowHeight({ index: 0 }) - getRowHeight({ index: 1 }) - expect(cellRendererParams).toEqual([ - { columnIndex: 0, index: 0, rowIndex: 0 }, - { columnIndex: 0, index: 1, rowIndex: 1 } - ]) - }) + const offsetHeightMock = Object.getOwnPropertyDescriptor(Element.prototype, 'offsetHeight').get + const offsetWidthMock = Object.getOwnPropertyDescriptor(Element.prototype, 'offsetWidth').get - it('should reset all cached measurements when resetMeasurements() is called', () => { - const { - cellRenderer, - cellRendererParams - } = createCellRenderer() - const { - getRowHeight, - resetMeasurements - } = renderHelper({ cellRenderer }) - - expect(cellRendererParams).toEqual([]) - getRowHeight({ index: 0 }) - getRowHeight({ index: 1 }) - expect(cellRendererParams).toEqual([ - { columnIndex: 0, index: 0, rowIndex: 0 }, - { columnIndex: 0, index: 1, rowIndex: 1 } - ]) - - resetMeasurements() - - getRowHeight({ index: 0 }) - getRowHeight({ index: 1 }) - expect(cellRendererParams).toEqual([ - { columnIndex: 0, index: 0, rowIndex: 0 }, - { columnIndex: 0, index: 1, rowIndex: 1 }, - { columnIndex: 0, index: 0, rowIndex: 0 }, - { columnIndex: 0, index: 1, rowIndex: 1 } - ]) - }) + renderHelper({ cache, parent }) - it('should reset a specific cached row measurement when resetMeasurementForColumn() is called', () => { - const { - cellRenderer, - cellRendererParams - } = createCellRenderer() - const { - getColumnWidth, - resetMeasurementForColumn - } = renderHelper({ cellRenderer }) - - expect(cellRendererParams).toEqual([]) - getColumnWidth({ index: 0 }) - getColumnWidth({ index: 1 }) - expect(cellRendererParams).toEqual([ - { columnIndex: 0, index: 0, rowIndex: 0 }, - { columnIndex: 1, index: 0, rowIndex: 0 } - ]) - - resetMeasurementForColumn(0) - - getColumnWidth({ index: 0 }) - getColumnWidth({ index: 1 }) - expect(cellRendererParams).toEqual([ - { columnIndex: 0, index: 0, rowIndex: 0 }, - { columnIndex: 1, index: 0, rowIndex: 0 }, - { columnIndex: 0, index: 0, rowIndex: 0 } - ]) - }) + expect(cache.has(0, 0)).toBe(true) - it('should reset a specific cached row measurement when resetMeasurementForRow() is called', () => { - const { - cellRenderer, - cellRendererParams - } = createCellRenderer() - const { - getRowHeight, - resetMeasurementForRow - } = renderHelper({ cellRenderer }) - - expect(cellRendererParams).toEqual([]) - getRowHeight({ index: 0 }) - getRowHeight({ index: 1 }) - expect(cellRendererParams).toEqual([ - { columnIndex: 0, index: 0, rowIndex: 0 }, - { columnIndex: 0, index: 1, rowIndex: 1 } - ]) - - resetMeasurementForRow(0) - - getRowHeight({ index: 0 }) - getRowHeight({ index: 1 }) - expect(cellRendererParams).toEqual([ - { columnIndex: 0, index: 0, rowIndex: 0 }, - { columnIndex: 0, index: 1, rowIndex: 1 }, - { columnIndex: 0, index: 0, rowIndex: 0 } - ]) + expect(parent.invalidateCellSizeAfterRender).toHaveBeenCalled() + expect(offsetHeightMock.mock.calls).toHaveLength(1) + expect(offsetWidthMock.mock.calls).toHaveLength(1) + expect(cache.getWidth(0, 0)).toBe(100) + expect(cache.getHeight(0, 0)).toBe(20) }) - it('should allow a custom caching strategy to be specified', () => { - const customCellSizeCache = new CellSizeCache() - const { - cellRenderer, - cellRendererParams - } = createCellRenderer() - const { - getColumnWidth, - getRowHeight - } = renderHelper({ - cellRenderer, - cellSizeCache: customCellSizeCache, - columnCount: 5, - columnWidth: 200, - rowCount: 2, - rowHeight: 50 + it('componentDidUpdate() should not measure content that is already in the cache', () => { + const cache = new CellMeasurerCache() + cache.set(0, 0, 100, 20) + + const parent = createParent({ cache }) + + expect(cache.has(0, 0)).toBe(true) + + mockClientWidthAndHeight({ + height: 20, + width: 100 }) - expect(customCellSizeCache.getColumnWidth(0)).toEqual(undefined) - expect(cellRendererParams.length).toEqual(0) - expect(getColumnWidth({ index: 0 })).toEqual(200) - expect(customCellSizeCache.getColumnWidth(0)).toEqual(200) - expect(cellRendererParams.length).toEqual(2) - expect(getColumnWidth({ index: 0 })).toEqual(200) - expect(cellRendererParams.length).toEqual(2) - - expect(customCellSizeCache.getRowHeight(0)).toEqual(undefined) - expect(cellRendererParams.length).toEqual(2) - expect(getRowHeight({ index: 0 })).toEqual(50) - expect(customCellSizeCache.getRowHeight(0)).toEqual(50) - expect(cellRendererParams.length).toEqual(7) - expect(getRowHeight({ index: 0 })).toEqual(50) - expect(cellRendererParams.length).toEqual(7) + renderHelper({ cache, parent }) + renderHelper({ cache, parent }) + + const offsetHeightMock = Object.getOwnPropertyDescriptor(Element.prototype, 'offsetHeight').get + const offsetWidthMock = Object.getOwnPropertyDescriptor(Element.prototype, 'offsetWidth').get + + expect(parent.invalidateCellSizeAfterRender).not.toHaveBeenCalled() + expect(offsetHeightMock.mock.calls).toHaveLength(0) + expect(offsetWidthMock.mock.calls).toHaveLength(0) }) - it('should support changing the custom caching strategy after initialization', () => { - const customCellSizeCacheA = new CellSizeCache() - const customCellSizeCacheB = new CellSizeCache() - const { cellRenderer } = createCellRenderer() - const { getColumnWidth: getColumnWidthA } = renderHelper({ - cellRenderer, - cellSizeCache: customCellSizeCacheA, - columnCount: 5, - columnWidth: 200 - }) - expect(customCellSizeCacheA.getColumnWidth(0)).toEqual(undefined) - expect(getColumnWidthA({ index: 0 })).toEqual(200) - expect(customCellSizeCacheA.getColumnWidth(0)).toEqual(200) - - const { getColumnWidth: getColumnWidthB } = renderHelper({ - cellRenderer, - cellSizeCache: customCellSizeCacheA, - columnCount: 5, - columnWidth: 100 - }) - expect(customCellSizeCacheA.getColumnWidth(0)).toEqual(200) - expect(getColumnWidthB({ index: 0 })).toEqual(200) - expect(customCellSizeCacheA.getColumnWidth(0)).toEqual(200) - - const { getColumnWidth: getColumnWidthC } = renderHelper({ - cellRenderer, - cellSizeCache: customCellSizeCacheB, - columnCount: 5, - columnWidth: 50 - }) - expect(customCellSizeCacheB.getColumnWidth(0)).toEqual(undefined) - expect(getColumnWidthC({ index: 0 })).toEqual(50) - expect(customCellSizeCacheB.getColumnWidth(0)).toEqual(50) + it('componentDidUpdate() should pass a :measure param to a function child', () => { + const cache = new CellMeasurerCache() + + const children = jest.fn() + children.mockReturnValue(
) + + renderHelper({ cache, children }) + + expect(children).toHaveBeenCalled() + + const params = children.mock.calls[0][0] + + expect(typeof params.measure === 'function').toBe(true) }) - it('should calculate row height just once when using the alternative uniform-size cell size cache', () => { - const cellSizeCache = new CellSizeCache({ - uniformRowHeight: true - }) - const { - cellRenderer, - cellRendererParams - } = createCellRenderer() - const { - getRowHeight - } = renderHelper({ - cellRenderer, - cellSizeCache, - rowCount: 5 + it('should still update cache without a parent Grid', () => { + spyOn(console, 'warn') + + mockClientWidthAndHeight({ + height: 20, + width: 100 }) - expect(cellRendererParams).toEqual([]) - const height1 = getRowHeight({ index: 0 }) - const height2 = getRowHeight({ index: 1 }) - const height3 = getRowHeight({ index: 0 }) - expect(cellRendererParams).toEqual([ - { columnIndex: 0, index: 0, rowIndex: 0 } - ]) + const cache = new CellMeasurerCache() - const expectedHeight = HEIGHTS[0] + renderHelper({ cache }) // No parent Grid - expect(height1).toEqual(expectedHeight) - expect(height2).toEqual(expectedHeight) - expect(height3).toEqual(expectedHeight) + expect(cache.has(0, 0)).toBe(true) + + expect(console.warn).not.toHaveBeenCalled() }) - it('should calculate column-width just once when using the alternative uniform-size cell size cache', () => { - const cellSizeCache = new CellSizeCache({ - uniformColumnWidth: true - }) - const { - cellRenderer, - cellRendererParams - } = createCellRenderer() - const { - getColumnWidth - } = renderHelper({ - cellRenderer, - cellSizeCache, - columnCount: 5 - }) + it('should warn if parent Grid does not specify a :deferredMeasurementCache prop', () => { + spyOn(console, 'warn') - expect(cellRendererParams).toEqual([]) - const width1 = getColumnWidth({ index: 0 }) - const width2 = getColumnWidth({ index: 1 }) - const width3 = getColumnWidth({ index: 0 }) - expect(cellRendererParams).toEqual([ - { columnIndex: 0, index: 0, rowIndex: 0 } - ]) + const parent = createParent() // Parent Grid with no deferredMeasurementCache prop + + renderHelper({ parent }) + + expect(console.warn).toHaveBeenCalledWith( + 'CellMeasurer should be rendered within a Grid that has a deferredMeasurementCache prop.' + ) - const expectedWidth = WIDTHS[0] + renderHelper({ parent }) - expect(width1).toEqual(expectedWidth) - expect(width2).toEqual(expectedWidth) - expect(width3).toEqual(expectedWidth) + expect(console.warn).toHaveBeenCalledTimes(1) }) }) diff --git a/source/CellMeasurer/CellMeasurer.js b/source/CellMeasurer/CellMeasurer.js index c5195a87e..a98e5bc17 100644 --- a/source/CellMeasurer/CellMeasurer.js +++ b/source/CellMeasurer/CellMeasurer.js @@ -1,260 +1,120 @@ /** @flow */ -import React, { Component, PropTypes } from 'react' -import shallowCompare from 'react-addons-shallow-compare' -import ReactDOM from 'react-dom' -import CellSizeCache from './defaultCellSizeCache' - -/** - * Measures a Grid cell's contents by rendering them in a way that is not visible to the user. - * Either a fixed width or height may be provided if it is desirable to measure only in one direction. - */ -export default class CellMeasurer extends Component { - static propTypes = { - /** - * Renders a cell given its indices. - * Should implement the following interface: ({ columnIndex: number, rowIndex: number }): PropTypes.node - */ - cellRenderer: PropTypes.func.isRequired, - - /** - * Optional, custom caching strategy for cell sizes. - */ - cellSizeCache: PropTypes.object, - - /** - * Function responsible for rendering a virtualized component. - * This function should implement the following signature: - * ({ getColumnWidth, getRowHeight, resetMeasurements }) => PropTypes.element - */ - children: PropTypes.func.isRequired, - - /** - * Number of columns in grid. - */ - columnCount: PropTypes.number.isRequired, - - /** - * A Node, Component instance, or function that returns either. - * If this property is not specified the document body will be used. - */ - container: React.PropTypes.oneOfType([ - React.PropTypes.func, - React.PropTypes.node - ]), - - /** - * Assign a fixed :height in order to measure dynamic text :width only. - */ - height: PropTypes.number, - - /** - * Number of rows in grid. - */ - rowCount: PropTypes.number.isRequired, - - /** - * Assign a fixed :width in order to measure dynamic text :height only. - */ - width: PropTypes.number - }; - - constructor (props, state) { - super(props, state) - - this._cellSizeCache = props.cellSizeCache || new CellSizeCache() - - this.getColumnWidth = this.getColumnWidth.bind(this) - this.getRowHeight = this.getRowHeight.bind(this) - this.resetMeasurements = this.resetMeasurements.bind(this) - this.resetMeasurementForColumn = this.resetMeasurementForColumn.bind(this) - this.resetMeasurementForRow = this.resetMeasurementForRow.bind(this) - } - - getColumnWidth ({ index }) { - const columnWidth = this._cellSizeCache.getColumnWidth(index) - if (columnWidth != null) { - return columnWidth - } - - const { rowCount } = this.props - - let maxWidth = 0 - - for (let rowIndex = 0; rowIndex < rowCount; rowIndex++) { - let { width } = this._measureCell({ - clientWidth: true, - columnIndex: index, - rowIndex - }) - - maxWidth = Math.max(maxWidth, width) +import { PureComponent } from 'react' +import { findDOMNode } from 'react-dom' + +type Props = { + cache: mixed, + children: mixed, + columnIndex: number, + parent: mixed, + rowIndex: number, + style: mixed +}; + +function warnAboutImproperUse (parent) { + if (process.env.NODE_ENV !== 'production') { + if ( + parent && + parent.props.deferredMeasurementCache === undefined && + parent.__warnedAboutImproperUse !== true + ) { + parent.__warnedAboutImproperUse = true + console.warn('CellMeasurer should be rendered within a Grid that has a deferredMeasurementCache prop.') } - - this._cellSizeCache.setColumnWidth(index, maxWidth) - - return maxWidth } +} - getRowHeight ({ index }) { - const rowHeight = this._cellSizeCache.getRowHeight(index) - if (rowHeight != null) { - return rowHeight - } - - const { columnCount } = this.props - - let maxHeight = 0 - - for (let columnIndex = 0; columnIndex < columnCount; columnIndex++) { - let { height } = this._measureCell({ - clientHeight: true, - columnIndex, - rowIndex: index - }) - - maxHeight = Math.max(maxHeight, height) - } - - this._cellSizeCache.setRowHeight(index, maxHeight) +// Prevent Grid from warning about missing :style prop on CellMeasurer. +// It's understood that style will often be passed to the child instead. +const EMPTY_OBJECT = {} - return maxHeight - } +/** + * Wraps a cell and measures its rendered content. + * Measurements are stored in a per-cell cache. + * Cached-content is not be re-measured. + */ +export default class CellMeasurer extends PureComponent { + props: Props; - resetMeasurementForColumn (columnIndex) { - this._cellSizeCache.clearColumnWidth(columnIndex) + static defaultProps = { + style: EMPTY_OBJECT } - resetMeasurementForRow (rowIndex) { - this._cellSizeCache.clearRowHeight(rowIndex) - } + constructor (props, context) { + super(props, context) - resetMeasurements () { - this._cellSizeCache.clearAllColumnWidths() - this._cellSizeCache.clearAllRowHeights() + this._measure = this._measure.bind(this) } componentDidMount () { - this._renderAndMount() + this._maybeMeasureCell() } - componentWillReceiveProps (nextProps) { - const { cellSizeCache } = this.props - - if (cellSizeCache !== nextProps.cellSizeCache) { - this._cellSizeCache = nextProps.cellSizeCache - } - - this._updateDivDimensions(nextProps) - } - - componentWillUnmount () { - this._unmountContainer() + componentDidUpdate (prevProps, prevState) { + this._maybeMeasureCell() } render () { const { children } = this.props - return children({ - cellMeasurer: this, - getColumnWidth: this.getColumnWidth, - getRowHeight: this.getRowHeight, - resetMeasurements: this.resetMeasurements, - resetMeasurementForColumn: this.resetMeasurementForColumn, - resetMeasurementForRow: this.resetMeasurementForRow - }) - } - - shouldComponentUpdate (nextProps, nextState) { - return shallowCompare(this, nextProps, nextState) - } - - _getContainerNode (props) { - const { container } = props - - if (container) { - return ReactDOM.findDOMNode( - typeof container === 'function' - ? container() - : container - ) - } else { - return document.body - } - } - - _measureCell ({ - clientHeight = false, - clientWidth = true, - columnIndex, - rowIndex - }) { - const { cellRenderer } = this.props - - const rendered = cellRenderer({ - columnIndex, - index: rowIndex, // Simplify List :rowRenderer use case - rowIndex - }) + if (process.env.NODE_ENV !== 'production') { + const { parent } = this.props - // Handle edge case where this method is called before the CellMeasurer has completed its initial render (and mounted). - this._renderAndMount() - - // @TODO Keep an eye on this for future React updates as the interface may change: - // https://twitter.com/soprano/status/737316379712331776 - ReactDOM.unstable_renderSubtreeIntoContainer(this, rendered, this._div) - - const measurements = { - height: clientHeight && this._div.clientHeight, - width: clientWidth && this._div.clientWidth + warnAboutImproperUse(parent) } - ReactDOM.unmountComponentAtNode(this._div) - - return measurements + return typeof children === 'function' + ? children({ measure: this._measure }) + : children } - _renderAndMount () { - if (!this._div) { - this._div = document.createElement('div') - this._div.style.display = 'inline-block' - this._div.style.position = 'absolute' - this._div.style.visibility = 'hidden' - this._div.style.zIndex = -1 + _maybeMeasureCell () { + const { cache, columnIndex, parent, rowIndex } = this.props - this._updateDivDimensions(this.props) + if (!cache.has(rowIndex, columnIndex)) { + const node = findDOMNode(this) + const height = node.offsetHeight + const width = node.offsetWidth - this._containerNode = this._getContainerNode(this.props) - this._containerNode.appendChild(this._div) - } - } - - _unmountContainer () { - if (this._div) { - this._containerNode.removeChild(this._div) + cache.set( + rowIndex, + columnIndex, + width, + height + ) - this._div = null + // If size has changed, let Grid know to re-render. + if (parent !== undefined) { + parent.invalidateCellSizeAfterRender({ + columnIndex, + rowIndex + }) + } } - - this._containerNode = null } - _updateDivDimensions (props) { - const { height, width } = props + _measure () { + const { cache, columnIndex, parent, rowIndex } = this.props - if ( - height && - height !== this._divHeight - ) { - this._divHeight = height - this._div.style.height = `${height}px` - } + const node = findDOMNode(this) + const height = node.offsetHeight + const width = node.offsetWidth if ( - width && - width !== this._divWidth + height !== cache.getHeight(rowIndex, columnIndex) || + width !== cache.getWidth(rowIndex, columnIndex) ) { - this._divWidth = width - this._div.style.width = `${width}px` + cache.set( + rowIndex, + columnIndex, + width, + height + ) + + parent.recomputeGridSize({ + columnIndex, + rowIndex + }) } } } diff --git a/source/CellMeasurer/CellMeasurerCache.jest.js b/source/CellMeasurer/CellMeasurerCache.jest.js new file mode 100644 index 000000000..7ea12d3a1 --- /dev/null +++ b/source/CellMeasurer/CellMeasurerCache.jest.js @@ -0,0 +1,116 @@ +import CellMeasurerCache, { DEFAULT_HEIGHT, DEFAULT_WIDTH } from './CellMeasurerCache' + +describe('CellMeasurerCache', () => { + it('should override defaultHeight/defaultWidth if minHeight/minWidth are greater', () => { + const cache = new CellMeasurerCache({ + defaultHeight: 20, + defaultWidth: 100, + minHeight: 30, + minWidth: 150 + }) + cache.set(0, 0, 50, 10) + expect(cache.getHeight(0, 0)).toBe(30) + expect(cache.getWidth(0, 0)).toBe(150) + expect(cache.rowHeight({ index: 0 })).toBe(30) + expect(cache.columnWidth({ index: 0 })).toBe(150) + }) + + it('should correctly report cache status', () => { + const cache = new CellMeasurerCache() + expect(cache.has(0, 0)).toBe(false) + }) + + it('should cache cells', () => { + const cache = new CellMeasurerCache() + cache.set(0, 0, 100, 20) + expect(cache.has(0, 0)).toBe(true) + }) + + it('should return the correct default sizes for uncached cells if specified', () => { + const cache = new CellMeasurerCache({ + defaultHeight: 20, + defaultWidth: 100, + minHeight: 15, + minWidth: 80 + }) + expect(cache.getWidth(0, 0)).toBe(100) + expect(cache.getHeight(0, 0)).toBe(20) + cache.set(0, 0, 70, 10) + expect(cache.getWidth(0, 0)).toBe(80) + expect(cache.getHeight(0, 0)).toBe(15) + }) + + it('should clear a single cached cell', () => { + const cache = new CellMeasurerCache() + cache.set(0, 0, 100, 20) + cache.set(1, 0, 100, 20) + expect(cache.has(0, 0)).toBe(true) + expect(cache.has(1, 0)).toBe(true) + cache.clear(0, 0) + expect(cache.has(0, 0)).toBe(false) + expect(cache.has(1, 0)).toBe(true) + }) + + it('should clear all cached cells', () => { + const cache = new CellMeasurerCache() + cache.set(0, 0, 100, 20) + cache.set(1, 0, 100, 20) + expect(cache.has(0, 0)).toBe(true) + expect(cache.has(1, 0)).toBe(true) + cache.clearAll() + expect(cache.has(0, 0)).toBe(false) + expect(cache.has(1, 0)).toBe(false) + }) + + it('should support a custom :keyMapper', () => { + const keyMapper = jest.fn() + keyMapper.mockReturnValue('a') + + const cache = new CellMeasurerCache({ keyMapper }) + cache.set(0, 0, 100, 20) + expect(cache.has(0, 0)).toBe(true) + + keyMapper.mock.calls.splice(0) + keyMapper.mockReturnValue('b') + expect(cache.has(0, 0)).toBe(false) + expect(keyMapper.mock.calls).toHaveLength(1) + }) + + it('should provide a Grid-compatible :columnWidth method', () => { + const cache = new CellMeasurerCache() + expect(cache.columnWidth({ index: 0 })).toBe(DEFAULT_WIDTH) + cache.set(0, 0, 100, 50) + expect(cache.columnWidth({ index: 0 })).toBe(100) + expect(cache.columnWidth({ index: 1 })).toBe(DEFAULT_WIDTH) + cache.set(1, 0, 75, 50) + expect(cache.columnWidth({ index: 0 })).toBe(100) + cache.set(2, 0, 125, 50) + expect(cache.columnWidth({ index: 0 })).toBe(125) + }) + + it('should provide a Grid-compatible :rowHeight method', () => { + const cache = new CellMeasurerCache() + expect(cache.rowHeight({ index: 0 })).toBe(DEFAULT_HEIGHT) + cache.set(0, 0, 100, 50) + expect(cache.rowHeight({ index: 0 })).toBe(50) + expect(cache.rowHeight({ index: 1 })).toBe(DEFAULT_HEIGHT) + cache.set(0, 1, 100, 25) + expect(cache.rowHeight({ index: 0 })).toBe(50) + cache.set(0, 2, 100, 75) + expect(cache.rowHeight({ index: 0 })).toBe(75) + }) + + it('should return the :defaultWidth for :columnWidth if not measured', () => { + const cache = new CellMeasurerCache({ + defaultWidth: 25 + }) + expect(cache.columnWidth({ index: 0 })).toBe(25) + }) + + it('should return the :defaultHeight for :rowHeight if not measured', () => { + const cache = new CellMeasurerCache({ + defaultHeight: 25 + }) + expect(cache.rowHeight({ index: 0 })).toBe(25) + }) +}) diff --git a/source/CellMeasurer/CellMeasurerCache.js b/source/CellMeasurer/CellMeasurerCache.js new file mode 100644 index 000000000..f8289e6eb --- /dev/null +++ b/source/CellMeasurer/CellMeasurerCache.js @@ -0,0 +1,180 @@ +/** @flow */ + +export const DEFAULT_HEIGHT = 30 +export const DEFAULT_WIDTH = 100 + +// Enables more intelligent mapping of a given column and row index to an item ID. +// This prevents a cell cache from being invalidated when its parent collection is modified. +type KeyMapper = ( + rowIndex: number, + columnIndex: number +) => any; + +type CellMeasurerCacheParams = { + defaultHeight ?: number, + defaultWidth ?: number, + fixedHeight ?: boolean, + fixedWidth ?: boolean, + minHeight?: number, + minWidth?: number, + keyMapper ?: KeyMapper +}; + +type Cache = { + [key: any]: number +}; + +type IndexParam = { + index: number +}; + +/** + * Caches measurements for a given cell. + */ +export default class CellMeasurerCache { + _cellHeightCache: Cache; + _cellWidthCache: Cache; + _columnWidthCache: Cache; + _defaultHeight: ?number; + _defaultWidth: ?number; + _minHeight: ?number; + _minWidth: ?number; + _keyMapper: KeyMapper; + _rowHeightCache: Cache; + + constructor (params : CellMeasurerCacheParams = {}) { + const { + defaultHeight, + defaultWidth, + fixedHeight, + fixedWidth, + keyMapper, + minHeight, + minWidth + } = params + + this._hasFixedHeight = fixedHeight === true + this._hasFixedWidth = fixedWidth === true + this._minHeight = minHeight || 0 + this._minWidth = minWidth || 0 + this._keyMapper = keyMapper || defaultKeyMapper + + this._defaultHeight = Math.max(this._minHeight, defaultHeight || DEFAULT_HEIGHT) + this._defaultWidth = Math.max(this._minWidth, defaultWidth || DEFAULT_WIDTH) + + this._columnCount = 0 + this._rowCount = 0 + + this._cellHeightCache = {} + this._cellWidthCache = {} + this._columnWidthCache = {} + this._rowHeightCache = {} + } + + clear ( + rowIndex: number, + columnIndex: number + ) : void { + const key = this._keyMapper(rowIndex, columnIndex) + + delete this._cellHeightCache[key] + delete this._cellWidthCache[key] + } + + clearAll () : void { + this._cellHeightCache = {} + this._cellWidthCache = {} + } + + columnWidth = ({ index } : IndexParam) => { + return this._columnWidthCache.hasOwnProperty(index) + ? this._columnWidthCache[index] + : this._defaultWidth + } + + hasFixedHeight () : boolean { + return this._hasFixedHeight + } + + hasFixedWidth () : boolean { + return this._hasFixedWidth + } + + getHeight ( + rowIndex: number, + columnIndex: number + ) : ?number { + const key = this._keyMapper(rowIndex, columnIndex) + + return this._cellHeightCache.hasOwnProperty(key) + ? Math.max(this._minHeight, this._cellHeightCache[key]) + : this._defaultHeight + } + + getWidth ( + rowIndex: number, + columnIndex: number + ) : ?number { + const key = this._keyMapper(rowIndex, columnIndex) + + return this._cellWidthCache.hasOwnProperty(key) + ? Math.max(this._minWidth, this._cellWidthCache[key]) + : this._defaultWidth + } + + has ( + rowIndex: number, + columnIndex: number + ) : boolean { + const key = this._keyMapper(rowIndex, columnIndex) + + return this._cellHeightCache.hasOwnProperty(key) + } + + rowHeight = ({ index } : IndexParam) => { + return this._rowHeightCache.hasOwnProperty(index) + ? this._rowHeightCache[index] + : this._defaultHeight + } + + set ( + rowIndex: number, + columnIndex: number, + width: number, + height: number + ) : void { + const key = this._keyMapper(rowIndex, columnIndex) + + if (columnIndex >= this._columnCount) { + this._columnCount = columnIndex + 1 + } + if (rowIndex >= this._rowCount) { + this._rowCount = rowIndex + 1 + } + + // Size is cached per cell so we don't have to re-measure if cells are re-ordered. + this._cellHeightCache[key] = height + this._cellWidthCache[key] = width + + // :columnWidth and :rowHeight are derived based on all cells in a column/row. + // Pre-cache these derived values for faster lookup later. + // Reads are expected to occur more frequently than writes in this case. + let columnWidth = 0 + for (let i = 0; i < this._rowCount; i++) { + columnWidth = Math.max(columnWidth, this.getWidth(i, columnIndex)) + } + let rowHeight = 0 + for (let i = 0; i < this._columnCount; i++) { + rowHeight = Math.max(rowHeight, this.getHeight(rowIndex, i)) + } + this._columnWidthCache[columnIndex] = columnWidth + this._rowHeightCache[rowIndex] = rowHeight + } +} + +function defaultKeyMapper ( + rowIndex: number, + columnIndex: number +): any { + return `${rowIndex}-${columnIndex}` +} diff --git a/source/CellMeasurer/defaultCellSizeCache.jest.js b/source/CellMeasurer/defaultCellSizeCache.jest.js deleted file mode 100644 index 820df074a..000000000 --- a/source/CellMeasurer/defaultCellSizeCache.jest.js +++ /dev/null @@ -1,97 +0,0 @@ -import CellSizeCache from './defaultCellSizeCache' - -describe('CellSizeCache', () => { - function verifyUniformColumnWidths (cellSizeCache) { - cellSizeCache.setColumnWidth(1, 1) - expect(cellSizeCache.getColumnWidth(1)).toEqual(1) - expect(cellSizeCache.getColumnWidth(2)).toEqual(1) - cellSizeCache.setColumnWidth(2, 2) - expect(cellSizeCache.getColumnWidth(1)).toEqual(2) - expect(cellSizeCache.getColumnWidth(2)).toEqual(2) - expect(cellSizeCache.getColumnWidth(3)).toEqual(2) - cellSizeCache.clearColumnWidth(1) - expect(cellSizeCache.getColumnWidth(1)).toEqual(undefined) - expect(cellSizeCache.getColumnWidth(2)).toEqual(undefined) - } - - function verifyUniformRowHeights (cellSizeCache) { - cellSizeCache.setRowHeight(1, 1) - expect(cellSizeCache.getRowHeight(1)).toEqual(1) - expect(cellSizeCache.getRowHeight(2)).toEqual(1) - cellSizeCache.setRowHeight(2, 2) - expect(cellSizeCache.getRowHeight(1)).toEqual(2) - expect(cellSizeCache.getRowHeight(2)).toEqual(2) - expect(cellSizeCache.getRowHeight(3)).toEqual(2) - cellSizeCache.clearRowHeight(1) - expect(cellSizeCache.getRowHeight(1)).toEqual(undefined) - expect(cellSizeCache.getRowHeight(2)).toEqual(undefined) - } - - function verifyVaryingColumnWidths (cellSizeCache) { - cellSizeCache.setColumnWidth(1, 1) - expect(cellSizeCache.getColumnWidth(1)).toEqual(1) - expect(cellSizeCache.getColumnWidth(2)).toEqual(undefined) - cellSizeCache.setColumnWidth(2, 2) - expect(cellSizeCache.getColumnWidth(1)).toEqual(1) - expect(cellSizeCache.getColumnWidth(2)).toEqual(2) - expect(cellSizeCache.getColumnWidth(3)).toEqual(undefined) - cellSizeCache.clearColumnWidth(1) - expect(cellSizeCache.getColumnWidth(1)).toEqual(undefined) - expect(cellSizeCache.getColumnWidth(2)).toEqual(2) - cellSizeCache.clearAllColumnWidths() - expect(cellSizeCache.getColumnWidth(1)).toEqual(undefined) - expect(cellSizeCache.getColumnWidth(2)).toEqual(undefined) - } - - function verifyVaryingRowHeights (cellSizeCache) { - cellSizeCache.setRowHeight(1, 1) - expect(cellSizeCache.getRowHeight(1)).toEqual(1) - expect(cellSizeCache.getRowHeight(2)).toEqual(undefined) - cellSizeCache.setRowHeight(2, 2) - expect(cellSizeCache.getRowHeight(1)).toEqual(1) - expect(cellSizeCache.getRowHeight(2)).toEqual(2) - expect(cellSizeCache.getRowHeight(3)).toEqual(undefined) - cellSizeCache.clearRowHeight(1) - expect(cellSizeCache.getRowHeight(1)).toEqual(undefined) - expect(cellSizeCache.getRowHeight(2)).toEqual(2) - cellSizeCache.clearAllRowHeights() - expect(cellSizeCache.getRowHeight(1)).toEqual(undefined) - expect(cellSizeCache.getRowHeight(2)).toEqual(undefined) - } - - it('should support uniform height and width', () => { - const cellSizeCache = new CellSizeCache({ - uniformColumnWidth: true, - uniformRowHeight: true - }) - verifyUniformColumnWidths(cellSizeCache) - verifyUniformRowHeights(cellSizeCache) - }) - - it('should support uniform height only', () => { - const cellSizeCache = new CellSizeCache({ - uniformColumnWidth: false, - uniformRowHeight: true - }) - verifyVaryingColumnWidths(cellSizeCache) - verifyUniformRowHeights(cellSizeCache) - }) - - it('should support uniform width only', () => { - const cellSizeCache = new CellSizeCache({ - uniformColumnWidth: true, - uniformRowHeight: false - }) - verifyUniformColumnWidths(cellSizeCache) - verifyVaryingRowHeights(cellSizeCache) - }) - - it('should support varying height and width', () => { - const cellSizeCache = new CellSizeCache({ - uniformColumnWidth: false, - uniformRowHeight: false - }) - verifyVaryingColumnWidths(cellSizeCache) - verifyVaryingRowHeights(cellSizeCache) - }) -}) diff --git a/source/CellMeasurer/defaultCellSizeCache.js b/source/CellMeasurer/defaultCellSizeCache.js deleted file mode 100644 index 3fc05d60a..000000000 --- a/source/CellMeasurer/defaultCellSizeCache.js +++ /dev/null @@ -1,64 +0,0 @@ -/** - * Default CellMeasurer `cellSizeCache` implementation. - * Permanently caches all cell sizes (identified by column and row index) unless explicitly cleared. - * Can be configured to handle uniform cell widths and/or heights as a way of optimizing certain use cases. - */ -export default class DefaultCellSizeCache { - constructor ({ - uniformRowHeight = false, - uniformColumnWidth = false - } = {}) { - this._uniformRowHeight = uniformRowHeight - this._uniformColumnWidth = uniformColumnWidth - - this._cachedColumnWidth = undefined - this._cachedRowHeight = undefined - - this._cachedColumnWidths = {} - this._cachedRowHeights = {} - } - - clearAllColumnWidths () { - this._cachedColumnWidth = undefined - this._cachedColumnWidths = {} - } - - clearAllRowHeights () { - this._cachedRowHeight = undefined - this._cachedRowHeights = {} - } - - clearColumnWidth (index: any) { - this._cachedColumnWidth = undefined - - delete this._cachedColumnWidths[index] - } - - clearRowHeight (index: any) { - this._cachedRowHeight = undefined - - delete this._cachedRowHeights[index] - } - - getColumnWidth (index: any): ?number { - return this._uniformColumnWidth - ? this._cachedColumnWidth - : this._cachedColumnWidths[index] - } - - getRowHeight (index: any): ?number { - return this._uniformRowHeight - ? this._cachedRowHeight - : this._cachedRowHeights[index] - } - - setColumnWidth (index: any, width: number) { - this._cachedColumnWidth = width - this._cachedColumnWidths[index] = width - } - - setRowHeight (index: any, height: number) { - this._cachedRowHeight = height - this._cachedRowHeights[index] = height - } -} diff --git a/source/CellMeasurer/idCellSizeCache.jest.js b/source/CellMeasurer/idCellSizeCache.jest.js deleted file mode 100644 index 04846c1c8..000000000 --- a/source/CellMeasurer/idCellSizeCache.jest.js +++ /dev/null @@ -1,68 +0,0 @@ -import idCellSizeCache from './idCellSizeCache' - -describe('idCellSizeCache', () => { - it('should track width and height using id instead of index', () => { - const indexToIdMap = { - 0: 'foo', - 1: 'bar', - 2: 'baz' - } - const indexToIdMapCalls = [] - const cache = idCellSizeCache({ - indexToIdMap: (index) => { - indexToIdMapCalls.push(index) - return indexToIdMap[index] - } - }) - - // Set values with initial indices - cache.setColumnWidth(0, 100) - cache.setRowHeight(0, 25) - cache.setColumnWidth(1, 200) - cache.setRowHeight(1, 50) - cache.setColumnWidth(2, 300) - cache.setRowHeight(2, 75) - - // Mimic a sort operation - indexToIdMap[0] = 'baz' - indexToIdMap[1] = 'bar' - indexToIdMap[2] = 'foo' - - // Previous widths and heights should still be available - expect(cache.getColumnWidth(0)).toBe(300) - expect(cache.getRowHeight(0)).toBe(75) - expect(cache.getColumnWidth(1)).toBe(200) - expect(cache.getRowHeight(1)).toBe(50) - expect(cache.getColumnWidth(2)).toBe(100) - expect(cache.getRowHeight(2)).toBe(25) - - // Clear a specific item - cache.clearColumnWidth(0) - cache.clearRowHeight(0) - - // Mimic a sort operation - indexToIdMap[0] = 'bar' - indexToIdMap[1] = 'foo' - indexToIdMap[2] = 'baz' - - // Previous widths and heights should still be available - expect(cache.getColumnWidth(0)).toBe(200) - expect(cache.getRowHeight(0)).toBe(50) - expect(cache.getColumnWidth(1)).toBe(100) - expect(cache.getRowHeight(1)).toBe(25) - expect(cache.getColumnWidth(2)).toBeUndefined() - expect(cache.getRowHeight(2)).toBeUndefined() - - // Clear all items - cache.clearAllColumnWidths() - cache.clearAllRowHeights() - - // No sizes should be available - expect(cache.getColumnWidth(0)).toBeUndefined() - expect(cache.getRowHeight(0)).toBeUndefined() - expect(cache.getColumnWidth(1)).toBeUndefined() - expect(cache.getRowHeight(1)).toBeUndefined() - expect(cache.getColumnWidth(2)).toBeUndefined() - expect(cache.getRowHeight(2)).toBeUndefined() - }) -}) diff --git a/source/CellMeasurer/idCellSizeCache.js b/source/CellMeasurer/idCellSizeCache.js deleted file mode 100644 index d6f6e59f0..000000000 --- a/source/CellMeasurer/idCellSizeCache.js +++ /dev/null @@ -1,72 +0,0 @@ -/** @flow */ -import DefaultCellSizeCache from './defaultCellSizeCache' - -type IdCellSizeCacheConstructorParams = { - indexToIdMap : Function, - uniformRowHeight ?: boolean, - uniformColumnWidth ?: boolean -}; - -/** - * Alternate CellMeasurer `cellSizeCache` implementation. - * Similar to `defaultCellSizeCache` except that sizes are tied to data id rather than index. - * Requires an index-to-id map function (passed in externally) to operate. - */ -export default function idCellSizeCache ({ - indexToIdMap, - uniformColumnWidth = false, - uniformRowHeight = false -} : IdCellSizeCacheConstructorParams) { - const cellSizeCache = new DefaultCellSizeCache({ - uniformColumnWidth, - uniformRowHeight - }) - - return { - clearAllColumnWidths () { - cellSizeCache.clearAllColumnWidths() - }, - - clearAllRowHeights () { - cellSizeCache.clearAllRowHeights() - }, - - clearColumnWidth (index: number) { - cellSizeCache.clearColumnWidth( - indexToIdMap(index) - ) - }, - - clearRowHeight (index: number) { - cellSizeCache.clearRowHeight( - indexToIdMap(index) - ) - }, - - getColumnWidth (index: number): ?number { - return cellSizeCache.getColumnWidth( - indexToIdMap(index) - ) - }, - - getRowHeight (index: number): ?number { - return cellSizeCache.getRowHeight( - indexToIdMap(index) - ) - }, - - setColumnWidth (index: number, width: number) { - cellSizeCache.setColumnWidth( - indexToIdMap(index), - width - ) - }, - - setRowHeight (index: number, height: number) { - cellSizeCache.setRowHeight( - indexToIdMap(index), - height - ) - } - } -} diff --git a/source/CellMeasurer/index.js b/source/CellMeasurer/index.js index f2ba0dc78..33516996e 100644 --- a/source/CellMeasurer/index.js +++ b/source/CellMeasurer/index.js @@ -1,4 +1,3 @@ export default from './CellMeasurer' export CellMeasurer from './CellMeasurer' -export defaultCellSizeCache from './defaultCellSizeCache' -export idCellSizeCache from './idCellSizeCache' +export CellMeasurerCache from './CellMeasurerCache' diff --git a/source/Collection/Collection.example.js b/source/Collection/Collection.example.js index ed3e8f0d9..d0bcc989b 100644 --- a/source/Collection/Collection.example.js +++ b/source/Collection/Collection.example.js @@ -1,11 +1,10 @@ /** @flow */ -import React, { Component, PropTypes } from 'react' +import React, { PropTypes, PureComponent } from 'react' import Immutable from 'immutable' import { ContentBox, ContentBoxHeader, ContentBoxParagraph } from '../demo/ContentBox' import { LabeledInput, InputRow } from '../demo/LabeledInput' import AutoSizer from '../AutoSizer' import Collection from './Collection' -import shallowCompare from 'react-addons-shallow-compare' import styles from './Collection.example.css' // Defines a pattern of sizes and positions for a range of 10 rotating cells @@ -13,7 +12,7 @@ import styles from './Collection.example.css' const GUTTER_SIZE = 3 const CELL_WIDTH = 75 -export default class CollectionExample extends Component { +export default class CollectionExample extends PureComponent { static contextTypes = { list: PropTypes.instanceOf(Immutable.List).isRequired }; @@ -126,10 +125,6 @@ export default class CollectionExample extends Component { ) } - shouldComponentUpdate (nextProps, nextState) { - return shallowCompare(this, nextProps, nextState) - } - _cellRenderer ({ index, isScrolling, key, style }) { const { list } = this.context const { showScrollingPlaceholder } = this.state diff --git a/source/Collection/Collection.jest.js b/source/Collection/Collection.jest.js index 379c4c196..38fa9405b 100644 --- a/source/Collection/Collection.jest.js +++ b/source/Collection/Collection.jest.js @@ -719,4 +719,19 @@ describe('Collection', () => { done() }) }) + + // See issue #568 for more + it('forceUpdate will also forceUpdate the inner CollectionView', () => { + const cellRenderer = jest.fn() + cellRenderer.mockImplementation(({ key }) =>
) + + const rendered = render(getMarkup({ cellRenderer })) + + expect(cellRenderer).toHaveBeenCalled() + + cellRenderer.mockReset() + rendered.forceUpdate() + + expect(cellRenderer).toHaveBeenCalled() + }) }) diff --git a/source/Collection/Collection.js b/source/Collection/Collection.js index 423500314..df293d024 100644 --- a/source/Collection/Collection.js +++ b/source/Collection/Collection.js @@ -1,15 +1,15 @@ -import React, { Component, PropTypes } from 'react' +/** @flow */ +import React, { PropTypes, PureComponent } from 'react' import CollectionView from './CollectionView' import calculateSizeAndPositionData from './utils/calculateSizeAndPositionData' import getUpdatedOffsetForIndex from '../utils/getUpdatedOffsetForIndex' -import shallowCompare from 'react-addons-shallow-compare' import type { ScrollPosition, SizeInfo } from './types' /** * Renders scattered or non-linear data. * Unlike Grid, which renders checkerboard data, Collection can render arbitrarily positioned- even overlapping- data. */ -export default class Collection extends Component { +export default class Collection extends PureComponent { static propTypes = { 'aria-label': PropTypes.string, @@ -64,6 +64,12 @@ export default class Collection extends Component { this._setCollectionViewRef = this._setCollectionViewRef.bind(this) } + forceUpdate () { + if (this._collectionView !== undefined) { + this._collectionView.forceUpdate() + } + } + /** See Collection#recomputeCellSizesAndPositions */ recomputeCellSizesAndPositions () { this._cellCache = [] @@ -85,10 +91,6 @@ export default class Collection extends Component { ) } - shouldComponentUpdate (nextProps, nextState) { - return shallowCompare(this, nextProps, nextState) - } - /** CellLayoutManager interface */ calculateSizeAndPositionData () { diff --git a/source/Collection/CollectionView.js b/source/Collection/CollectionView.js index f60418e2a..7cd22a666 100644 --- a/source/Collection/CollectionView.js +++ b/source/Collection/CollectionView.js @@ -1,11 +1,10 @@ /** @flow */ -import React, { Component, PropTypes } from 'react' +import React, { PropTypes, PureComponent } from 'react' import cn from 'classnames' import createCallbackMemoizer from '../utils/createCallbackMemoizer' import getScrollbarSize from 'dom-helpers/util/scrollbarSize' -import shallowCompare from 'react-addons-shallow-compare' -// @TODO It would be nice to refactor Grid to use this code as well. +// @TODO Merge Collection and CollectionView /** * Specifies the number of miliseconds during which to disable pointer events while a scroll is in progress. @@ -26,7 +25,7 @@ const SCROLL_POSITION_CHANGE_REASONS = { * Monitors changes in properties (eg. cellCount) and state (eg. scroll offsets) to determine when rendering needs to occur. * This component does not render any visible content itself; it defers to the specified :cellLayoutManager. */ -export default class CollectionView extends Component { +export default class CollectionView extends PureComponent { static propTypes = { 'aria-label': PropTypes.string, @@ -416,10 +415,6 @@ export default class CollectionView extends Component { ) } - shouldComponentUpdate (nextProps, nextState) { - return shallowCompare(this, nextProps, nextState) - } - /* ---------------------------- Helper methods ---------------------------- */ /** diff --git a/source/ColumnSizer/ColumnSizer.example.js b/source/ColumnSizer/ColumnSizer.example.js index 7b2df742b..e2363428d 100644 --- a/source/ColumnSizer/ColumnSizer.example.js +++ b/source/ColumnSizer/ColumnSizer.example.js @@ -1,16 +1,15 @@ /** * @flow */ -import React, { Component } from 'react' +import React, { PureComponent } from 'react' import styles from './ColumnSizer.example.css' import AutoSizer from '../AutoSizer' import ColumnSizer from './ColumnSizer' import Grid from '../Grid' import { ContentBox, ContentBoxHeader, ContentBoxParagraph } from '../demo/ContentBox' import { LabeledInput, InputRow } from '../demo/LabeledInput' -import shallowCompare from 'react-addons-shallow-compare' -export default class ColumnSizerExample extends Component { +export default class ColumnSizerExample extends PureComponent { constructor (props) { super(props) @@ -106,10 +105,6 @@ export default class ColumnSizerExample extends Component { ) } - shouldComponentUpdate (nextProps, nextState) { - return shallowCompare(this, nextProps, nextState) - } - _noColumnMaxWidthChange (event) { let columnMaxWidth = parseInt(event.target.value, 10) diff --git a/source/ColumnSizer/ColumnSizer.jest.js b/source/ColumnSizer/ColumnSizer.jest.js index d958e1449..c9364e170 100644 --- a/source/ColumnSizer/ColumnSizer.jest.js +++ b/source/ColumnSizer/ColumnSizer.jest.js @@ -111,6 +111,8 @@ describe('ColumnSizer', () => { }) it('should error if the registered child is not a Grid or a MultiGrid', () => { + spyOn(console, 'error') + expect(() => { render( { describe('pure', () => { it('should not re-render unless props have changed', () => { let cellRendererCalled = false - function cellRenderer () { + function cellRenderer ({ key, style }) { cellRendererCalled = true - return 'foo' + return
} const markup = getMarkup({ cellRenderer }) render(markup) @@ -1394,14 +1394,10 @@ describe('Grid', () => { expect(cellRendererCalled).toEqual(false) }) - it('should not re-render grid components if they shallowCompare style', () => { + it('should not re-render grid components if they extend PureComponent', () => { let componentUpdates = 0 - class GridComponent extends React.Component { - shouldComponentUpdate (nextProps, nextState) { - return shallowCompare(this, nextProps, nextState) - } - + class GridComponent extends React.PureComponent { componentDidUpdate () { componentUpdates++ } @@ -1497,8 +1493,9 @@ describe('Grid', () => { it('should clear style cache if cell sizes change', () => { const cellRendererCalls = [] - function cellRenderer (props) { - cellRendererCalls.push(props) + function cellRenderer (params) { + cellRendererCalls.push(params) + return
} const props = { @@ -1529,8 +1526,9 @@ describe('Grid', () => { it('should not pull from the style cache while scrolling if there is an offset adjustment', () => { let cellRendererCalls = [] - function cellRenderer (props) { - cellRendererCalls.push(props) + function cellRenderer (params) { + cellRendererCalls.push(params) + return
} const grid = render(getMarkup({ @@ -1555,4 +1553,146 @@ describe('Grid', () => { expect(cellRendererCalls.length).toEqual(3) expect(firstProps.style).not.toBe(secondProps.style) }) + + it('should only cache styles when a :deferredMeasurementCache is provided if the cell has already been measured', () => { + const cache = new CellMeasurerCache() + cache.set(0, 0, 100, 100) + cache.set(1, 1, 100, 100) + + const grid = render(getMarkup({ + columnCount: 2, + deferredMeasurementCache: cache, + rowCount: 2 + })) + + const keys = Object.keys(grid._styleCache) + + expect(keys).toEqual(['0-0', '1-1']) + }) + + it('should warn about cells that forget to include the :style property', () => { + spyOn(console, 'warn') + + function cellRenderer (params) { + return
+ } + + render(getMarkup({ + cellRenderer + })) + + expect(console.warn).toHaveBeenCalledWith('Rendered cell should include style property for positioning.') + expect(console.warn).toHaveBeenCalledTimes(1) + }) + + describe('deferredMeasurementCache', () => { + it('invalidateCellSizeAfterRender should invalidate cache and refresh displayed cells after mount', () => { + const cache = new CellMeasurerCache() + + let invalidateCellSizeAfterRender = true + + const cellRenderer = jest.fn() + cellRenderer.mockImplementation( + (params) => { + // Don't get stuck in a loop + if (invalidateCellSizeAfterRender) { + invalidateCellSizeAfterRender = false + + params.parent.invalidateCellSizeAfterRender({ + columnIndex: 1, + rowIndex: 0 + }) + } + return
+ }) + + const props = { + cellRenderer, + columnCount: 2, + deferredMeasurementCache: cache, + rowCount: 2 + } + + render(getMarkup(props)) + + // 4 times for initial render + 4 once cellCache was cleared + expect(cellRenderer).toHaveBeenCalledTimes(8) + }) + + it('should invalidate cache and refresh displayed cells after update', () => { + const cache = new CellMeasurerCache() + + const cellRenderer = jest.fn() + cellRenderer.mockImplementation( + (params) =>
+ ) + + const props = { + cellRenderer, + columnCount: 2, + deferredMeasurementCache: cache, + rowCount: 2 + } + + const grid = render(getMarkup(props)) + + expect(cellRenderer).toHaveBeenCalledTimes(4) + + let invalidateCellSizeAfterRender = false + + cellRenderer.mockReset() + cellRenderer.mockImplementation( + (params) => { + // Don't get stuck in a loop + if (invalidateCellSizeAfterRender) { + invalidateCellSizeAfterRender = false + params.parent.invalidateCellSizeAfterRender({ + columnIndex: 1, + rowIndex: 0 + }) + } + return
+ }) + + invalidateCellSizeAfterRender = true + grid.recomputeGridSize() + + // 4 times for initial render + 4 once cellCache was cleared + expect(cellRenderer).toHaveBeenCalledTimes(8) + }) + + it('should not cache cells until they have been measured by CellMeasurer', () => { + const cache = new CellMeasurerCache() + + // Fake measure cell 0,0 but not cell 0,1 + cache.set(0, 0, 100, 30) + + const cellRenderer = jest.fn() + cellRenderer.mockImplementation( + (params) =>
+ ) + + const props = { + cellRenderer, + columnCount: 2, + deferredMeasurementCache: cache, + rowCount: 1 + } + + // Trigger 2 renders + // The second render should re-use the style for cell 0,0 + // But should not re-use the style for cell 0,1 since it was not measured + const grid = render(getMarkup(props)) + grid.forceUpdate() + + // 0,0 - 0,1 - 0,0 - 0,1 + expect(cellRenderer).toHaveBeenCalledTimes(4) + const style00A = cellRenderer.mock.calls[0][0].style + const style01A = cellRenderer.mock.calls[1][0].style + const style00B = cellRenderer.mock.calls[2][0].style + const style01B = cellRenderer.mock.calls[3][0].style + expect(style00A).toBe(style00B) + expect(style01A).not.toBe(style01B) + }) + }) }) diff --git a/source/Grid/Grid.js b/source/Grid/Grid.js index e85b9f4af..04c285845 100644 --- a/source/Grid/Grid.js +++ b/source/Grid/Grid.js @@ -1,12 +1,11 @@ /** @flow */ -import React, { Component, PropTypes } from 'react' +import React, { PropTypes, PureComponent } from 'react' import cn from 'classnames' import calculateSizeAndPositionDataAndUpdateScrollOffset from './utils/calculateSizeAndPositionDataAndUpdateScrollOffset' import ScalingCellSizeAndPositionManager from './utils/ScalingCellSizeAndPositionManager' import createCallbackMemoizer from '../utils/createCallbackMemoizer' import defaultOverscanIndicesGetter, { SCROLL_DIRECTION_BACKWARD, SCROLL_DIRECTION_FORWARD } from './utils/defaultOverscanIndicesGetter' import getScrollbarSize from 'dom-helpers/util/scrollbarSize' -import shallowCompare from 'react-addons-shallow-compare' import updateScrollIndexHelper from './utils/updateScrollIndexHelper' import defaultCellRangeRenderer from './defaultCellRangeRenderer' @@ -29,7 +28,7 @@ const SCROLL_POSITION_CHANGE_REASONS = { * Renders tabular data with virtualization along the vertical and horizontal axes. * Row heights and column widths must be known ahead of time and specified as properties. */ -export default class Grid extends Component { +export default class Grid extends PureComponent { static propTypes = { 'aria-label': PropTypes.string, @@ -88,6 +87,12 @@ export default class Grid extends Component { /** Optional inline style applied to inner cell-container */ containerStyle: PropTypes.object, + /** + * If CellMeasurer is used to measure this Grid's children, this should be a pointer to its CellMeasurerCache. + * A shared CellMeasurerCache reference enables Grid and CellMeasurer to share measurement data. + */ + deferredMeasurementCache: PropTypes.object, + /** * Used to estimate the total width of a Grid before all of its columns have actually been measured. * The estimated total width is adjusted as columns are rendered. @@ -245,12 +250,20 @@ export default class Grid extends Component { this._columnWidthGetter = this._wrapSizeGetter(props.columnWidth) this._rowHeightGetter = this._wrapSizeGetter(props.rowHeight) + this._deferredInvalidateColumnIndex = null + this._deferredInvalidateRowIndex = null + + const deferredMeasurementCache = props.deferredMeasurementCache + const deferredMode = typeof deferredMeasurementCache !== 'undefined' + this._columnSizeAndPositionManager = new ScalingCellSizeAndPositionManager({ + batchAllCells: deferredMode && !deferredMeasurementCache.hasFixedHeight(), cellCount: props.columnCount, cellSizeGetter: (params) => this._columnWidthGetter(params), estimatedCellSize: this._getEstimatedColumnSize(props) }) this._rowSizeAndPositionManager = new ScalingCellSizeAndPositionManager({ + batchAllCells: deferredMode && !deferredMeasurementCache.hasFixedWidth(), cellCount: props.rowCount, cellSizeGetter: (params) => this._rowHeightGetter(params), estimatedCellSize: this._getEstimatedRowSize(props) @@ -261,6 +274,25 @@ export default class Grid extends Component { this._styleCache = {} } + /** + * Invalidate Grid size and recompute visible cells. + * This is a deferred wrapper for recomputeGridSize(). + * It sets a flag to be evaluated on cDM/cDU to avoid unnecessary renders. + * This method is intended for advanced use-cases like CellMeasurer. + */ + // @TODO (bvaughn) Add automated test coverage for this. + invalidateCellSizeAfterRender ({ + columnIndex, + rowIndex + }) { + this._deferredInvalidateColumnIndex = typeof this._deferredInvalidateColumnIndex === 'number' + ? Math.min(this._deferredInvalidateColumnIndex, columnIndex) + : columnIndex + this._deferredInvalidateRowIndex = typeof this._deferredInvalidateRowIndex === 'number' + ? Math.min(this._deferredInvalidateRowIndex, rowIndex) + : rowIndex + } + /** * Pre-measure all columns and rows in a Grid. * Typically cells are only measured as needed and estimated sizes are used for cells that have not yet been measured. @@ -314,6 +346,10 @@ export default class Grid extends Component { componentDidMount () { const { scrollLeft, scrollToColumn, scrollTop, scrollToRow } = this.props + // If cell sizes have been invalidated (eg we are using CellMeasurer) then reset cached positions. + // We must do this at the start of the method as we may calculate and update scroll position below. + this._handleInvalidatedGridSize() + // If this component was first rendered server-side, scrollbar size will be undefined. // In that event we need to remeasure. if (!this._scrollbarSizeMeasured) { @@ -352,6 +388,10 @@ export default class Grid extends Component { const { autoHeight, columnCount, height, rowCount, scrollToAlignment, scrollToColumn, scrollToRow, width } = this.props const { scrollLeft, scrollPositionChangeReason, scrollTop } = this.state + // If cell sizes have been invalidated (eg we are using CellMeasurer) then reset cached positions. + // We must do this at the start of the method as we may calculate and update scroll position below. + this._handleInvalidatedGridSize() + // Handle edge case where column or row count has only just increased over 0. // In this case we may have to restore a previously-specified scroll offset. // For more info see bvaughn/react-virtualized/issues/218 @@ -630,10 +670,6 @@ export default class Grid extends Component { ) } - shouldComponentUpdate (nextProps, nextState) { - return shallowCompare(this, nextProps, nextState) - } - /* ---------------------------- Helper methods ---------------------------- */ _calculateChildrenToRender (props = this.props, state = this.state) { @@ -641,6 +677,7 @@ export default class Grid extends Component { cellRenderer, cellRangeRenderer, columnCount, + deferredMeasurementCache, height, overscanColumnCount, overscanIndicesGetter, @@ -713,8 +750,10 @@ export default class Grid extends Component { columnSizeAndPositionManager: this._columnSizeAndPositionManager, columnStartIndex: this._columnStartIndex, columnStopIndex: this._columnStopIndex, + deferredMeasurementCache, horizontalOffsetAdjustment, isScrolling, + parent: this, rowSizeAndPositionManager: this._rowSizeAndPositionManager, rowStartIndex: this._rowStartIndex, rowStopIndex: this._rowStopIndex, @@ -783,6 +822,22 @@ export default class Grid extends Component { : props.estimatedRowSize } + /** + * Check for batched CellMeasurer size invalidations. + * This will occur the first time one or more previously unmeasured cells are rendered. + */ + _handleInvalidatedGridSize () { + if (typeof this._deferredInvalidateColumnIndex === 'number') { + const columnIndex = this._deferredInvalidateColumnIndex + const rowIndex = this._deferredInvalidateRowIndex + + delete this._deferredInvalidateColumnIndex + delete this._deferredInvalidateRowIndex + + this.recomputeGridSize({ columnIndex, rowIndex }) + } + } + _invokeOnGridRenderedHelper () { const { onSectionRendered } = this.props diff --git a/source/Grid/defaultCellRangeRenderer.js b/source/Grid/defaultCellRangeRenderer.js index a8364d2df..ab18263f5 100644 --- a/source/Grid/defaultCellRangeRenderer.js +++ b/source/Grid/defaultCellRangeRenderer.js @@ -9,8 +9,10 @@ export default function defaultCellRangeRenderer ({ columnSizeAndPositionManager, columnStartIndex, columnStopIndex, + deferredMeasurementCache, horizontalOffsetAdjustment, isScrolling, + parent, // Grid (or List or Table) rowSizeAndPositionManager, rowStartIndex, rowStopIndex, @@ -21,9 +23,21 @@ export default function defaultCellRangeRenderer ({ visibleColumnIndices, visibleRowIndices }: DefaultCellRangeRendererParams) { + const deferredMode = typeof deferredMeasurementCache !== 'undefined' + const renderedCells = [] - const offsetAdjusted = verticalOffsetAdjustment || horizontalOffsetAdjustment - const canCacheStyle = !isScrolling || !offsetAdjusted + + // Browsers have native size limits for elements (eg Chrome 33M pixels, IE 1.5M pixes). + // User cannot scroll beyond these size limitations. + // In order to work around this, ScalingCellSizeAndPositionManager compresses offsets. + // We should never cache styles for compressed offsets though as this can lead to bugs. + // See issue #576 for more. + const areOffsetsAdjusted = ( + columnSizeAndPositionManager.areOffsetsAdjusted() || + rowSizeAndPositionManager.areOffsetsAdjusted() + ) + + const canCacheStyle = !isScrolling || !areOffsetsAdjusted for (let rowIndex = rowStartIndex; rowIndex <= rowStopIndex; rowIndex++) { let rowDatum = rowSizeAndPositionManager.getSizeAndPositionOfCell(rowIndex) @@ -43,15 +57,33 @@ export default function defaultCellRangeRenderer ({ if (canCacheStyle && styleCache[key]) { style = styleCache[key] } else { - style = { - height: rowDatum.size, - left: columnDatum.offset + horizontalOffsetAdjustment, - position: 'absolute', - top: rowDatum.offset + verticalOffsetAdjustment, - width: columnDatum.size - } + // In deferred mode, cells will be initially rendered before we know their size. + // Don't interfere with CellMeasurer's measurements by setting an invalid size. + if ( + deferredMode && + !deferredMeasurementCache.has(rowIndex, columnIndex) + ) { + // Position not-yet-measured cells at top/left 0,0, + // And give them width/height of 'auto' so they can grow larger than the parent Grid if necessary. + // Positioning them further to the right/bottom influences their measured size. + style = { + height: 'auto', + left: 0, + position: 'absolute', + top: 0, + width: 'auto' + } + } else { + style = { + height: rowDatum.size, + left: columnDatum.offset + horizontalOffsetAdjustment, + position: 'absolute', + top: rowDatum.offset + verticalOffsetAdjustment, + width: columnDatum.size + } - styleCache[key] = style + styleCache[key] = style + } } let cellRendererParams = { @@ -59,6 +91,7 @@ export default function defaultCellRangeRenderer ({ isScrolling, isVisible, key, + parent, rowIndex, style } @@ -93,6 +126,10 @@ export default function defaultCellRangeRenderer ({ continue } + if (process.env.NODE_ENV !== 'production') { + warnAboutMissingStyle(parent, renderedCell) + } + renderedCells.push(renderedCell) } } @@ -100,6 +137,20 @@ export default function defaultCellRangeRenderer ({ return renderedCells } +function warnAboutMissingStyle (parent, renderedCell) { + if (process.env.NODE_ENV !== 'production') { + if ( + renderedCell && + renderedCell.props.style === undefined && + parent.__warnedAboutMissingStyle !== true + ) { + parent.__warnedAboutMissingStyle = true + + console.warn('Rendered cell should include style property for positioning.') + } + } +} + type DefaultCellRangeRendererParams = { cellCache: Object, cellRenderer: Function, diff --git a/source/Grid/utils/CellSizeAndPositionManager.jest.js b/source/Grid/utils/CellSizeAndPositionManager.jest.js index 3aa7d281a..8a3f82ee9 100644 --- a/source/Grid/utils/CellSizeAndPositionManager.jest.js +++ b/source/Grid/utils/CellSizeAndPositionManager.jest.js @@ -3,11 +3,13 @@ import CellSizeAndPositionManager from './CellSizeAndPositionManager' describe('CellSizeAndPositionManager', () => { function getCellSizeAndPositionManager ({ + batchAllCells, cellCount = 100, estimatedCellSize = 15 } = {}) { const cellSizeGetterCalls = [] const cellSizeAndPositionManager = new CellSizeAndPositionManager({ + batchAllCells, cellCount, cellSizeGetter: ({ index }) => { cellSizeGetterCalls.push(index) @@ -333,6 +335,22 @@ describe('CellSizeAndPositionManager', () => { expect(start).toEqual(95) expect(stop).toEqual(99) }) + + it('should return all cells if :batchAllCells param was used (for CellMeasurer support)', () => { + const { cellSizeAndPositionManager } = getCellSizeAndPositionManager({ + batchAllCells: true, + cellCount: 100 + }) + const { + start, + stop + } = cellSizeAndPositionManager.getVisibleCellRange({ + containerSize: 50, + offset: 950 + }) + expect(start).toEqual(0) + expect(stop).toEqual(99) + }) }) describe('resetCell', () => { diff --git a/source/Grid/utils/CellSizeAndPositionManager.js b/source/Grid/utils/CellSizeAndPositionManager.js index 9e869c2cf..648b22d33 100644 --- a/source/Grid/utils/CellSizeAndPositionManager.js +++ b/source/Grid/utils/CellSizeAndPositionManager.js @@ -5,10 +5,12 @@ */ export default class CellSizeAndPositionManager { constructor ({ + batchAllCells = false, cellCount, cellSizeGetter, estimatedCellSize }: CellSizeAndPositionManagerConstructorParams) { + this._batchAllCells = batchAllCells this._cellSizeGetter = cellSizeGetter this._cellCount = cellCount this._estimatedCellSize = estimatedCellSize @@ -19,6 +21,13 @@ export default class CellSizeAndPositionManager { // Measurements for cells up to this index can be trusted; cells afterward should be estimated. this._lastMeasuredIndex = -1 + + // Used in deferred mode to track which cells have been queued for measurement. + this._lastBatchedIndex = -1 + } + + areOffsetsAdjusted (): bool { + return false } configure ({ @@ -41,6 +50,13 @@ export default class CellSizeAndPositionManager { return this._lastMeasuredIndex } + getOffsetAdjustment ({ + containerSize, + offset // safe + }: ContainerSizeAndOffset): number { + return 0 + } + /** * This method returns the size and position for the cell at the specified index. * It just-in-time calculates (or used cached values) for cells leading up to the index. @@ -57,19 +73,28 @@ export default class CellSizeAndPositionManager { for (var i = this._lastMeasuredIndex + 1; i <= index; i++) { let size = this._cellSizeGetter({ index: i }) - if (size == null || isNaN(size)) { + // undefined or NaN probably means a logic error in the size getter. + // null means we're using CellMeasurer and haven't yet measured a given index. + if (size === undefined || isNaN(size)) { throw Error(`Invalid size returned for cell ${i} of value ${size}`) + } else if (size === null) { + this._cellSizeAndPositionData[i] = { + offset, + size: 0 + } + + this._lastBatchedIndex = index + } else { + this._cellSizeAndPositionData[i] = { + offset, + size + } + + offset += size + + this._lastMeasuredIndex = index } - - this._cellSizeAndPositionData[i] = { - offset, - size - } - - offset += size } - - this._lastMeasuredIndex = index } return this._cellSizeAndPositionData[index] @@ -142,10 +167,21 @@ export default class CellSizeAndPositionManager { return Math.max(0, Math.min(totalSize - containerSize, idealOffset)) } - getVisibleCellRange ({ - containerSize, - offset - }: GetVisibleCellRangeParams): VisibleCellRange { + getVisibleCellRange (params: GetVisibleCellRangeParams): VisibleCellRange { + // Advanced use-cases (eg CellMeasurer) require batched measurements to determine accurate sizes. + // eg we can't know a row's height without measuring the height of all columns within that row. + if (this._batchAllCells) { + return { + start: 0, + stop: this._cellCount - 1 + } + } + + let { + containerSize, + offset + } = params + const totalSize = this.getTotalSize() if (totalSize === 0) { @@ -160,7 +196,10 @@ export default class CellSizeAndPositionManager { let stop = start - while (offset < maxOffset && stop < this._cellCount - 1) { + while ( + offset < maxOffset && + stop < this._cellCount - 1 + ) { stop++ offset += this.getSizeAndPositionOfCell(stop).size @@ -266,6 +305,7 @@ export default class CellSizeAndPositionManager { } type CellSizeAndPositionManagerConstructorParams = { + batchAllCells ?: boolean, cellCount: number, cellSizeGetter: Function, estimatedCellSize: number @@ -276,6 +316,11 @@ type ConfigureParams = { estimatedCellSize: number }; +type ContainerSizeAndOffset = { + containerSize: number, + offset: number +}; + type GetVisibleCellRangeParams = { containerSize: number, offset: number diff --git a/source/Grid/utils/ScalingCellSizeAndPositionManager.js b/source/Grid/utils/ScalingCellSizeAndPositionManager.js index b5dc4ee87..769f60353 100644 --- a/source/Grid/utils/ScalingCellSizeAndPositionManager.js +++ b/source/Grid/utils/ScalingCellSizeAndPositionManager.js @@ -21,6 +21,10 @@ export default class ScalingCellSizeAndPositionManager { this._maxScrollSize = maxScrollSize } + areOffsetsAdjusted (): bool { + return this._cellSizeAndPositionManager.getTotalSize() > this._maxScrollSize + } + configure (params): void { this._cellSizeAndPositionManager.configure(params) } diff --git a/source/InfiniteLoader/InfiniteLoader.example.js b/source/InfiniteLoader/InfiniteLoader.example.js index 1f96246f5..ef4c7e1ea 100644 --- a/source/InfiniteLoader/InfiniteLoader.example.js +++ b/source/InfiniteLoader/InfiniteLoader.example.js @@ -1,17 +1,16 @@ /** @flow */ -import React, { Component, PropTypes } from 'react' +import React, { PropTypes, PureComponent } from 'react' import { ContentBox, ContentBoxHeader, ContentBoxParagraph } from '../demo/ContentBox' import Immutable from 'immutable' import AutoSizer from '../AutoSizer' import InfiniteLoader from './InfiniteLoader' import List from '../List' -import shallowCompare from 'react-addons-shallow-compare' import styles from './InfiniteLoader.example.css' const STATUS_LOADING = 1 const STATUS_LOADED = 2 -export default class InfiniteLoaderExample extends Component { +export default class InfiniteLoaderExample extends PureComponent { static contextTypes = { list: PropTypes.instanceOf(Immutable.List).isRequired } @@ -99,10 +98,6 @@ export default class InfiniteLoaderExample extends Component { ) } - shouldComponentUpdate (nextProps, nextState) { - return shallowCompare(this, nextProps, nextState) - } - _clearData () { this.setState({ loadedRowCount: 0, diff --git a/source/InfiniteLoader/InfiniteLoader.jest.js b/source/InfiniteLoader/InfiniteLoader.jest.js index 939adf044..c5ea4c2b5 100644 --- a/source/InfiniteLoader/InfiniteLoader.jest.js +++ b/source/InfiniteLoader/InfiniteLoader.jest.js @@ -29,7 +29,7 @@ describe('InfiniteLoader', () => { function rowRenderer ({ index, key, style }) { rowRendererCalls.push(index) return ( -
+
) } diff --git a/source/InfiniteLoader/InfiniteLoader.js b/source/InfiniteLoader/InfiniteLoader.js index 69abc61fe..acb14481d 100644 --- a/source/InfiniteLoader/InfiniteLoader.js +++ b/source/InfiniteLoader/InfiniteLoader.js @@ -1,6 +1,5 @@ /** @flow */ -import { Component, PropTypes } from 'react' -import shallowCompare from 'react-addons-shallow-compare' +import { PropTypes, PureComponent } from 'react' import createCallbackMemoizer from '../utils/createCallbackMemoizer' /** @@ -8,7 +7,7 @@ import createCallbackMemoizer from '../utils/createCallbackMemoizer' * This component decorates a virtual component and just-in-time prefetches rows as a user scrolls. * It is intended as a convenience component; fork it if you'd like finer-grained control over data-loading. */ -export default class InfiniteLoader extends Component { +export default class InfiniteLoader extends PureComponent { static propTypes = { /** * Function responsible for rendering a virtualized component. @@ -78,10 +77,6 @@ export default class InfiniteLoader extends Component { }) } - shouldComponentUpdate (nextProps, nextState) { - return shallowCompare(this, nextProps, nextState) - } - _loadUnloadedRanges (unloadedRanges) { const { loadMoreRows } = this.props diff --git a/source/List/List.example.js b/source/List/List.example.js index 81dd9cd6a..3e22ea559 100644 --- a/source/List/List.example.js +++ b/source/List/List.example.js @@ -3,15 +3,14 @@ */ import cn from 'classnames' import Immutable from 'immutable' -import React, { Component, PropTypes } from 'react' +import React, { PropTypes, PureComponent } from 'react' import styles from './List.example.css' import AutoSizer from '../AutoSizer' import List from './List' import { ContentBox, ContentBoxHeader, ContentBoxParagraph } from '../demo/ContentBox' import { LabeledInput, InputRow } from '../demo/LabeledInput' -import shallowCompare from 'react-addons-shallow-compare' -export default class ListExample extends Component { +export default class ListExample extends PureComponent { static contextTypes = { list: PropTypes.instanceOf(Immutable.List).isRequired }; @@ -141,10 +140,6 @@ export default class ListExample extends Component { ) } - shouldComponentUpdate (nextProps, nextState) { - return shallowCompare(this, nextProps, nextState) - } - _getDatum (index) { const { list } = this.context diff --git a/source/List/List.jest.js b/source/List/List.jest.js index a89ef5545..efb20ae98 100644 --- a/source/List/List.jest.js +++ b/source/List/List.jest.js @@ -454,6 +454,12 @@ describe('List', () => { expect(rowRendererCalls[1].isVisible).toEqual(false) }) + it('should relay the Grid :parent param to the :rowRenderer', () => { + const rowRenderer = jest.fn().mockReturnValue(null) + findDOMNode(render(getMarkup({ rowRenderer }))) + expect(rowRenderer.mock.calls[0][0].parent).not.toBeUndefined() + }) + describe('pure', () => { it('should not re-render unless props have changed', () => { let rowRendererCalled = false diff --git a/source/List/List.js b/source/List/List.js index 945c2b4fd..743b2ca3f 100644 --- a/source/List/List.js +++ b/source/List/List.js @@ -1,8 +1,7 @@ /** @flow */ import Grid from '../Grid' -import React, { Component, PropTypes } from 'react' +import React, { PropTypes, PureComponent } from 'react' import cn from 'classnames' -import shallowCompare from 'react-addons-shallow-compare' /** * It is inefficient to create and manage a large list of DOM elements within a scrolling container @@ -12,7 +11,7 @@ import shallowCompare from 'react-addons-shallow-compare' * * This component renders a virtualized list of elements with either fixed or dynamic heights. */ -export default class List extends Component { +export default class List extends PureComponent { static propTypes = { 'aria-label': PropTypes.string, @@ -157,10 +156,6 @@ export default class List extends Component { ) } - shouldComponentUpdate (nextProps, nextState) { - return shallowCompare(this, nextProps, nextState) - } - _cellRenderer ({ rowIndex, style, ...rest }) { const { rowRenderer } = this.props diff --git a/source/MultiGrid/MultiGrid.example.js b/source/MultiGrid/MultiGrid.example.js index b815a8792..c796a4c12 100644 --- a/source/MultiGrid/MultiGrid.example.js +++ b/source/MultiGrid/MultiGrid.example.js @@ -1,11 +1,10 @@ /** @flow */ import Immutable from 'immutable' -import React, { Component, PropTypes } from 'react' +import React, { PropTypes, PureComponent } from 'react' import { ContentBox, ContentBoxHeader, ContentBoxParagraph } from '../demo/ContentBox' import { LabeledInput, InputRow } from '../demo/LabeledInput' import AutoSizer from '../AutoSizer' import MultiGrid from './MultiGrid' -import shallowCompare from 'react-addons-shallow-compare' import styles from './MultiGrid.example.css' const STYLE = { @@ -26,7 +25,7 @@ const STYLE_TOP_RIGHT_GRID = { fontWeight: 'bold' } -export default class MultiGridExample extends Component { +export default class MultiGridExample extends PureComponent { static contextTypes = { list: PropTypes.instanceOf(Immutable.List).isRequired }; @@ -90,10 +89,6 @@ export default class MultiGridExample extends Component { ) } - shouldComponentUpdate (nextProps, nextState) { - return shallowCompare(this, nextProps, nextState) - } - _cellRenderer ({ columnIndex, key, rowIndex, style }) { return (
{ }) }) + describe('#forceUpdateGrids', () => { + it('should call forceUpdate() on inner Grids', () => { + const cellRenderer = jest.fn() + cellRenderer.mockImplementation(({ key }) =>
) + + const rendered = render(getMarkup({ + cellRenderer, + columnCount: 2, + fixedColumnCount: 1, + fixedRowCount: 1, + rowCount: 2 + })) + + expect(cellRenderer.mock.calls).toHaveLength(4) + + cellRenderer.mockReset() + rendered.forceUpdateGrids() + + expect(cellRenderer.mock.calls).toHaveLength(4) + }) + }) + describe('styles', () => { it('should support custom style for the outer MultiGrid wrapper element', () => { const rendered = findDOMNode(render(getMarkup({ diff --git a/source/MultiGrid/MultiGrid.js b/source/MultiGrid/MultiGrid.js index 98ca192a8..0ea241d98 100644 --- a/source/MultiGrid/MultiGrid.js +++ b/source/MultiGrid/MultiGrid.js @@ -1,6 +1,5 @@ /** @flow */ -import React, { Component, PropTypes } from 'react' -import shallowCompare from 'react-addons-shallow-compare' +import React, { PropTypes, PureComponent } from 'react' import Grid from '../Grid' /** @@ -10,7 +9,7 @@ import Grid from '../Grid' * If no sticky columns, only 1 sticky header Grid will be rendered. * If sticky columns, 2 sticky header Grids will be rendered. */ -export default class MultiGrid extends Component { +export default class MultiGrid extends PureComponent { static propTypes = { fixedColumnCount: PropTypes.number.isRequired, fixedRowCount: PropTypes.number.isRequired, @@ -51,6 +50,13 @@ export default class MultiGrid extends Component { this._topRightGridRef = this._topRightGridRef.bind(this) } + forceUpdateGrids () { + this._bottomLeftGrid && this._bottomLeftGrid.forceUpdate() + this._bottomRightGrid && this._bottomRightGrid.forceUpdate() + this._topLeftGrid && this._topLeftGrid.forceUpdate() + this._topRightGrid && this._topRightGrid.forceUpdate() + } + /** See Grid#measureAllCells */ measureAllCells () { this._bottomLeftGrid && this._bottomLeftGrid.measureAllCells() @@ -59,13 +65,6 @@ export default class MultiGrid extends Component { this._topRightGrid && this._topRightGrid.measureAllCells() } - /** See issue #546 */ - measureAllRows () { - console.warn('MultiGrid measureAllRows() is deprecated; use measureAllCells() instead.') - - this.measureAllCells() - } - /** See Grid#recomputeGridSize */ recomputeGridSize ({ columnIndex = 0, @@ -168,10 +167,6 @@ export default class MultiGrid extends Component { ) } - shouldComponentUpdate (nextProps, nextState) { - return shallowCompare(this, nextProps, nextState) - } - _bottomLeftGridRef (ref) { this._bottomLeftGrid = ref } diff --git a/source/ScrollSync/ScrollSync.example.js b/source/ScrollSync/ScrollSync.example.js index 600818b81..b6f857a50 100644 --- a/source/ScrollSync/ScrollSync.example.js +++ b/source/ScrollSync/ScrollSync.example.js @@ -1,10 +1,9 @@ /** @flow */ -import React, { Component } from 'react' +import React, { PureComponent } from 'react' import { ContentBox, ContentBoxHeader, ContentBoxParagraph } from '../demo/ContentBox' import AutoSizer from '../AutoSizer' import Grid from '../Grid' import ScrollSync from './ScrollSync' -import shallowCompare from 'react-addons-shallow-compare' import cn from 'classnames' import styles from './ScrollSync.example.css' import scrollbarSize from 'dom-helpers/util/scrollbarSize' @@ -14,7 +13,7 @@ const LEFT_COLOR_TO = hexToRgb('#BC3959') const TOP_COLOR_FROM = hexToRgb('#000000') const TOP_COLOR_TO = hexToRgb('#333333') -export default class GridExample extends Component { +export default class GridExample extends PureComponent { constructor (props, context) { super(props, context) @@ -177,10 +176,6 @@ export default class GridExample extends Component { ) } - shouldComponentUpdate (nextProps, nextState) { - return shallowCompare(this, nextProps, nextState) - } - _renderBodyCell ({ columnIndex, key, rowIndex, style }) { if (columnIndex < 1) { return diff --git a/source/ScrollSync/ScrollSync.js b/source/ScrollSync/ScrollSync.js index 62b073801..9495fa1a3 100644 --- a/source/ScrollSync/ScrollSync.js +++ b/source/ScrollSync/ScrollSync.js @@ -1,10 +1,9 @@ -import { Component, PropTypes } from 'react' -import shallowCompare from 'react-addons-shallow-compare' +import { PropTypes, PureComponent } from 'react' /** * HOC that simplifies the process of synchronizing scrolling between two or more virtualized components. */ -export default class ScrollSync extends Component { +export default class ScrollSync extends PureComponent { static propTypes = { /** * Function responsible for rendering 2 or more virtualized components. @@ -44,10 +43,6 @@ export default class ScrollSync extends Component { }) } - shouldComponentUpdate (nextProps, nextState) { - return shallowCompare(this, nextProps, nextState) - } - _onScroll ({ clientHeight, clientWidth, scrollHeight, scrollLeft, scrollTop, scrollWidth }) { this.setState({ clientHeight, clientWidth, scrollHeight, scrollLeft, scrollTop, scrollWidth }) } diff --git a/source/Table/Table.example.js b/source/Table/Table.example.js index 9cf6f3ea1..1eddad914 100644 --- a/source/Table/Table.example.js +++ b/source/Table/Table.example.js @@ -1,6 +1,6 @@ /** @flow */ import Immutable from 'immutable' -import React, { Component, PropTypes } from 'react' +import React, { PropTypes, PureComponent } from 'react' import { ContentBox, ContentBoxHeader, ContentBoxParagraph } from '../demo/ContentBox' import { LabeledInput, InputRow } from '../demo/LabeledInput' import AutoSizer from '../AutoSizer' @@ -8,10 +8,9 @@ import Column from './Column' import Table from './Table' import SortDirection from './SortDirection' import SortIndicator from './SortIndicator' -import shallowCompare from 'react-addons-shallow-compare' import styles from './Table.example.css' -export default class TableExample extends Component { +export default class TableExample extends PureComponent { static contextTypes = { list: PropTypes.instanceOf(Immutable.List).isRequired }; @@ -218,10 +217,6 @@ export default class TableExample extends Component { ) } - shouldComponentUpdate (nextProps, nextState) { - return shallowCompare(this, nextProps, nextState) - } - _getDatum (list, index) { return list.get(index % list.size) } diff --git a/source/Table/Table.jest.js b/source/Table/Table.jest.js index d9969a756..bdc35ceae 100644 --- a/source/Table/Table.jest.js +++ b/source/Table/Table.jest.js @@ -982,4 +982,10 @@ describe('Table', () => { const rendered = findDOMNode(render(getMarkup())) expect(rendered.querySelector('.ReactVirtualized__Grid__innerScrollContainer').style.width).toEqual('auto') }) + + it('should relay the Grid :parent param to the Column :cellRenderer', () => { + const cellRenderer = jest.fn().mockReturnValue(null) + findDOMNode(render(getMarkup({ cellRenderer }))) + expect(cellRenderer.mock.calls[0][0].parent).not.toBeUndefined() + }) }) diff --git a/source/Table/Table.js b/source/Table/Table.js index b2df13668..02329e217 100644 --- a/source/Table/Table.js +++ b/source/Table/Table.js @@ -1,9 +1,8 @@ /** @flow */ import cn from 'classnames' import Column from './Column' -import React, { Component, PropTypes } from 'react' +import React, { PropTypes, PureComponent } from 'react' import { findDOMNode } from 'react-dom' -import shallowCompare from 'react-addons-shallow-compare' import Grid from '../Grid' import defaultRowRenderer from './defaultRowRenderer' import SortDirection from './SortDirection' @@ -12,7 +11,7 @@ import SortDirection from './SortDirection' * Table component with fixed headers and virtualized rows for improved performance with large data sets. * This component expects explicit width, height, and padding parameters. */ -export default class Table extends Component { +export default class Table extends PureComponent { static propTypes = { 'aria-label': PropTypes.string, @@ -334,14 +333,11 @@ export default class Table extends Component { ) } - shouldComponentUpdate (nextProps, nextState) { - return shallowCompare(this, nextProps, nextState) - } - _createColumn ({ column, columnIndex, isScrolling, + parent, rowData, rowIndex }) { @@ -354,7 +350,7 @@ export default class Table extends Component { } = column.props const cellData = cellDataGetter({ columnData, dataKey, rowData }) - const renderedCell = cellRenderer({ cellData, columnData, dataKey, isScrolling, rowData, rowIndex }) + const renderedCell = cellRenderer({ cellData, columnData, dataKey, isScrolling, parent, rowData, rowIndex }) const style = this._cachedColumnStyles[columnIndex] @@ -443,6 +439,7 @@ export default class Table extends Component { rowIndex: index, isScrolling, key, + parent, style }) { const { @@ -468,6 +465,7 @@ export default class Table extends Component { column, columnIndex, isScrolling, + parent, rowData, rowIndex: index, scrollbarWidth diff --git a/source/WindowScroller/WindowScroller.example.js b/source/WindowScroller/WindowScroller.example.js index 7df394e2b..521847761 100644 --- a/source/WindowScroller/WindowScroller.example.js +++ b/source/WindowScroller/WindowScroller.example.js @@ -1,15 +1,14 @@ /** @flow */ import cn from 'classnames' import Immutable from 'immutable' -import React, { Component, PropTypes } from 'react' +import React, { PropTypes, PureComponent } from 'react' import { ContentBox, ContentBoxHeader, ContentBoxParagraph } from '../demo/ContentBox' import WindowScroller from './WindowScroller' import List from '../List' import AutoSizer from '../AutoSizer' -import shallowCompare from 'react-addons-shallow-compare' import styles from './WindowScroller.example.css' -export default class WindowScrollerExample extends Component { +export default class WindowScrollerExample extends PureComponent { static contextTypes = { list: PropTypes.instanceOf(Immutable.List).isRequired, customElement: PropTypes.any, @@ -98,10 +97,6 @@ export default class WindowScrollerExample extends Component { ) } - shouldComponentUpdate (nextProps, nextState) { - return shallowCompare(this, nextProps, nextState) - } - _hideHeader () { const { showHeaderText } = this.state diff --git a/source/WindowScroller/WindowScroller.js b/source/WindowScroller/WindowScroller.js index 6219b7b3d..b771363a7 100644 --- a/source/WindowScroller/WindowScroller.js +++ b/source/WindowScroller/WindowScroller.js @@ -1,11 +1,10 @@ /** @flow */ -import { Component, PropTypes } from 'react' +import { PropTypes, PureComponent } from 'react' import ReactDOM from 'react-dom' -import shallowCompare from 'react-addons-shallow-compare' import { registerScrollListener, unregisterScrollListener } from './utils/onScroll' import { getHeight, getPositionFromTop, getScrollTop } from './utils/dimensions' -export default class WindowScroller extends Component { +export default class WindowScroller extends PureComponent { static propTypes = { /** * Function responsible for rendering children. @@ -113,10 +112,6 @@ export default class WindowScroller extends Component { }) } - shouldComponentUpdate (nextProps, nextState) { - return shallowCompare(this, nextProps, nextState) - } - _onResize (event) { this.updatePosition() } diff --git a/source/demo/Application.js b/source/demo/Application.js index 329dbaa98..216c699a6 100644 --- a/source/demo/Application.js +++ b/source/demo/Application.js @@ -1,7 +1,6 @@ /** @flow */ import Immutable from 'immutable' -import React, { Component, PropTypes } from 'react' -import shallowCompare from 'react-addons-shallow-compare' +import React, { PropTypes, PureComponent } from 'react' import { HashRouter, Match, Redirect } from 'react-router' import ComponentLink from './ComponentLink' @@ -42,7 +41,7 @@ const COMPONENT_EXAMPLES_MAP = { // HACK Generate arbitrary data for use in example components :) const list = Immutable.List(generateRandomList()) -export default class Application extends Component { +export default class Application extends PureComponent { static childContextTypes = { list: PropTypes.instanceOf(Immutable.List).isRequired, customElement: PropTypes.any, @@ -146,8 +145,4 @@ export default class Application extends Component { ) } - - shouldComponentUpdate (nextProps, nextState) { - return shallowCompare(this, nextProps, nextState) - } } diff --git a/source/demo/utils.js b/source/demo/utils.js index 1cd5552b1..42e6f4d55 100644 --- a/source/demo/utils.js +++ b/source/demo/utils.js @@ -5,11 +5,21 @@ export function generateRandomList () { const list = [] for (var i = 0; i < 1000; i++) { + const random = loremIpsum[i % loremIpsum.length] + const randoms = [random] + + for (let j = Math.round(Math.random() * 10); j--;) { + randoms.push( + loremIpsum[i * j % loremIpsum.length] + ) + } + list.push({ color: BADGE_COLORS[i % BADGE_COLORS.length], index: i, name: NAMES[i % NAMES.length], - random: loremIpsum[i % loremIpsum.length], + random, + randomLong: randoms.join(' '), size: ROW_HEIGHTS[Math.floor(Math.random() * ROW_HEIGHTS.length)] }) } @@ -24,21 +34,78 @@ const ROW_HEIGHTS = [50, 75, 100] const loremIpsum = [ 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.', 'Phasellus vulputate odio commodo tortor sodales, et vehicula ipsum viverra.', + 'In et mollis velit, accumsan volutpat libero.', + 'Nulla rutrum tellus ipsum, eget fermentum sem dictum quis.', + 'Suspendisse eget vehicula elit.', + 'Proin ut lacus lacus.', + 'Aliquam erat volutpat.', + 'Vivamus ac suscipit est, et elementum lectus.', 'Cras tincidunt nisi in urna molestie varius.', + 'Integer in magna eu nibh imperdiet tristique.', + 'Curabitur eu pellentesque nisl.', + 'Etiam non consequat est.', + 'Duis mi massa, feugiat nec molestie sit amet, suscipit et metus.', 'Curabitur ac enim dictum arcu varius fermentum vel sodales dui.', 'Ut tristique augue at congue molestie.', + 'Integer semper sem lorem, scelerisque suscipit lacus consequat nec.', + 'Etiam euismod efficitur magna nec dignissim.', + 'Morbi vel neque lectus.', + 'Etiam ac accumsan elit, et pharetra ex.', + 'Suspendisse vitae gravida mauris.', + 'Pellentesque sed laoreet erat.', + 'Nam aliquet purus quis massa eleifend, et efficitur felis aliquam.', + 'Fusce faucibus diam erat, sed consectetur urna auctor at.', + 'Praesent et nulla velit.', 'Cras eget enim nec odio feugiat tristique eu quis ante.', + 'Morbi blandit diam vitae odio sollicitudin finibus.', + 'Integer ac ante fermentum, placerat orci vel, fermentum lacus.', + 'Maecenas est elit, semper ut posuere et, congue ut orci.', 'Phasellus eget enim vitae nunc luctus sodales a eu erat.', + 'Curabitur dapibus nisi sed nisi dictum, in imperdiet urna posuere.', + 'Vivamus commodo odio metus, tincidunt facilisis augue dictum quis.', + 'Curabitur sagittis a lectus ac sodales.', + 'Nam eget eros purus.', + 'Nam scelerisque et ante in porta.', + 'Proin vitae augue tristique, malesuada nisl ut, fermentum nisl.', 'Nulla bibendum quam id velit blandit dictum.', + 'Cras tempus ac dolor ut convallis.', + 'Sed vel ipsum est.', + 'Nulla ut leo vestibulum, ultricies sapien ac, pellentesque dolor.', + 'Etiam ultricies maximus tempus.', 'Donec dignissim mi ac libero feugiat, vitae lacinia odio viverra.', + 'Curabitur condimentum tellus sit amet neque posuere, condimentum tempus purus eleifend.', + 'Donec tempus, augue id hendrerit pretium, mauris leo congue nulla, ac iaculis erat nunc in dolor.', 'Praesent vel lectus venenatis, elementum mauris vitae, ullamcorper nulla.', + 'Maecenas non diam cursus, imperdiet massa eget, pellentesque ex.', + 'Vestibulum luctus risus vel augue auctor blandit.', + 'Nullam augue diam, pulvinar sed sapien et, hendrerit venenatis risus.', 'Quisque sollicitudin nulla nec tellus feugiat hendrerit.', 'Vestibulum a eros accumsan, lacinia eros non, pretium diam.', + 'Aenean iaculis augue sit amet scelerisque aliquam.', 'Donec ornare felis et dui hendrerit, eget bibendum nibh interdum.', + 'Maecenas tellus magna, tristique vitae orci vel, auctor tincidunt nisi.', + 'Fusce non libero quis velit porttitor maximus at eget enim.', + 'Sed in aliquet tellus.', + 'Etiam a tortor erat.', 'Donec nec diam vel tellus egestas lobortis.', + 'Vivamus dictum erat nulla, sit amet accumsan dolor scelerisque eu.', + 'In nec eleifend ex, pellentesque dapibus sapien.', + 'Duis a mollis nisi.', 'Sed ornare nisl sit amet dolor pellentesque, eu fermentum leo interdum.', 'Sed eget mauris condimentum, molestie justo eu, feugiat felis.', + 'Nunc suscipit leo non dui blandit, ac malesuada ex consequat.', + 'Morbi varius placerat congue.', + 'Praesent id velit in nunc elementum aliquet.', 'Sed luctus justo vitae nibh bibendum blandit.', + 'Sed et sapien turpis.', 'Nulla ac eros vestibulum, mollis ante eu, rutrum nulla.', - 'Sed cursus magna ut vehicula rutrum.' + 'Sed cursus magna ut vehicula rutrum.', + 'Ut consectetur feugiat consectetur.', + 'Nulla nec ligula posuere neque sollicitudin rutrum a a dui.', + 'Nulla ut quam odio.', + 'Integer dignissim sapien et orci sodales volutpat.', + 'Nullam a sapien leo.', + 'Praesent cursus semper purus, vitae gravida risus dapibus mattis.', + 'Sed pellentesque nulla lorem, in commodo arcu feugiat sed.', + 'Phasellus blandit arcu non diam varius ornare.' ] diff --git a/source/index.js b/source/index.js index 83e6fbc59..bd7516cc2 100644 --- a/source/index.js +++ b/source/index.js @@ -3,9 +3,7 @@ export { ArrowKeyStepper } from './ArrowKeyStepper' export { AutoSizer } from './AutoSizer' export { CellMeasurer, - defaultCellSizeCache as defaultCellMeasurerCellSizeCache, - defaultCellSizeCache as uniformSizeCellMeasurerCellSizeCache, // 7.21 backwards compatible export - idCellSizeCache as idCellMeasurerCellSizeCache + CellMeasurerCache } from './CellMeasurer' export { Collection } from './Collection' export { ColumnSizer } from './ColumnSizer' diff --git a/source/jest-setup.js b/source/jest-setup.js index 843c22985..d0d8597c0 100644 --- a/source/jest-setup.js +++ b/source/jest-setup.js @@ -3,3 +3,6 @@ jest.mock('dom-helpers/util/scrollbarSize', () => { return 20 } }) + +// Polyfill requestAnimationFrame() for ReactDOMFrameScheduling +global.requestAnimationFrame = require('raf') diff --git a/webpack.config.umd.js b/webpack.config.umd.js index 3eb3186dd..327b76d99 100644 --- a/webpack.config.umd.js +++ b/webpack.config.umd.js @@ -14,8 +14,7 @@ module.exports = { }, externals: { 'react': 'React', - 'react-dom': 'ReactDOM', - 'react-addons-shallow-compare': 'var React.addons.shallowCompare' + 'react-dom': 'ReactDOM' }, plugins: [ new webpack.optimize.UglifyJsPlugin({ diff --git a/yarn.lock b/yarn.lock index dc7857fed..566fb02e0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2245,15 +2245,16 @@ fb-watchman@^1.8.0, fb-watchman@^1.9.0: dependencies: bser "1.0.2" -fbjs@^0.8.4: - version "0.8.6" - resolved "https://registry.yarnpkg.com/fbjs/-/fbjs-0.8.6.tgz#7eb67d6986b2d5007a9b6e92e0e7cb6f75cad290" +fbjs@^0.8.9: + version "0.8.9" + resolved "https://registry.yarnpkg.com/fbjs/-/fbjs-0.8.9.tgz#180247fbd347dcc9004517b904f865400a0c8f14" dependencies: core-js "^1.0.0" isomorphic-fetch "^2.1.1" loose-envify "^1.0.0" object-assign "^4.1.0" promise "^7.1.1" + setimmediate "^1.0.5" ua-parser-js "^0.7.9" figures@^1.3.5: @@ -2352,6 +2353,10 @@ flatten@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/flatten/-/flatten-1.0.2.tgz#dae46a9d78fbe25292258cc1e780a41d95c03782" +flow-bin@^0.39.0: + version "0.39.0" + resolved "https://registry.yarnpkg.com/flow-bin/-/flow-bin-0.39.0.tgz#b1012a14460df1aa79d3a728e10f93c6944226d0" + for-in@^0.1.5: version "0.1.6" resolved "https://registry.yarnpkg.com/for-in/-/for-in-0.1.6.tgz#c9f96e89bfad18a545af5ec3ed352a1d9e5b4dc8" @@ -4144,6 +4149,10 @@ pbkdf2-compat@2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/pbkdf2-compat/-/pbkdf2-compat-2.0.1.tgz#b6e0c8fa99494d94e0511575802a59a5c142f288" +performance-now@~0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-0.2.0.tgz#33ef30c5c77d4ea21c5a53869d91b56d8f2555e5" + pify@^2.0.0: version "2.3.0" resolved "https://registry.yarnpkg.com/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c" @@ -4551,6 +4560,12 @@ querystringify@0.0.x: version "0.0.4" resolved "https://registry.yarnpkg.com/querystringify/-/querystringify-0.0.4.tgz#0cf7f84f9463ff0ae51c4c4b142d95be37724d9c" +raf@^3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/raf/-/raf-3.3.0.tgz#93845eeffc773f8129039f677f80a36044eee2c3" + dependencies: + performance-now "~0.2.0" + randomatic@^1.1.3: version "1.1.5" resolved "https://registry.yarnpkg.com/randomatic/-/randomatic-1.1.5.tgz#5e9ef5f2d573c67bd2b8124ae90b5156e457840b" @@ -4571,13 +4586,12 @@ rc@~1.1.6: minimist "^1.2.0" strip-json-comments "~1.0.4" -react-addons-shallow-compare@^15.3.1: - version "15.3.2" - resolved "https://registry.yarnpkg.com/react-addons-shallow-compare/-/react-addons-shallow-compare-15.3.2.tgz#c9edba49b9eab44d0c59024d289beb1ab97318b5" - -react-addons-test-utils@^15.3.1: - version "15.3.2" - resolved "https://registry.yarnpkg.com/react-addons-test-utils/-/react-addons-test-utils-15.3.2.tgz#c09a44f583425a4a9c1b38444d7a6c3e6f0f41f6" +react-addons-test-utils@16.0.0-alpha.2: + version "16.0.0-alpha.2" + resolved "https://registry.yarnpkg.com/react-addons-test-utils/-/react-addons-test-utils-16.0.0-alpha.2.tgz#cd1a32fea6511110617dd165f199ce4a424b868f" + dependencies: + fbjs "^0.8.9" + object-assign "^4.1.0" react-broadcast@^0.1.1: version "0.1.2" @@ -4597,9 +4611,13 @@ react-deep-force-update@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/react-deep-force-update/-/react-deep-force-update-1.0.1.tgz#f911b5be1d2a6fe387507dd6e9a767aa2924b4c7" -react-dom@^15.3.1: - version "15.3.2" - resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-15.3.2.tgz#c46b0aa5380d7b838e7a59c4a7beff2ed315531f" +react-dom@16.0.0-alpha.2: + version "16.0.0-alpha.2" + resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.0.0-alpha.2.tgz#b2c916b0b0d8b34567967bdb945f55827a571c29" + dependencies: + fbjs "^0.8.9" + loose-envify "^1.1.0" + object-assign "^4.1.0" react-proxy@^1.1.7: version "1.1.8" @@ -4628,11 +4646,11 @@ react-transform-hmr@^1.0.2: global "^4.3.0" react-proxy "^1.1.7" -react@^15.3.1: - version "15.3.2" - resolved "https://registry.yarnpkg.com/react/-/react-15.3.2.tgz#a7bccd2fee8af126b0317e222c28d1d54528d09e" +react@16.0.0-alpha.2: + version "16.0.0-alpha.2" + resolved "https://registry.yarnpkg.com/react/-/react-16.0.0-alpha.2.tgz#9c81ac487d2795e1fba1bcc3bdff702c6267b42f" dependencies: - fbjs "^0.8.4" + fbjs "^0.8.9" loose-envify "^1.1.0" object-assign "^4.1.0" @@ -5036,6 +5054,10 @@ set-immediate-shim@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/set-immediate-shim/-/set-immediate-shim-1.0.1.tgz#4b2b1b27eb808a9f8dcc481a58e5e56f599f3f61" +setimmediate@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/setimmediate/-/setimmediate-1.0.5.tgz#290cbb232e306942d7d7ea9b83732ab7856f8285" + setprototypeof@1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.0.1.tgz#52009b27888c4dc48f591949c0a8275834c1ca7e"