Browse Source

feat: Cache status height to avoid expensive renders (#4439)

* feat: Cache status height to avoid expensive renders

* feat: Escape content and emojify in reducers

* fix(css): Remove backface-visibility: hidden from .scrollable

* fix(statuses): Avoid creating DOMParses inside a loop
Sorin Davidoi 6 years ago
parent
commit
8eb6d171e6

+ 17 - 0
app/javascript/mastodon/actions/statuses.js

@@ -23,6 +23,9 @@ export const STATUS_UNMUTE_REQUEST = 'STATUS_UNMUTE_REQUEST';
 export const STATUS_UNMUTE_SUCCESS = 'STATUS_UNMUTE_SUCCESS';
 export const STATUS_UNMUTE_FAIL    = 'STATUS_UNMUTE_FAIL';
 
+export const STATUS_SET_HEIGHT = 'STATUS_SET_HEIGHT';
+export const STATUSES_CLEAR_HEIGHT = 'STATUSES_CLEAR_HEIGHT';
+
 export function fetchStatusRequest(id, skipLoading) {
   return {
     type: STATUS_FETCH_REQUEST,
@@ -215,3 +218,17 @@ export function unmuteStatusFail(id, error) {
     error,
   };
 };
+
+export function setStatusHeight (id, height) {
+  return {
+    type: STATUS_SET_HEIGHT,
+    id,
+    height,
+  };
+};
+
+export function clearStatusesHeight () {
+  return {
+    type: STATUSES_CLEAR_HEIGHT,
+  };
+};

+ 2 - 5
app/javascript/mastodon/components/display_name.js

@@ -1,7 +1,5 @@
 import React from 'react';
 import ImmutablePropTypes from 'react-immutable-proptypes';
-import escapeTextContentForBrowser from 'escape-html';
-import emojify from '../emoji';
 
 export default class DisplayName extends React.PureComponent {
 
@@ -10,12 +8,11 @@ export default class DisplayName extends React.PureComponent {
   };
 
   render () {
-    const displayName     = this.props.account.get('display_name').length === 0 ? this.props.account.get('username') : this.props.account.get('display_name');
-    const displayNameHTML = { __html: emojify(escapeTextContentForBrowser(displayName)) };
+    const displayNameHtml = { __html: this.props.account.get('display_name_html') };
 
     return (
       <span className='display-name'>
-        <strong className='display-name__html' dangerouslySetInnerHTML={displayNameHTML} /> <span className='display-name__account'>@{this.props.account.get('acct')}</span>
+        <strong className='display-name__html' dangerouslySetInnerHTML={displayNameHtml} /> <span className='display-name__account'>@{this.props.account.get('acct')}</span>
       </span>
     );
   }

+ 13 - 13
app/javascript/mastodon/components/status.js

@@ -8,8 +8,6 @@ import DisplayName from './display_name';
 import StatusContent from './status_content';
 import StatusActionBar from './status_action_bar';
 import { FormattedMessage } from 'react-intl';
-import emojify from '../emoji';
-import escapeTextContentForBrowser from 'escape-html';
 import ImmutablePureComponent from 'react-immutable-pure-component';
 import scheduleIdleTask from '../features/ui/util/schedule_idle_task';
 import { MediaGallery, VideoPlayer } from '../features/ui/util/async-components';
@@ -36,6 +34,7 @@ export default class Status extends ImmutablePureComponent {
     onOpenMedia: PropTypes.func,
     onOpenVideo: PropTypes.func,
     onBlock: PropTypes.func,
+    onHeightChange: PropTypes.func,
     me: PropTypes.number,
     boostModal: PropTypes.bool,
     autoPlayGif: PropTypes.bool,
@@ -47,7 +46,6 @@ export default class Status extends ImmutablePureComponent {
 
   state = {
     isExpanded: false,
-    isIntersecting: true, // assume intersecting until told otherwise
     isHidden: false, // set to true in requestIdleCallback to trigger un-render
   }
 
@@ -108,6 +106,10 @@ export default class Status extends ImmutablePureComponent {
     if (this.node && this.node.children.length !== 0) {
       // save the height of the fully-rendered element
       this.height = getRectFromEntry(entry).height;
+
+      if (this.props.onHeightChange) {
+        this.props.onHeightChange(this.props.status, this.height);
+      }
     }
 
     this.setState((prevState) => {
@@ -179,9 +181,13 @@ export default class Status extends ImmutablePureComponent {
       return null;
     }
 
-    if (!isIntersecting && isHidden) {
+    const hasIntersectionObserverWrapper = !!this.props.intersectionObserverWrapper;
+    const isHiddenForSure = isIntersecting === false && isHidden;
+    const visibilityUnknownButHeightIsCached = isIntersecting === undefined && status.has('height');
+
+    if (hasIntersectionObserverWrapper && (isHiddenForSure || visibilityUnknownButHeightIsCached)) {
       return (
-        <article ref={this.handleRef} data-id={status.get('id')} aria-posinset={index} aria-setsize={listLength} tabIndex='0' style={{ height: `${this.height}px`, opacity: 0, overflow: 'hidden' }}>
+        <article ref={this.handleRef} data-id={status.get('id')} aria-posinset={index} aria-setsize={listLength} tabIndex='0' style={{ height: `${this.height || status.get('height')}px`, opacity: 0, overflow: 'hidden' }}>
           {status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])}
           {status.get('content')}
         </article>
@@ -189,19 +195,13 @@ export default class Status extends ImmutablePureComponent {
     }
 
     if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') {
-      let displayName = status.getIn(['account', 'display_name']);
-
-      if (displayName.length === 0) {
-        displayName = status.getIn(['account', 'username']);
-      }
-
-      const displayNameHTML = { __html: emojify(escapeTextContentForBrowser(displayName)) };
+      const display_name_html = { __html: status.getIn(['account', 'display_name_html']) };
 
       return (
         <article className='status__wrapper' ref={this.handleRef} data-id={status.get('id')} aria-posinset={index} aria-setsize={listLength} tabIndex='0'>
           <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> }} />
+            <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={display_name_html} /></a> }} />
           </div>
 
           <Status {...other} wrapped status={status.get('reblog')} account={status.get('account')} />

+ 2 - 4
app/javascript/mastodon/components/status_content.js

@@ -1,8 +1,6 @@
 import React from 'react';
 import ImmutablePropTypes from 'react-immutable-proptypes';
-import escapeTextContentForBrowser from 'escape-html';
 import PropTypes from 'prop-types';
-import emojify from '../emoji';
 import { isRtl } from '../rtl';
 import { FormattedMessage } from 'react-intl';
 import Permalink from './permalink';
@@ -119,8 +117,8 @@ export default class StatusContent extends React.PureComponent {
 
     const hidden = this.props.onExpandedToggle ? !this.props.expanded : this.state.hidden;
 
-    const content = { __html: emojify(status.get('content')) };
-    const spoilerContent = { __html: emojify(escapeTextContentForBrowser(status.get('spoiler_text', ''))) };
+    const content = { __html: status.get('contentHtml') };
+    const spoilerContent = { __html: status.get('spoilerHtml') };
     const directionStyle = { direction: 'ltr' };
     const classNames = classnames('status__content', {
       'status__content--with-action': this.props.onClick && this.context.router,

+ 5 - 1
app/javascript/mastodon/containers/status_container.js

@@ -16,7 +16,7 @@ import {
   blockAccount,
   muteAccount,
 } from '../actions/accounts';
-import { muteStatus, unmuteStatus, deleteStatus } from '../actions/statuses';
+import { muteStatus, unmuteStatus, deleteStatus, setStatusHeight } from '../actions/statuses';
 import { initReport } from '../actions/reports';
 import { openModal } from '../actions/modal';
 import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
@@ -124,6 +124,10 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
     }
   },
 
+  onHeightChange (status, height) {
+    dispatch(setStatusHeight(status.get('id'), height));
+  },
+
 });
 
 export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Status));

+ 3 - 10
app/javascript/mastodon/features/account/components/header.js

@@ -1,8 +1,6 @@
 import React from 'react';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import PropTypes from 'prop-types';
-import emojify from '../../../emoji';
-import escapeTextContentForBrowser from 'escape-html';
 import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 import IconButton from '../../../components/icon_button';
 import Motion from 'react-motion/lib/Motion';
@@ -92,15 +90,10 @@ export default class Header extends ImmutablePureComponent {
       return null;
     }
 
-    let displayName = account.get('display_name');
     let info        = '';
     let actionBtn   = '';
     let lockedIcon  = '';
 
-    if (displayName.length === 0) {
-      displayName = account.get('username');
-    }
-
     if (me !== account.get('id') && account.getIn(['relationship', 'followed_by'])) {
       info = <span className='account--follows-info'><FormattedMessage id='account.follows_you' defaultMessage='Follows you' /></span>;
     }
@@ -125,15 +118,15 @@ export default class Header extends ImmutablePureComponent {
       lockedIcon = <i className='fa fa-lock' />;
     }
 
-    const content         = { __html: emojify(account.get('note')) };
-    const displayNameHTML = { __html: emojify(escapeTextContentForBrowser(displayName)) };
+    const content         = { __html: account.get('note_emojified') };
+    const displayNameHtml = { __html: account.get('display_name_html') };
 
     return (
       <div className='account__header' style={{ backgroundImage: `url(${account.get('header')})` }}>
         <div>
           <Avatar account={account} autoPlayGif={this.props.autoPlayGif} />
 
-          <span className='account__header__display-name' dangerouslySetInnerHTML={displayNameHTML} />
+          <span className='account__header__display-name' dangerouslySetInnerHTML={displayNameHtml} />
           <span className='account__header__username'>@{account.get('acct')} {lockedIcon}</span>
           <div className='account__header__content' dangerouslySetInnerHTML={content} />
 

+ 1 - 2
app/javascript/mastodon/features/compose/components/reply_indicator.js

@@ -4,7 +4,6 @@ import PropTypes from 'prop-types';
 import Avatar from '../../../components/avatar';
 import IconButton from '../../../components/icon_button';
 import DisplayName from '../../../components/display_name';
-import emojify from '../../../emoji';
 import { defineMessages, injectIntl } from 'react-intl';
 import ImmutablePureComponent from 'react-immutable-pure-component';
 
@@ -43,7 +42,7 @@ export default class ReplyIndicator extends ImmutablePureComponent {
       return null;
     }
 
-    const content  = { __html: emojify(status.get('content')) };
+    const content  = { __html: status.get('contentHtml') };
 
     return (
       <div className='reply-indicator'>

+ 1 - 2
app/javascript/mastodon/features/follow_requests/components/account_authorize.js

@@ -4,7 +4,6 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
 import Permalink from '../../../components/permalink';
 import Avatar from '../../../components/avatar';
 import DisplayName from '../../../components/display_name';
-import emojify from '../../../emoji';
 import IconButton from '../../../components/icon_button';
 import { defineMessages, injectIntl } from 'react-intl';
 import ImmutablePureComponent from 'react-immutable-pure-component';
@@ -26,7 +25,7 @@ export default class AccountAuthorize extends ImmutablePureComponent {
 
   render () {
     const { intl, account, onAuthorize, onReject } = this.props;
-    const content = { __html: emojify(account.get('note')) };
+    const content = { __html: account.get('note_emojified') };
 
     return (
       <div className='account-authorize__wrapper'>

+ 2 - 5
app/javascript/mastodon/features/notifications/components/notification.js

@@ -4,8 +4,6 @@ import StatusContainer from '../../../containers/status_container';
 import AccountContainer from '../../../containers/account_container';
 import { FormattedMessage } from 'react-intl';
 import Permalink from '../../../components/permalink';
-import emojify from '../../../emoji';
-import escapeTextContentForBrowser from 'escape-html';
 import ImmutablePureComponent from 'react-immutable-pure-component';
 
 export default class Notification extends ImmutablePureComponent {
@@ -67,9 +65,8 @@ export default class Notification extends ImmutablePureComponent {
   render () {
     const { notification } = this.props;
     const account          = notification.get('account');
-    const displayName      = account.get('display_name').length > 0 ? account.get('display_name') : account.get('username');
-    const displayNameHTML  = { __html: emojify(escapeTextContentForBrowser(displayName)) };
-    const link             = <Permalink className='notification__display-name' href={account.get('url')} title={account.get('acct')} to={`/accounts/${account.get('id')}`} dangerouslySetInnerHTML={displayNameHTML} />;
+    const displayNameHtml  = { __html: account.get('display_name_html') };
+    const link             = <Permalink className='notification__display-name' href={account.get('url')} title={account.get('acct')} to={`/accounts/${account.get('id')}`} dangerouslySetInnerHTML={displayNameHtml} />;
 
     switch(notification.get('type')) {
     case 'follow':

+ 1 - 2
app/javascript/mastodon/features/report/components/status_check_box.js

@@ -1,7 +1,6 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 import ImmutablePropTypes from 'react-immutable-proptypes';
-import emojify from '../../../emoji';
 import Toggle from 'react-toggle';
 
 export default class StatusCheckBox extends React.PureComponent {
@@ -15,7 +14,7 @@ export default class StatusCheckBox extends React.PureComponent {
 
   render () {
     const { status, checked, onToggle, disabled } = this.props;
-    const content = { __html: emojify(status.get('content')) };
+    const content = { __html: status.get('contentHtml') };
 
     if (status.get('reblog')) {
       return null;

+ 4 - 0
app/javascript/mastodon/features/ui/index.js

@@ -12,6 +12,7 @@ import { debounce } from 'lodash';
 import { uploadCompose } from '../../actions/compose';
 import { refreshHomeTimeline } from '../../actions/timelines';
 import { refreshNotifications } from '../../actions/notifications';
+import { clearStatusesHeight } from '../../actions/statuses';
 import { WrappedSwitch, WrappedRoute } from './util/react_router_helpers';
 import UploadArea from './components/upload_area';
 import ColumnsAreaContainer from './containers/columns_area_container';
@@ -66,6 +67,9 @@ export default class UI extends React.PureComponent {
   };
 
   handleResize = debounce(() => {
+    // The cached heights are no longer accurate, invalidate
+    this.props.dispatch(clearStatusesHeight());
+
     this.setState({ width: window.innerWidth });
   }, 500, {
     trailing: true,

+ 6 - 0
app/javascript/mastodon/reducers/accounts.js

@@ -44,7 +44,9 @@ import {
   FAVOURITED_STATUSES_EXPAND_SUCCESS,
 } from '../actions/favourites';
 import { STORE_HYDRATE } from '../actions/store';
+import emojify from '../emoji';
 import { Map as ImmutableMap, fromJS } from 'immutable';
+import escapeTextContentForBrowser from 'escape-html';
 
 const normalizeAccount = (state, account) => {
   account = { ...account };
@@ -53,6 +55,10 @@ const normalizeAccount = (state, account) => {
   delete account.following_count;
   delete account.statuses_count;
 
+  const displayName = account.display_name.length === 0 ? account.username : account.display_name;
+  account.display_name_html = emojify(escapeTextContentForBrowser(displayName));
+  account.note_emojified = emojify(account.note);
+
   return state.set(account.id, fromJS(account));
 };
 

+ 25 - 1
app/javascript/mastodon/reducers/statuses.js

@@ -13,6 +13,8 @@ import {
   CONTEXT_FETCH_SUCCESS,
   STATUS_MUTE_SUCCESS,
   STATUS_UNMUTE_SUCCESS,
+  STATUS_SET_HEIGHT,
+  STATUSES_CLEAR_HEIGHT,
 } from '../actions/statuses';
 import {
   TIMELINE_REFRESH_SUCCESS,
@@ -33,7 +35,11 @@ import {
   FAVOURITED_STATUSES_EXPAND_SUCCESS,
 } from '../actions/favourites';
 import { SEARCH_FETCH_SUCCESS } from '../actions/search';
+import emojify from '../emoji';
 import { Map as ImmutableMap, fromJS } from 'immutable';
+import escapeTextContentForBrowser from 'escape-html';
+
+const domParser = new DOMParser();
 
 const normalizeStatus = (state, status) => {
   if (!status) {
@@ -49,7 +55,9 @@ const normalizeStatus = (state, status) => {
   }
 
   const searchContent = [status.spoiler_text, status.content].join(' ').replace(/<br \/>/g, '\n').replace(/<\/p><p>/g, '\n\n');
-  normalStatus.search_index = new DOMParser().parseFromString(searchContent, 'text/html').documentElement.textContent;
+  normalStatus.search_index = domParser.parseFromString(searchContent, 'text/html').documentElement.textContent;
+  normalStatus.contentHtml = emojify(normalStatus.content);
+  normalStatus.spoilerHtml = emojify(escapeTextContentForBrowser(normalStatus.spoiler_text || ''));
 
   return state.update(status.id, ImmutableMap(), map => map.mergeDeep(fromJS(normalStatus)));
 };
@@ -82,6 +90,18 @@ const filterStatuses = (state, relationship) => {
   return state;
 };
 
+const setHeight = (state, id, height) => {
+  return state.update(id, ImmutableMap(), map => map.set('height', height));
+};
+
+const clearHeights = (state) => {
+  state.forEach(status => {
+    state = state.deleteIn([status.get('id'), 'height']);
+  });
+
+  return state;
+};
+
 const initialState = ImmutableMap();
 
 export default function statuses(state = initialState, action) {
@@ -120,6 +140,10 @@ export default function statuses(state = initialState, action) {
     return deleteStatus(state, action.id, action.references);
   case ACCOUNT_BLOCK_SUCCESS:
     return filterStatuses(state, action.relationship);
+  case STATUS_SET_HEIGHT:
+    return setHeight(state, action.id, action.height);
+  case STATUSES_CLEAR_HEIGHT:
+    return clearHeights(state);
   default:
     return state;
   }

+ 0 - 1
app/javascript/styles/components.scss

@@ -1572,7 +1572,6 @@
   overflow-y: scroll;
   overflow-x: hidden;
   flex: 1 1 auto;
-  backface-visibility: hidden;
   -webkit-overflow-scrolling: touch;
   @supports(display: grid) { // hack to fix Chrome <57
     contain: strict;

+ 1 - 11
spec/javascript/components/display_name.test.js

@@ -9,19 +9,9 @@ describe('<DisplayName />', () => {
     const account = fromJS({
       username: 'bar',
       acct: 'bar@baz',
-      display_name: 'Foo',
+      display_name_html: '<p>Foo</p>',
     });
     const wrapper = render(<DisplayName account={account} />);
     expect(wrapper).to.have.text('Foo @bar@baz');
   });
-
-  it('renders the username + account name if display name is empty', () => {
-    const account = fromJS({
-      username: 'bar',
-      acct: 'bar@baz',
-      display_name: '',
-    });
-    const wrapper = render(<DisplayName account={account} />);
-    expect(wrapper).to.have.text('bar @bar@baz');
-  });
 });