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
This commit is contained in:
		
				
					committed by
					
						 Eugen Rochko
						Eugen Rochko
					
				
			
			
				
	
			
			
			
						parent
						
							5942347407
						
					
				
				
					commit
					8eb6d171e6
				
			| @@ -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, | ||||
|   }; | ||||
| }; | ||||
|   | ||||
| @@ -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> | ||||
|     ); | ||||
|   } | ||||
|   | ||||
| @@ -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')} /> | ||||
|   | ||||
| @@ -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, | ||||
|   | ||||
| @@ -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)); | ||||
|   | ||||
| @@ -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} /> | ||||
|  | ||||
|   | ||||
| @@ -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'> | ||||
|   | ||||
| @@ -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'> | ||||
|   | ||||
| @@ -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,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; | ||||
|   | ||||
| @@ -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, | ||||
|   | ||||
| @@ -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)); | ||||
| }; | ||||
|  | ||||
|   | ||||
| @@ -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; | ||||
|   } | ||||
|   | ||||
| @@ -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; | ||||
|   | ||||
| @@ -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'); | ||||
|   }); | ||||
| }); | ||||
|   | ||||
		Reference in New Issue
	
	Block a user