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,23 +73,49 @@ 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 | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   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) => { | ||||
|     if (this.props.onRef) { | ||||
|       this.props.onRef(node); | ||||
| 
 | ||||
|     this.node = node; | ||||
|     if (node && node.children.length !== 0) { | ||||
|       this.height = node.clientHeight; | ||||
|     } | ||||
|   } | ||||
|   } | ||||
| 
 | ||||
|   handleClick = () => { | ||||
|     const { status } = this.props; | ||||
|  | @ -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