From 8eb6d171e690e013eb2881478cfa1fd50b4ba705 Mon Sep 17 00:00:00 2001 From: Sorin Davidoi Date: Mon, 7 Aug 2017 20:32:03 +0200 Subject: [PATCH] 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 --- app/javascript/mastodon/actions/statuses.js | 17 ++++++++++++ .../mastodon/components/display_name.js | 7 ++--- app/javascript/mastodon/components/status.js | 26 +++++++++---------- .../mastodon/components/status_content.js | 6 ++--- .../mastodon/containers/status_container.js | 6 ++++- .../features/account/components/header.js | 13 +++------- .../compose/components/reply_indicator.js | 3 +-- .../components/account_authorize.js | 3 +-- .../notifications/components/notification.js | 7 ++--- .../report/components/status_check_box.js | 3 +-- app/javascript/mastodon/features/ui/index.js | 4 +++ app/javascript/mastodon/reducers/accounts.js | 6 +++++ app/javascript/mastodon/reducers/statuses.js | 26 ++++++++++++++++++- app/javascript/styles/components.scss | 1 - .../components/display_name.test.js | 12 +-------- 15 files changed, 83 insertions(+), 57 deletions(-) diff --git a/app/javascript/mastodon/actions/statuses.js b/app/javascript/mastodon/actions/statuses.js index 2204e0b14..0b5e72c17 100644 --- a/app/javascript/mastodon/actions/statuses.js +++ b/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, + }; +}; diff --git a/app/javascript/mastodon/components/display_name.js b/app/javascript/mastodon/components/display_name.js index dc3665a2b..2cf84f8f4 100644 --- a/app/javascript/mastodon/components/display_name.js +++ b/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 ( - @{this.props.account.get('acct')} + @{this.props.account.get('acct')} ); } diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js index 25879c497..38a4aafc1 100644 --- a/app/javascript/mastodon/components/status.js +++ b/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 ( -
+
{status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])} {status.get('content')}
@@ -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 (
- }} /> + }} />
diff --git a/app/javascript/mastodon/components/status_content.js b/app/javascript/mastodon/components/status_content.js index fdf7aa531..2069f971c 100644 --- a/app/javascript/mastodon/components/status_content.js +++ b/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, diff --git a/app/javascript/mastodon/containers/status_container.js b/app/javascript/mastodon/containers/status_container.js index 438ecfe43..b150165aa 100644 --- a/app/javascript/mastodon/containers/status_container.js +++ b/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)); diff --git a/app/javascript/mastodon/features/account/components/header.js b/app/javascript/mastodon/features/account/components/header.js index 40567d17c..8f4dd352d 100644 --- a/app/javascript/mastodon/features/account/components/header.js +++ b/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 = ; } @@ -125,15 +118,15 @@ export default class Header extends ImmutablePureComponent { lockedIcon = ; } - 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 (
- + @{account.get('acct')} {lockedIcon}
diff --git a/app/javascript/mastodon/features/compose/components/reply_indicator.js b/app/javascript/mastodon/features/compose/components/reply_indicator.js index 35a9b4b1b..7672440b4 100644 --- a/app/javascript/mastodon/features/compose/components/reply_indicator.js +++ b/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 (
diff --git a/app/javascript/mastodon/features/follow_requests/components/account_authorize.js b/app/javascript/mastodon/features/follow_requests/components/account_authorize.js index 66fa5c235..4fc5638d9 100644 --- a/app/javascript/mastodon/features/follow_requests/components/account_authorize.js +++ b/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 (
diff --git a/app/javascript/mastodon/features/notifications/components/notification.js b/app/javascript/mastodon/features/notifications/components/notification.js index 9d631644a..2992185fd 100644 --- a/app/javascript/mastodon/features/notifications/components/notification.js +++ b/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 = ; + const displayNameHtml = { __html: account.get('display_name_html') }; + const link = ; switch(notification.get('type')) { case 'follow': diff --git a/app/javascript/mastodon/features/report/components/status_check_box.js b/app/javascript/mastodon/features/report/components/status_check_box.js index 6a1a84c28..cc9232201 100644 --- a/app/javascript/mastodon/features/report/components/status_check_box.js +++ b/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; diff --git a/app/javascript/mastodon/features/ui/index.js b/app/javascript/mastodon/features/ui/index.js index b6307dd4c..a791f8947 100644 --- a/app/javascript/mastodon/features/ui/index.js +++ b/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, diff --git a/app/javascript/mastodon/reducers/accounts.js b/app/javascript/mastodon/reducers/accounts.js index 4d7c3adc9..6442d13be 100644 --- a/app/javascript/mastodon/reducers/accounts.js +++ b/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)); }; diff --git a/app/javascript/mastodon/reducers/statuses.js b/app/javascript/mastodon/reducers/statuses.js index b1b1d0988..3e40b0b42 100644 --- a/app/javascript/mastodon/reducers/statuses.js +++ b/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(/
/g, '\n').replace(/<\/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; } diff --git a/app/javascript/styles/components.scss b/app/javascript/styles/components.scss index 9174f5112..77b06b2d0 100644 --- a/app/javascript/styles/components.scss +++ b/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; diff --git a/spec/javascript/components/display_name.test.js b/spec/javascript/components/display_name.test.js index ad9288d4d..ab484cf3e 100644 --- a/spec/javascript/components/display_name.test.js +++ b/spec/javascript/components/display_name.test.js @@ -9,19 +9,9 @@ describe('', () => { const account = fromJS({ username: 'bar', acct: 'bar@baz', - display_name: 'Foo', + display_name_html: '

Foo

', }); const wrapper = render(); 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(); - expect(wrapper).to.have.text('bar @bar@baz'); - }); });