Skip to content

Instantly share code, notes, and snippets.

@janpaul123
Created May 18, 2018 20:47
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save janpaul123/1c63660d422f02ad65cc4f1092fc787a to your computer and use it in GitHub Desktop.
Save janpaul123/1c63660d422f02ad65cc4f1092fc787a to your computer and use it in GitHub Desktop.
Copyright: Remix Software; License: MIT
/* eslint-disable react/prop-types */
import React from 'react';
import ReactDOM from 'react-dom';
import ReactTestUtils from 'react-dom/test-utils';
import FastScrollComponent from './FastScrollComponent';
describe('<FastScrollComponent>', function() {
const setupComponent = ({ cacheWhenNotVisible = false, height = 100 }) => {
// eslint-disable-line react/prop-types
const rowHeight = 25;
const rowWidth = 1000;
const rowCount = 100;
this.layers = new Array(rowCount);
return (
<div
style={{
width: rowWidth,
height: height,
position: 'relative',
}}
>
<FastScrollComponent.Container
width={rowWidth}
height={rowCount * rowHeight}
overscanPx={0}
snapPx={1}
ref={(el) => {
this.container = el;
}}
>
{new Array(rowCount).fill(0).map((_, x) => (
<FastScrollComponent.Layer
key={x}
left={0}
top={rowHeight * x}
width={rowWidth}
height={rowHeight}
ref={(el) => {
this.layers[x] = el;
}}
cacheWhenNotVisible={cacheWhenNotVisible}
>
{() => <div style={{ height: rowHeight }}>{x}</div>}
</FastScrollComponent.Layer>
))}
</FastScrollComponent.Container>
</div>
);
};
describe('cacheWhenNotVisible is false', () => {
beforeEach(() => {
this.component = window.renderComponent(setupComponent({}));
});
it('displays the top four elements and hides the rest', () => {
expect(this.layers[3].state.rendered).toBeTruthy();
expect(this.layers[4].state.rendered).toBeFalsy();
});
it('un-renders the top elements when scrolled', () => {
this.container._root.scrollTop = 100; // scroll 4th element out
ReactTestUtils.Simulate.scroll(this.container._root);
jasmine.clock().tick(100);
expect(this.layers[3].state.rendered).toBeFalsy();
expect(this.layers[4].state.rendered).toBeTruthy();
});
it('renders additional elements when height is changed', () => {
window.renderComponentAgain(this.component, setupComponent({ height: 200 }));
jasmine.clock().tick(100); // trigger throttled update
expect(this.layers[0].state.rendered).toBeTruthy();
expect(this.layers[7].state.rendered).toBeTruthy();
expect(this.layers[8].state.rendered).toBeFalsy();
});
});
describe('cacheWhenNotVisible is true', () => {
beforeEach(() => {
this.component = window.renderComponent(
setupComponent({
cacheWhenNotVisible: true,
}),
);
});
it('displays the top four elements and hides the rest', () => {
expect(this.layers[3].state.rendered).toBeTruthy();
expect(ReactDOM.findDOMNode(this.layers[3]).style.display).toEqual('');
expect(this.layers[4].state.rendered).toBeFalsy();
});
it('hides the top elements when scrolled', () => {
this.container._root.scrollTop = 100; // scroll 4th element out
ReactTestUtils.Simulate.scroll(this.container._root);
jasmine.clock().tick(100);
expect(this.layers[3].state.rendered).toBeTruthy();
expect(ReactDOM.findDOMNode(this.layers[3]).style.display).toEqual('none');
expect(this.layers[4].state.rendered).toBeTruthy();
expect(ReactDOM.findDOMNode(this.layers[4]).style.display).toEqual('');
});
});
});
import PropTypes from 'prop-types';
import React from 'react';
import shallowEqual from 'shallowequal';
import throttle from 'lodash/throttle';
const contextTypes = {
dimensions: PropTypes.object,
updateFuncs: PropTypes.array,
};
const FastScrollComponent = {
Container: class extends React.Component {
static propTypes = {
children: PropTypes.node,
width: PropTypes.number.isRequired,
height: PropTypes.number.isRequired,
// These are advanced options for if you want to tweak the defaults.
// Number of pixels of "overscan" around the visible area. More reduces
// flickering when scrolling but is slower.
overscanPx: PropTypes.number,
// How long to wait after the last scroll event to restore pointer events
// again. Less means a better responding interface but is slower.
resetPointerEventsMs: PropTypes.number,
// How often we can rerender during scrolling. Less reduces flickering
// when scrolling but is slower, but too much can also be slower because
// more elements have to be rerendered.
scrollRefreshThrottleMs: PropTypes.number,
// The grid size that we snap to, to reduce the number of times we have to
// rerender. For example when this is 25, and the scrollTop is 30, we
// pretend that the scroll top is actually 25. Has to be less than
// overscanPx. More is faster because we rerender less often, but too much
// can be slower because more elements have to be rerendered.
snapPx: PropTypes.number,
};
static childContextTypes = contextTypes;
static defaultProps = {
overscanPx: 100,
snapPx: 25,
scrollRefreshThrottleMs: 50,
resetPointerEventsMs: 200,
};
getChildContext() {
return { dimensions: this._dimensions, updateFuncs: this._updateFuncs };
}
componentWillMount() {
this._dimensions = { left: 0, top: 0, width: 0, height: 0 };
this._updateFuncs = [];
this._throttledUpdateVisibleChildren = throttle(
this._updateVisibleChildren,
this.props.scrollRefreshThrottleMs,
);
}
componentDidMount() {
window.addEventListener('resize', this._throttledUpdateVisibleChildren);
this._throttledUpdateVisibleChildren();
}
componentWillReceiveProps(nextProps) {
if (__DEV__) {
if (this.props.overscanPx !== nextProps.overscanPx) {
throw new Error('Changing <FastScrollComponent.Container overscanPx> is not supported');
}
if (this.props.resetPointerEventsMs !== nextProps.resetPointerEventsMs) {
throw new Error(
'Changing <FastScrollComponent.Container resetPointerEventsMs> is not supported',
);
}
if (this.props.scrollRefreshThrottleMs !== nextProps.scrollRefreshThrottleMs) {
throw new Error(
'Changing <FastScrollComponent.Container scrollRefreshThrottleMs> is not supported',
);
}
if (this.props.snapPx !== nextProps.snapPx) {
throw new Error('Changing <FastScrollComponent.Container snapPx> is not supported');
}
}
}
componentDidUpdate() {
this._throttledUpdateVisibleChildren();
}
componentWillUnmount() {
window.removeEventListener('resize', this._throttledUpdateVisibleChildren);
}
scrollableElement = () => {
return this._root;
};
_updateVisibleChildren = () => {
if (!this._root) return;
const { scrollLeft, scrollTop, clientWidth, clientHeight } = this._root;
const { overscanPx, snapPx, resetPointerEventsMs } = this.props;
// Snap to snapPx grid.
const snappedScrollLeft = Math.floor(scrollLeft / snapPx) * snapPx;
const snappedScrollTop = Math.floor(scrollTop / snapPx) * snapPx;
// Don't do anything if we're at the same position as last time.
if (
snappedScrollLeft === this._lastSnappedScrollLeft &&
snappedScrollTop === this._lastSnappedScrollTop &&
clientWidth === this._lastClientWidth &&
clientHeight === this._lastClientHeight
) {
return;
}
this._lastSnappedScrollLeft = snappedScrollLeft;
this._lastSnappedScrollTop = snappedScrollTop;
this._lastClientWidth = clientWidth;
this._lastClientHeight = clientHeight;
// Update visible dimensions, including overscan.
this._dimensions.left = snappedScrollLeft - overscanPx;
this._dimensions.top = snappedScrollTop - overscanPx;
this._dimensions.width = clientWidth + overscanPx * 2;
this._dimensions.height = clientHeight + overscanPx * 2;
// Let children know that visible dimensions have changed.
this._updateFuncs.forEach((updateFunc) => updateFunc());
// Disable pointer events so underlying elements don't update while scrolling.
this._inner.style.pointerEvents = 'none';
clearTimeout(this._pointerEventsTimeout);
this._pointerEventsTimeout = setTimeout(this._resetPointerEvents, resetPointerEventsMs);
};
_resetPointerEvents = () => {
if (!this._root) return;
this._inner.style.pointerEvents = 'auto';
};
render() {
return (
<div
style={{
position: 'absolute',
left: 0,
top: 0,
width: '100%',
height: '100%',
overflow: 'scroll', // Necessary for easily correcting scrollbars using <ScrollbarPaddingComponent>.
willChange: 'transform', // More efficient painting (see https://redd.it/4a7a5u).
}}
onScroll={this._throttledUpdateVisibleChildren}
ref={(element) => (this._root = element)}
>
<div
style={{
position: 'relative',
width: this.props.width,
height: this.props.height,
}}
ref={(element) => (this._inner = element)}
>
{this.props.children}
</div>
</div>
);
}
},
Layer: class extends React.Component {
static propTypes = {
left: PropTypes.number.isRequired,
top: PropTypes.number.isRequired,
width: PropTypes.number.isRequired,
height: PropTypes.number.isRequired,
children: PropTypes.func, // Always a func, so we don't accidentally create unnecessary elements.
// TODO(JP): Should this be an option? Maybe always do this until children are changed?
cacheWhenNotVisible: PropTypes.bool,
};
static contextTypes = contextTypes;
static childContextTypes = contextTypes;
state = { rendered: false };
getChildContext() {
return { dimensions: this._dimensions, updateFuncs: this._updateFuncs };
}
componentWillMount() {
this._dimensions = { left: 0, top: 0, width: 0, height: 0 };
this._updateFuncs = [];
}
componentDidMount() {
this.context.updateFuncs.push(this._updateVisibility);
this._updateVisibility();
if (__DEV__) this._assertPosition();
}
componentDidUpdate(prevProps) {
// TODO(JP): Since this may trigger a state change, do part of it
// (the state change) in componentWillReceiveProps instead.
// Also add a shouldComponentUpdate so we don't fully rerender when
// children don't change (instead just update the <div> directly).
if (!shallowEqual(this.props, prevProps)) {
this._updateVisibility();
}
if (__DEV__) this._assertPosition();
}
componentWillUnmount() {
const index = this.context.updateFuncs.indexOf(this._updateVisibility);
if (index === -1) throw new Error('Entry not found in updateFuncs');
this.context.updateFuncs.splice(index, 1);
}
_updateVisibility = () => {
const { left, top, width, height } = this.context.dimensions;
this._dimensions.left = left - this.props.left;
this._dimensions.top = top - this.props.top;
this._dimensions.width = width;
this._dimensions.height = height;
const visible =
this.props.left < left + width &&
this.props.left + this.props.width > left &&
this.props.top < top + height &&
this.props.top + this.props.height > top;
if (visible) {
if (!this.state.rendered) {
this.setState({ rendered: true });
} else {
if (this.props.cacheWhenNotVisible) {
this._element.style.display = 'block';
}
this._updateFuncs.forEach((updateFunc) => updateFunc());
}
} else if (this.state.rendered) {
if (this.props.cacheWhenNotVisible) {
this._element.style.display = 'none';
} else {
this.setState({ rendered: false });
}
}
};
_assertPosition = () => {
if (this.state.rendered) {
if (!this._element.offsetLeft === this.props.left) {
throw new Error('offsetLeft does not match <FastScrollComponent.Layer left>');
}
if (!this._element.offsetTop === this.props.top) {
throw new Error('offsetTop does not match <FastScrollComponent.Layer top>');
}
}
};
render() {
if (!this.state.rendered) return null;
return (
<div
style={{
position: 'absolute',
left: this.props.left,
top: this.props.top,
width: this.props.width,
height: this.props.height,
}}
ref={(element) => (this._element = element)}
>
{this.props.children()}
</div>
);
}
},
};
export default FastScrollComponent;
import FastScrollComponent from './FastScrollComponent';
import React from 'react';
export default {
examples: [
{
title: '<FastScrollComponent>',
component: class extends React.Component {
render() {
return (
<div style={{ position: 'relative', width: 600, height: 300 }}>
<FastScrollComponent.Container width={30 * 100} height={30 * 100}>
{new Array(100).fill(0).map((_, x) => (
<FastScrollComponent.Layer
key={x}
left={x * 30}
top={0}
width={30}
height={30 * 100}
cacheWhenNotVisible
>
{() =>
new Array(100).fill(0).map((__, y) => (
<FastScrollComponent.Layer
key={y}
left={0}
top={y * 30}
width={30}
height={30}
cacheWhenNotVisible
>
{() => (
<div
style={{ position: 'absolute', width: 30, height: 30, fontSize: 6 }}
>
{x}, {y}
</div>
)}
</FastScrollComponent.Layer>
))
}
</FastScrollComponent.Layer>
))}
</FastScrollComponent.Container>
</div>
);
}
},
},
],
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment