From 34a93ccf571426ffaac68c573f231bb679c0ff3b Mon Sep 17 00:00:00 2001 From: Nolan Lawson Date: Mon, 29 May 2017 09:17:51 -0700 Subject: [PATCH] Add IntersectionObserverWrapper to cut down on re-renders (#3406) --- app/javascript/mastodon/components/status.js | 66 +++++++++++++------ .../mastodon/components/status_list.js | 65 ++---------------- .../ui/util/intersection_observer_wrapper.js | 48 ++++++++++++++ 3 files changed, 100 insertions(+), 79 deletions(-) create mode 100644 app/javascript/mastodon/features/ui/util/intersection_observer_wrapper.js diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js index a9f9c1b6b9..d35642ede8 100644 --- a/app/javascript/mastodon/components/status.js +++ b/app/javascript/mastodon/components/status.js @@ -32,16 +32,16 @@ class Status extends ImmutablePureComponent { onOpenMedia: PropTypes.func, onOpenVideo: PropTypes.func, onBlock: PropTypes.func, - onRef: PropTypes.func, - isIntersecting: PropTypes.bool, me: PropTypes.number, boostModal: PropTypes.bool, autoPlayGif: PropTypes.bool, muted: PropTypes.bool, + intersectionObserverWrapper: PropTypes.object, }; 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 @@ -59,12 +59,12 @@ class Status extends ImmutablePureComponent { updateOnStates = [] 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 // that either "isIntersecting" or "isHidden" matter, and then they're // the only things that matter. - return this.props.isIntersecting !== false || !this.state.isHidden; - } else if (nextProps.isIntersecting !== false && this.props.isIntersecting === false) { + return this.state.isIntersecting || !this.state.isHidden; + } else if (nextState.isIntersecting && !this.state.isIntersecting) { // 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 return true; @@ -73,21 +73,47 @@ class Status extends ImmutablePureComponent { return super.shouldComponentUpdate(nextProps, nextState); } - componentWillReceiveProps (nextProps) { - if (nextProps.isIntersecting === false && this.props.isIntersecting !== false) { - requestIdleCallback(() => this.setState({ isHidden: true })); - } else { - this.setState({ isHidden: !nextProps.isIntersecting }); + componentDidMount () { + if (!this.props.intersectionObserverWrapper) { + // TODO: enable IntersectionObserver optimization for notification statuses. + // These are managed in notifications/index.js rather than status_list.js + return; } + this.props.intersectionObserverWrapper.observe( + this.props.id, + this.node, + this.handleIntersection + ); } - handleRef = (node) => { - if (this.props.onRef) { - this.props.onRef(node); - - if (node && node.children.length !== 0) { - this.height = node.clientHeight; + 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) => { + this.node = node; + if (node && node.children.length !== 0) { + this.height = node.clientHeight; } } @@ -107,14 +133,14 @@ class Status extends ImmutablePureComponent { render () { let media = null; let statusAvatar; - const { status, account, isIntersecting, onRef, ...other } = this.props; - const { isHidden } = this.state; + const { status, account, ...other } = this.props; + const { isIntersecting, isHidden } = this.state; if (status === null) { return null; } - if (isIntersecting === false && isHidden) { + if (!isIntersecting && isHidden) { return (
{status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])} diff --git a/app/javascript/mastodon/components/status_list.js b/app/javascript/mastodon/components/status_list.js index 39f663dfb3..9ee3af4d1e 100644 --- a/app/javascript/mastodon/components/status_list.js +++ b/app/javascript/mastodon/components/status_list.js @@ -5,6 +5,7 @@ import PropTypes from 'prop-types'; import StatusContainer from '../containers/status_container'; import LoadMore from './load_more'; import ImmutablePureComponent from 'react-immutable-pure-component'; +import IntersectionObserverWrapper from '../features/ui/util/intersection_observer_wrapper'; class StatusList extends ImmutablePureComponent { @@ -26,12 +27,7 @@ class StatusList extends ImmutablePureComponent { trackScroll: true, }; - state = { - isIntersecting: {}, - intersectionCount: 0, - } - - statusRefQueue = [] + intersectionObserverWrapper = new IntersectionObserverWrapper(); handleScroll = (e) => { const { scrollTop, scrollHeight, clientHeight } = e.target; @@ -64,53 +60,14 @@ class StatusList extends ImmutablePureComponent { } attachIntersectionObserver () { - const onIntersection = (entries) => { - 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 = { + this.intersectionObserverWrapper.connect({ root: this.node, rootMargin: '300% 0px', - }; - - this.intersectionObserver = new IntersectionObserver(onIntersection, options); - - if (this.statusRefQueue.length) { - this.statusRefQueue.forEach(node => this.intersectionObserver.observe(node)); - this.statusRefQueue = []; - } + }); } detachIntersectionObserver () { - this.intersectionObserver.disconnect(); + this.intersectionObserverWrapper.disconnect(); } attachScrollListener () { @@ -125,15 +82,6 @@ class StatusList extends ImmutablePureComponent { 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) => { e.preventDefault(); this.props.onScrollToBottom(); @@ -141,7 +89,6 @@ class StatusList extends ImmutablePureComponent { render () { const { statusIds, onScrollToBottom, scrollKey, shouldUpdateScroll, isLoading, isUnread, hasMore, prepend, emptyMessage } = this.props; - const { isIntersecting } = this.state; let loadMore = null; let scrollableArea = null; @@ -164,7 +111,7 @@ class StatusList extends ImmutablePureComponent { {prepend} {statusIds.map((statusId) => { - return ; + return ; })} {loadMore} diff --git a/app/javascript/mastodon/features/ui/util/intersection_observer_wrapper.js b/app/javascript/mastodon/features/ui/util/intersection_observer_wrapper.js new file mode 100644 index 0000000000..0e959f9ae8 --- /dev/null +++ b/app/javascript/mastodon/features/ui/util/intersection_observer_wrapper.js @@ -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;