Add IntersectionObserverWrapper to cut down on re-renders (#3406)
This commit is contained in:
		
							parent
							
								
									922fb74197
								
							
						
					
					
						commit
						34a93ccf57
					
				
					 3 changed files with 100 additions and 79 deletions
				
			
		| 
						 | 
				
			
			@ -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 (
 | 
			
		||||
        <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'])}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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 <StatusContainer key={statusId} id={statusId} isIntersecting={isIntersecting[statusId]} onRef={this.handleStatusRef} />;
 | 
			
		||||
              return <StatusContainer key={statusId} id={statusId} intersectionObserverWrapper={this.intersectionObserverWrapper} />;
 | 
			
		||||
            })}
 | 
			
		||||
 | 
			
		||||
            {loadMore}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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;
 | 
			
		||||
		Loading…
	
		Reference in a new issue