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
					
				
					 8 changed files with 146 additions and 13 deletions
				
			
		| 
						 | 
				
			
			@ -32,12 +32,44 @@ 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,
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  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 = () => {
 | 
			
		||||
    const { status } = this.props;
 | 
			
		||||
    this.context.router.push(`/statuses/${status.getIn(['reblog', 'id'], status.get('id'))}`);
 | 
			
		||||
| 
						 | 
				
			
			@ -52,12 +84,22 @@ class Status extends ImmutablePureComponent {
 | 
			
		|||
  }
 | 
			
		||||
 | 
			
		||||
  render () {
 | 
			
		||||
    let media = '';
 | 
			
		||||
    let media = null;
 | 
			
		||||
    let statusAvatar;
 | 
			
		||||
    const { status, account, ...other } = this.props;
 | 
			
		||||
    const { status, account, isIntersecting, onRef, ...other } = this.props;
 | 
			
		||||
    const { isHidden } = this.state;
 | 
			
		||||
 | 
			
		||||
    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') {
 | 
			
		||||
| 
						 | 
				
			
			@ -70,7 +112,7 @@ class Status extends ImmutablePureComponent {
 | 
			
		|||
      const displayNameHTML = { __html: emojify(escapeTextContentForBrowser(displayName)) };
 | 
			
		||||
 | 
			
		||||
      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-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> }} />
 | 
			
		||||
| 
						 | 
				
			
			@ -98,7 +140,7 @@ class Status extends ImmutablePureComponent {
 | 
			
		|||
    }
 | 
			
		||||
 | 
			
		||||
    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'>
 | 
			
		||||
          <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,
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  state = {
 | 
			
		||||
    isIntersecting: [{ }],
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  statusRefQueue = []
 | 
			
		||||
 | 
			
		||||
  handleScroll = (e) => {
 | 
			
		||||
    const { scrollTop, scrollHeight, clientHeight } = e.target;
 | 
			
		||||
    const offset = scrollHeight - scrollTop - clientHeight;
 | 
			
		||||
| 
						 | 
				
			
			@ -42,6 +48,7 @@ class StatusList extends ImmutablePureComponent {
 | 
			
		|||
 | 
			
		||||
  componentDidMount () {
 | 
			
		||||
    this.attachScrollListener();
 | 
			
		||||
    this.attachIntersectionObserver();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  componentDidUpdate (prevProps) {
 | 
			
		||||
| 
						 | 
				
			
			@ -52,6 +59,39 @@ class StatusList extends ImmutablePureComponent {
 | 
			
		|||
 | 
			
		||||
  componentWillUnmount () {
 | 
			
		||||
    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 () {
 | 
			
		||||
| 
						 | 
				
			
			@ -66,6 +106,15 @@ 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();
 | 
			
		||||
| 
						 | 
				
			
			@ -73,10 +122,11 @@ class StatusList extends ImmutablePureComponent {
 | 
			
		|||
 | 
			
		||||
  render () {
 | 
			
		||||
    const { statusIds, onScrollToBottom, scrollKey, shouldUpdateScroll, isLoading, isUnread, hasMore, prepend, emptyMessage } = this.props;
 | 
			
		||||
    const isIntersecting = this.state.isIntersecting[0];
 | 
			
		||||
 | 
			
		||||
    let loadMore       = '';
 | 
			
		||||
    let scrollableArea = '';
 | 
			
		||||
    let unread         = '';
 | 
			
		||||
    let loadMore       = null;
 | 
			
		||||
    let scrollableArea = null;
 | 
			
		||||
    let unread         = null;
 | 
			
		||||
 | 
			
		||||
    if (!isLoading && statusIds.size > 0 && hasMore) {
 | 
			
		||||
      loadMore = <LoadMore onClick={this.handleLoadMore} />;
 | 
			
		||||
| 
						 | 
				
			
			@ -95,7 +145,7 @@ class StatusList extends ImmutablePureComponent {
 | 
			
		|||
            {prepend}
 | 
			
		||||
 | 
			
		||||
            {statusIds.map((statusId) => {
 | 
			
		||||
              return <StatusContainer key={statusId} id={statusId} />;
 | 
			
		||||
              return <StatusContainer key={statusId} id={statusId} isIntersecting={isIntersecting[statusId]} onRef={this.handleStatusRef} />;
 | 
			
		||||
            })}
 | 
			
		||||
 | 
			
		||||
            {loadMore}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										2
									
								
								app/javascript/mastodon/extra_polyfills.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								app/javascript/mastodon/extra_polyfills.js
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,2 @@
 | 
			
		|||
import 'intersection-observer';
 | 
			
		||||
import 'requestidlecallback';
 | 
			
		||||
| 
						 | 
				
			
			@ -1,9 +1,30 @@
 | 
			
		|||
import main from '../mastodon/main';
 | 
			
		||||
 | 
			
		||||
if (!window.Intl || !Object.assign || !Number.isNaN ||
 | 
			
		||||
    !window.Symbol || !Array.prototype.includes) {
 | 
			
		||||
  // load polyfills dynamically
 | 
			
		||||
  import('../mastodon/polyfills').then(main).catch(e => {
 | 
			
		||||
const needsBasePolyfills = !(
 | 
			
		||||
  window.Intl &&
 | 
			
		||||
  Object.assign &&
 | 
			
		||||
  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
 | 
			
		||||
  });
 | 
			
		||||
} else {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -554,6 +554,14 @@
 | 
			
		|||
  border-bottom: 1px solid lighten($ui-base-color, 8%);
 | 
			
		||||
  cursor: default;
 | 
			
		||||
 | 
			
		||||
  @keyframes fade {
 | 
			
		||||
    0% { opacity: 0; }
 | 
			
		||||
    100% { opacity: 1; }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  opacity: 1;
 | 
			
		||||
  animation: fade 0.3s linear;
 | 
			
		||||
 | 
			
		||||
  &.status-direct {
 | 
			
		||||
    background: lighten($ui-base-color, 8%);
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -55,6 +55,7 @@
 | 
			
		|||
    "glob": "^7.1.1",
 | 
			
		||||
    "http-link-header": "^0.8.0",
 | 
			
		||||
    "immutable": "^3.8.1",
 | 
			
		||||
    "intersection-observer": "^0.2.1",
 | 
			
		||||
    "intl": "^1.2.5",
 | 
			
		||||
    "is-nan": "^1.2.1",
 | 
			
		||||
    "js-yaml": "^3.8.3",
 | 
			
		||||
| 
						 | 
				
			
			@ -92,6 +93,7 @@
 | 
			
		|||
    "redux": "^3.6.0",
 | 
			
		||||
    "redux-immutable": "^3.1.0",
 | 
			
		||||
    "redux-thunk": "^2.2.0",
 | 
			
		||||
    "requestidlecallback": "^0.3.0",
 | 
			
		||||
    "reselect": "^2.5.4",
 | 
			
		||||
    "rimraf": "^2.6.1",
 | 
			
		||||
    "sass-loader": "^6.0.3",
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -3341,6 +3341,10 @@ interpret@^1.0.0:
 | 
			
		|||
  version "1.0.1"
 | 
			
		||||
  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:
 | 
			
		||||
  version "1.2.5"
 | 
			
		||||
  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"
 | 
			
		||||
    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:
 | 
			
		||||
  version "2.1.1"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42"
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
	Add table
		
		Reference in a new issue