Lazy load toots using IntersectionObserver (#3191)
* refactor(components/status_list): Lazy load using IntersectionObserver * refactor(components/status_list): Avoid setState bottleneck * refactor(components/status_list): Update state correctly * fix(components/status): Render if isIntersecting is undefined * refactor(components/status): Recycle timeout * refactor(components/status): Reduce animation duration * refactor(components/status): Use requestIdleCallback * chore: Split polyfill bundles * refactor(components/status_list): Increase rootMargin to 300% * fix(components/status): Check if onRef is not defined * chore: Add note about polyfill bundle splitting * fix(components/status): Reduce animation duration to 0.3 seconds
This commit is contained in:
		
							parent
							
								
									676ba50601
								
							
						
					
					
						commit
						8e4d1cba00
					
				|  | @ -32,12 +32,44 @@ 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, | ||||||
|   }; |   }; | ||||||
| 
 | 
 | ||||||
|  |   state = { | ||||||
|  |     isHidden: false, | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   componentWillReceiveProps (nextProps) { | ||||||
|  |     if (nextProps.isIntersecting === false && this.props.isIntersecting !== false) { | ||||||
|  |       requestIdleCallback(() => this.setState({ isHidden: true })); | ||||||
|  |     } else { | ||||||
|  |       this.setState({ isHidden: !nextProps.isIntersecting }); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   shouldComponentUpdate (nextProps, nextState) { | ||||||
|  |     if (nextProps.isIntersecting === false && this.props.isIntersecting !== false) { | ||||||
|  |       return nextState.isHidden; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     return true; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   handleRef = (node) => { | ||||||
|  |     if (this.props.onRef) { | ||||||
|  |       this.props.onRef(node); | ||||||
|  | 
 | ||||||
|  |       if (node && node.children.length !== 0) { | ||||||
|  |         this.height = node.clientHeight; | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|   handleClick = () => { |   handleClick = () => { | ||||||
|     const { status } = this.props; |     const { status } = this.props; | ||||||
|     this.context.router.push(`/statuses/${status.getIn(['reblog', 'id'], status.get('id'))}`); |     this.context.router.push(`/statuses/${status.getIn(['reblog', 'id'], status.get('id'))}`); | ||||||
|  | @ -52,12 +84,22 @@ class Status extends ImmutablePureComponent { | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   render () { |   render () { | ||||||
|     let media = ''; |     let media = null; | ||||||
|     let statusAvatar; |     let statusAvatar; | ||||||
|     const { status, account, ...other } = this.props; |     const { status, account, isIntersecting, onRef, ...other } = this.props; | ||||||
|  |     const { isHidden } = this.state; | ||||||
| 
 | 
 | ||||||
|     if (status === null) { |     if (status === null) { | ||||||
|       return <div />; |       return <div ref={this.handleRef} data-id={status.get('id')} />; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     if (isIntersecting === false && isHidden) { | ||||||
|  |       return ( | ||||||
|  |         <div ref={this.handleRef} data-id={status.get('id')} style={{ height: `${this.height}px`, opacity: 0 }}> | ||||||
|  |           {status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])} | ||||||
|  |           {status.get('content')} | ||||||
|  |         </div> | ||||||
|  |       ); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') { |     if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') { | ||||||
|  | @ -70,7 +112,7 @@ class Status extends ImmutablePureComponent { | ||||||
|       const displayNameHTML = { __html: emojify(escapeTextContentForBrowser(displayName)) }; |       const displayNameHTML = { __html: emojify(escapeTextContentForBrowser(displayName)) }; | ||||||
| 
 | 
 | ||||||
|       return ( |       return ( | ||||||
|         <div className='status__wrapper'> |         <div className='status__wrapper' ref={this.handleRef} data-id={status.get('id')} > | ||||||
|           <div className='status__prepend'> |           <div className='status__prepend'> | ||||||
|             <div className='status__prepend-icon-wrapper'><i className='fa fa-fw fa-retweet status__prepend-icon' /></div> |             <div className='status__prepend-icon-wrapper'><i className='fa fa-fw fa-retweet status__prepend-icon' /></div> | ||||||
|             <FormattedMessage id='status.reblogged_by' defaultMessage='{name} boosted' values={{ name: <a onClick={this.handleAccountClick} data-id={status.getIn(['account', 'id'])} href={status.getIn(['account', 'url'])} className='status__display-name muted'><strong dangerouslySetInnerHTML={displayNameHTML} /></a> }} /> |             <FormattedMessage id='status.reblogged_by' defaultMessage='{name} boosted' values={{ name: <a onClick={this.handleAccountClick} data-id={status.getIn(['account', 'id'])} href={status.getIn(['account', 'url'])} className='status__display-name muted'><strong dangerouslySetInnerHTML={displayNameHTML} /></a> }} /> | ||||||
|  | @ -98,7 +140,7 @@ class Status extends ImmutablePureComponent { | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     return ( |     return ( | ||||||
|       <div className={`status ${this.props.muted ? 'muted' : ''} status-${status.get('visibility')}`}> |       <div className={`status ${this.props.muted ? 'muted' : ''} status-${status.get('visibility')}`} data-id={status.get('id')} ref={this.handleRef}> | ||||||
|         <div className='status__info'> |         <div className='status__info'> | ||||||
|           <a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} /></a> |           <a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} /></a> | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -26,6 +26,12 @@ class StatusList extends ImmutablePureComponent { | ||||||
|     trackScroll: true, |     trackScroll: true, | ||||||
|   }; |   }; | ||||||
| 
 | 
 | ||||||
|  |   state = { | ||||||
|  |     isIntersecting: [{ }], | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   statusRefQueue = [] | ||||||
|  | 
 | ||||||
|   handleScroll = (e) => { |   handleScroll = (e) => { | ||||||
|     const { scrollTop, scrollHeight, clientHeight } = e.target; |     const { scrollTop, scrollHeight, clientHeight } = e.target; | ||||||
|     const offset = scrollHeight - scrollTop - clientHeight; |     const offset = scrollHeight - scrollTop - clientHeight; | ||||||
|  | @ -42,6 +48,7 @@ class StatusList extends ImmutablePureComponent { | ||||||
| 
 | 
 | ||||||
|   componentDidMount () { |   componentDidMount () { | ||||||
|     this.attachScrollListener(); |     this.attachScrollListener(); | ||||||
|  |     this.attachIntersectionObserver(); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   componentDidUpdate (prevProps) { |   componentDidUpdate (prevProps) { | ||||||
|  | @ -52,6 +59,39 @@ class StatusList extends ImmutablePureComponent { | ||||||
| 
 | 
 | ||||||
|   componentWillUnmount () { |   componentWillUnmount () { | ||||||
|     this.detachScrollListener(); |     this.detachScrollListener(); | ||||||
|  |     this.detachIntersectionObserver(); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   attachIntersectionObserver () { | ||||||
|  |     const onIntersection = (entries) => { | ||||||
|  |       this.setState(state => { | ||||||
|  |         const isIntersecting = { }; | ||||||
|  | 
 | ||||||
|  |         entries.forEach(entry => { | ||||||
|  |           const statusId = entry.target.getAttribute('data-id'); | ||||||
|  | 
 | ||||||
|  |           state.isIntersecting[0][statusId] = entry.isIntersecting; | ||||||
|  |         }); | ||||||
|  | 
 | ||||||
|  |         return { isIntersecting: [state.isIntersecting[0]] }; | ||||||
|  |       }); | ||||||
|  |     }; | ||||||
|  | 
 | ||||||
|  |     const options = { | ||||||
|  |       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(); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   attachScrollListener () { |   attachScrollListener () { | ||||||
|  | @ -66,6 +106,15 @@ 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(); | ||||||
|  | @ -73,10 +122,11 @@ 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.isIntersecting[0]; | ||||||
| 
 | 
 | ||||||
|     let loadMore       = ''; |     let loadMore       = null; | ||||||
|     let scrollableArea = ''; |     let scrollableArea = null; | ||||||
|     let unread         = ''; |     let unread         = null; | ||||||
| 
 | 
 | ||||||
|     if (!isLoading && statusIds.size > 0 && hasMore) { |     if (!isLoading && statusIds.size > 0 && hasMore) { | ||||||
|       loadMore = <LoadMore onClick={this.handleLoadMore} />; |       loadMore = <LoadMore onClick={this.handleLoadMore} />; | ||||||
|  | @ -95,7 +145,7 @@ class StatusList extends ImmutablePureComponent { | ||||||
|             {prepend} |             {prepend} | ||||||
| 
 | 
 | ||||||
|             {statusIds.map((statusId) => { |             {statusIds.map((statusId) => { | ||||||
|               return <StatusContainer key={statusId} id={statusId} />; |               return <StatusContainer key={statusId} id={statusId} isIntersecting={isIntersecting[statusId]} onRef={this.handleStatusRef} />; | ||||||
|             })} |             })} | ||||||
| 
 | 
 | ||||||
|             {loadMore} |             {loadMore} | ||||||
|  |  | ||||||
|  | @ -0,0 +1,2 @@ | ||||||
|  | import 'intersection-observer'; | ||||||
|  | import 'requestidlecallback'; | ||||||
|  | @ -1,9 +1,30 @@ | ||||||
| import main from '../mastodon/main'; | import main from '../mastodon/main'; | ||||||
| 
 | 
 | ||||||
| if (!window.Intl || !Object.assign || !Number.isNaN || | const needsBasePolyfills = !( | ||||||
|     !window.Symbol || !Array.prototype.includes) { |   window.Intl && | ||||||
|   // load polyfills dynamically
 |   Object.assign && | ||||||
|   import('../mastodon/polyfills').then(main).catch(e => { |   Number.isNaN && | ||||||
|  |   window.Symbol && | ||||||
|  |   Array.prototype.includes | ||||||
|  | ); | ||||||
|  | 
 | ||||||
|  | const needsExtraPolyfills = !( | ||||||
|  |   window.IntersectionObserver && | ||||||
|  |   window.requestIdleCallback | ||||||
|  | ); | ||||||
|  | 
 | ||||||
|  | // Latest version of Firefox and Safari do not have IntersectionObserver.
 | ||||||
|  | // Edge does not have requestIdleCallback.
 | ||||||
|  | // This avoids shipping them all the polyfills.
 | ||||||
|  | if (needsBasePolyfills) { | ||||||
|  |   Promise.all([ | ||||||
|  |     import('../mastodon/base_polyfills'), | ||||||
|  |     import('../mastodon/extra_polyfills'), | ||||||
|  |   ]).then(main).catch(e => { | ||||||
|  |     console.error(e); // eslint-disable-line no-console
 | ||||||
|  |   }); | ||||||
|  | } else if (needsExtraPolyfills) { | ||||||
|  |   import('../mastodon/extra_polyfills').then(main).catch(e => { | ||||||
|     console.error(e); // eslint-disable-line no-console
 |     console.error(e); // eslint-disable-line no-console
 | ||||||
|   }); |   }); | ||||||
| } else { | } else { | ||||||
|  |  | ||||||
|  | @ -554,6 +554,14 @@ | ||||||
|   border-bottom: 1px solid lighten($ui-base-color, 8%); |   border-bottom: 1px solid lighten($ui-base-color, 8%); | ||||||
|   cursor: default; |   cursor: default; | ||||||
| 
 | 
 | ||||||
|  |   @keyframes fade { | ||||||
|  |     0% { opacity: 0; } | ||||||
|  |     100% { opacity: 1; } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   opacity: 1; | ||||||
|  |   animation: fade 0.3s linear; | ||||||
|  | 
 | ||||||
|   &.status-direct { |   &.status-direct { | ||||||
|     background: lighten($ui-base-color, 8%); |     background: lighten($ui-base-color, 8%); | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -55,6 +55,7 @@ | ||||||
|     "glob": "^7.1.1", |     "glob": "^7.1.1", | ||||||
|     "http-link-header": "^0.8.0", |     "http-link-header": "^0.8.0", | ||||||
|     "immutable": "^3.8.1", |     "immutable": "^3.8.1", | ||||||
|  |     "intersection-observer": "^0.2.1", | ||||||
|     "intl": "^1.2.5", |     "intl": "^1.2.5", | ||||||
|     "is-nan": "^1.2.1", |     "is-nan": "^1.2.1", | ||||||
|     "js-yaml": "^3.8.3", |     "js-yaml": "^3.8.3", | ||||||
|  | @ -92,6 +93,7 @@ | ||||||
|     "redux": "^3.6.0", |     "redux": "^3.6.0", | ||||||
|     "redux-immutable": "^3.1.0", |     "redux-immutable": "^3.1.0", | ||||||
|     "redux-thunk": "^2.2.0", |     "redux-thunk": "^2.2.0", | ||||||
|  |     "requestidlecallback": "^0.3.0", | ||||||
|     "reselect": "^2.5.4", |     "reselect": "^2.5.4", | ||||||
|     "rimraf": "^2.6.1", |     "rimraf": "^2.6.1", | ||||||
|     "sass-loader": "^6.0.3", |     "sass-loader": "^6.0.3", | ||||||
|  |  | ||||||
|  | @ -3341,6 +3341,10 @@ interpret@^1.0.0: | ||||||
|   version "1.0.1" |   version "1.0.1" | ||||||
|   resolved "https://registry.yarnpkg.com/interpret/-/interpret-1.0.1.tgz#d579fb7f693b858004947af39fa0db49f795602c" |   resolved "https://registry.yarnpkg.com/interpret/-/interpret-1.0.1.tgz#d579fb7f693b858004947af39fa0db49f795602c" | ||||||
| 
 | 
 | ||||||
|  | intersection-observer@^0.2.1: | ||||||
|  |   version "0.2.1" | ||||||
|  |   resolved "https://registry.yarnpkg.com/intersection-observer/-/intersection-observer-0.2.1.tgz#cb55175f4eebef6436d957a7d1774d39a9248e5e" | ||||||
|  | 
 | ||||||
| intl: | intl: | ||||||
|   version "1.2.5" |   version "1.2.5" | ||||||
|   resolved "https://registry.yarnpkg.com/intl/-/intl-1.2.5.tgz#82244a2190c4e419f8371f5aa34daa3420e2abde" |   resolved "https://registry.yarnpkg.com/intl/-/intl-1.2.5.tgz#82244a2190c4e419f8371f5aa34daa3420e2abde" | ||||||
|  | @ -5832,6 +5836,10 @@ request@2, request@2.x, request@^2.74.0, request@^2.79.0: | ||||||
|     tunnel-agent "~0.4.1" |     tunnel-agent "~0.4.1" | ||||||
|     uuid "^3.0.0" |     uuid "^3.0.0" | ||||||
| 
 | 
 | ||||||
|  | requestidlecallback@^0.3.0: | ||||||
|  |   version "0.3.0" | ||||||
|  |   resolved "https://registry.yarnpkg.com/requestidlecallback/-/requestidlecallback-0.3.0.tgz#6fb74e0733f90df3faa4838f9f6a2a5f9b742ac5" | ||||||
|  | 
 | ||||||
| require-directory@^2.1.1: | require-directory@^2.1.1: | ||||||
|   version "2.1.1" |   version "2.1.1" | ||||||
|   resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" |   resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" | ||||||
|  |  | ||||||
		Loading…
	
		Reference in New Issue