Add IntersectionObserverWrapper to cut down on re-renders (#3406)

main
Nolan Lawson 2017-05-29 09:17:51 -07:00 committed by Eugen Rochko
parent 922fb74197
commit 34a93ccf57
3 changed files with 100 additions and 79 deletions

View File

@ -32,16 +32,16 @@ class Status extends ImmutablePureComponent {
onOpenMedia: PropTypes.func, onOpenMedia: PropTypes.func,
onOpenVideo: PropTypes.func, onOpenVideo: PropTypes.func,
onBlock: PropTypes.func, onBlock: PropTypes.func,
onRef: PropTypes.func,
isIntersecting: PropTypes.bool,
me: PropTypes.number, me: PropTypes.number,
boostModal: PropTypes.bool, boostModal: PropTypes.bool,
autoPlayGif: PropTypes.bool, autoPlayGif: PropTypes.bool,
muted: PropTypes.bool, muted: PropTypes.bool,
intersectionObserverWrapper: PropTypes.object,
}; };
state = { state = {
isHidden: false, isIntersecting: true, // assume intersecting until told otherwise
isHidden: false, // set to true in requestIdleCallback to trigger un-render
} }
// Avoid checking props that are functions (and whose equality will always // Avoid checking props that are functions (and whose equality will always
@ -59,12 +59,12 @@ class Status extends ImmutablePureComponent {
updateOnStates = [] updateOnStates = []
shouldComponentUpdate (nextProps, nextState) { shouldComponentUpdate (nextProps, nextState) {
if (nextProps.isIntersecting === false && nextState.isHidden) { if (!nextState.isIntersecting && nextState.isHidden) {
// It's only if we're not intersecting (i.e. offscreen) and isHidden is true // It's only if we're not intersecting (i.e. offscreen) and isHidden is true
// that either "isIntersecting" or "isHidden" matter, and then they're // that either "isIntersecting" or "isHidden" matter, and then they're
// the only things that matter. // the only things that matter.
return this.props.isIntersecting !== false || !this.state.isHidden; return this.state.isIntersecting || !this.state.isHidden;
} else if (nextProps.isIntersecting !== false && this.props.isIntersecting === false) { } else if (nextState.isIntersecting && !this.state.isIntersecting) {
// If we're going from a non-intersecting state to an intersecting state, // If we're going from a non-intersecting state to an intersecting state,
// (i.e. offscreen to onscreen), then we definitely need to re-render // (i.e. offscreen to onscreen), then we definitely need to re-render
return true; return true;
@ -73,23 +73,49 @@ class Status extends ImmutablePureComponent {
return super.shouldComponentUpdate(nextProps, nextState); return super.shouldComponentUpdate(nextProps, nextState);
} }
componentWillReceiveProps (nextProps) { componentDidMount () {
if (nextProps.isIntersecting === false && this.props.isIntersecting !== false) { if (!this.props.intersectionObserverWrapper) {
requestIdleCallback(() => this.setState({ isHidden: true })); // TODO: enable IntersectionObserver optimization for notification statuses.
} else { // These are managed in notifications/index.js rather than status_list.js
this.setState({ isHidden: !nextProps.isIntersecting }); return;
} }
this.props.intersectionObserverWrapper.observe(
this.props.id,
this.node,
this.handleIntersection
);
} }
handleIntersection = (entry) => {
// Edge 15 doesn't support isIntersecting, but we can infer it from intersectionRatio
// https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/12156111/
const isIntersecting = entry.intersectionRatio > 0;
this.setState((prevState) => {
if (prevState.isIntersecting && !isIntersecting) {
requestIdleCallback(this.hideIfNotIntersecting);
}
return {
isIntersecting: isIntersecting,
isHidden: false,
};
});
}
hideIfNotIntersecting = () => {
// When the browser gets a chance, test if we're still not intersecting,
// and if so, set our isHidden to true to trigger an unrender. The point of
// this is to save DOM nodes and avoid using up too much memory.
// See: https://github.com/tootsuite/mastodon/issues/2900
this.setState((prevState) => ({ isHidden: !prevState.isIntersecting }));
}
handleRef = (node) => { handleRef = (node) => {
if (this.props.onRef) { this.node = node;
this.props.onRef(node);
if (node && node.children.length !== 0) { if (node && node.children.length !== 0) {
this.height = node.clientHeight; this.height = node.clientHeight;
} }
} }
}
handleClick = () => { handleClick = () => {
const { status } = this.props; const { status } = this.props;
@ -107,14 +133,14 @@ class Status extends ImmutablePureComponent {
render () { render () {
let media = null; let media = null;
let statusAvatar; let statusAvatar;
const { status, account, isIntersecting, onRef, ...other } = this.props; const { status, account, ...other } = this.props;
const { isHidden } = this.state; const { isIntersecting, isHidden } = this.state;
if (status === null) { if (status === null) {
return null; return null;
} }
if (isIntersecting === false && isHidden) { if (!isIntersecting && isHidden) {
return ( return (
<div ref={this.handleRef} data-id={status.get('id')} style={{ height: `${this.height}px`, opacity: 0, overflow: 'hidden' }}> <div ref={this.handleRef} data-id={status.get('id')} style={{ height: `${this.height}px`, opacity: 0, overflow: 'hidden' }}>
{status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])} {status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])}

View File

@ -5,6 +5,7 @@ import PropTypes from 'prop-types';
import StatusContainer from '../containers/status_container'; import StatusContainer from '../containers/status_container';
import LoadMore from './load_more'; import LoadMore from './load_more';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
import IntersectionObserverWrapper from '../features/ui/util/intersection_observer_wrapper';
class StatusList extends ImmutablePureComponent { class StatusList extends ImmutablePureComponent {
@ -26,12 +27,7 @@ class StatusList extends ImmutablePureComponent {
trackScroll: true, trackScroll: true,
}; };
state = { intersectionObserverWrapper = new IntersectionObserverWrapper();
isIntersecting: {},
intersectionCount: 0,
}
statusRefQueue = []
handleScroll = (e) => { handleScroll = (e) => {
const { scrollTop, scrollHeight, clientHeight } = e.target; const { scrollTop, scrollHeight, clientHeight } = e.target;
@ -64,53 +60,14 @@ class StatusList extends ImmutablePureComponent {
} }
attachIntersectionObserver () { attachIntersectionObserver () {
const onIntersection = (entries) => { this.intersectionObserverWrapper.connect({
this.setState(state => {
entries.forEach(entry => {
const statusId = entry.target.getAttribute('data-id');
// Edge 15 doesn't support isIntersecting, but we can infer it from intersectionRatio
// https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/12156111/
state.isIntersecting[statusId] = entry.intersectionRatio > 0;
});
// isIntersecting is a map of DOM data-id's to booleans (true for
// intersecting, false for non-intersecting).
//
// We always want to return true in shouldComponentUpdate() if
// this object changes, because onIntersection() is only called if
// something has changed.
//
// Now, we *could* use an immutable map or some other structure to
// diff the full map, but that would be pointless because the browser
// has already informed us that something has changed. So we can just
// use a regular object, which will be diffed by ImmutablePureComponent
// based on reference equality (i.e. it's always "unchanged") and
// then we just increment intersectionCount to force a change.
return {
isIntersecting: state.isIntersecting,
intersectionCount: state.intersectionCount + 1,
};
});
};
const options = {
root: this.node, root: this.node,
rootMargin: '300% 0px', rootMargin: '300% 0px',
}; });
this.intersectionObserver = new IntersectionObserver(onIntersection, options);
if (this.statusRefQueue.length) {
this.statusRefQueue.forEach(node => this.intersectionObserver.observe(node));
this.statusRefQueue = [];
}
} }
detachIntersectionObserver () { detachIntersectionObserver () {
this.intersectionObserver.disconnect(); this.intersectionObserverWrapper.disconnect();
} }
attachScrollListener () { attachScrollListener () {
@ -125,15 +82,6 @@ class StatusList extends ImmutablePureComponent {
this.node = c; this.node = c;
} }
handleStatusRef = (node) => {
if (node && this.intersectionObserver) {
const statusId = node.getAttribute('data-id');
this.intersectionObserver.observe(node);
} else {
this.statusRefQueue.push(node);
}
}
handleLoadMore = (e) => { handleLoadMore = (e) => {
e.preventDefault(); e.preventDefault();
this.props.onScrollToBottom(); this.props.onScrollToBottom();
@ -141,7 +89,6 @@ class StatusList extends ImmutablePureComponent {
render () { render () {
const { statusIds, onScrollToBottom, scrollKey, shouldUpdateScroll, isLoading, isUnread, hasMore, prepend, emptyMessage } = this.props; const { statusIds, onScrollToBottom, scrollKey, shouldUpdateScroll, isLoading, isUnread, hasMore, prepend, emptyMessage } = this.props;
const { isIntersecting } = this.state;
let loadMore = null; let loadMore = null;
let scrollableArea = null; let scrollableArea = null;
@ -164,7 +111,7 @@ class StatusList extends ImmutablePureComponent {
{prepend} {prepend}
{statusIds.map((statusId) => { {statusIds.map((statusId) => {
return <StatusContainer key={statusId} id={statusId} isIntersecting={isIntersecting[statusId]} onRef={this.handleStatusRef} />; return <StatusContainer key={statusId} id={statusId} intersectionObserverWrapper={this.intersectionObserverWrapper} />;
})} })}
{loadMore} {loadMore}

View File

@ -0,0 +1,48 @@
// Wrapper for IntersectionObserver in order to make working with it
// a bit easier. We also follow this performance advice:
// "If you need to observe multiple elements, it is both possible and
// advised to observe multiple elements using the same IntersectionObserver
// instance by calling observe() multiple times."
// https://developers.google.com/web/updates/2016/04/intersectionobserver
class IntersectionObserverWrapper {
callbacks = {};
observerBacklog = [];
observer = null;
connect (options) {
const onIntersection = (entries) => {
entries.forEach(entry => {
const id = entry.target.getAttribute('data-id');
if (this.callbacks[id]) {
this.callbacks[id](entry);
}
});
};
this.observer = new IntersectionObserver(onIntersection, options);
this.observerBacklog.forEach(([ id, node, callback ]) => {
this.observe(id, node, callback);
});
this.observerBacklog = null;
}
observe (id, node, callback) {
if (!this.observer) {
this.observerBacklog.push([ id, node, callback ]);
} else {
this.callbacks[id] = callback;
this.observer.observe(node);
}
}
disconnect () {
if (this.observer) {
this.observer.disconnect();
}
}
}
export default IntersectionObserverWrapper;