@@ -32,16 +32,16 @@ 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, | |||
intersectionObserverWrapper: PropTypes.object, | |||
}; | |||
state = { | |||
isHidden: false, | |||
isIntersecting: true, // assume intersecting until told otherwise | |||
isHidden: false, // set to true in requestIdleCallback to trigger un-render | |||
} | |||
// Avoid checking props that are functions (and whose equality will always | |||
@@ -59,12 +59,12 @@ class Status extends ImmutablePureComponent { | |||
updateOnStates = [] | |||
shouldComponentUpdate (nextProps, nextState) { | |||
if (nextProps.isIntersecting === false && nextState.isHidden) { | |||
if (!nextState.isIntersecting && nextState.isHidden) { | |||
// It's only if we're not intersecting (i.e. offscreen) and isHidden is true | |||
// that either "isIntersecting" or "isHidden" matter, and then they're | |||
// the only things that matter. | |||
return this.props.isIntersecting !== false || !this.state.isHidden; | |||
} else if (nextProps.isIntersecting !== false && this.props.isIntersecting === false) { | |||
return this.state.isIntersecting || !this.state.isHidden; | |||
} else if (nextState.isIntersecting && !this.state.isIntersecting) { | |||
// If we're going from a non-intersecting state to an intersecting state, | |||
// (i.e. offscreen to onscreen), then we definitely need to re-render | |||
return true; | |||
@@ -73,21 +73,47 @@ class Status extends ImmutablePureComponent { | |||
return super.shouldComponentUpdate(nextProps, nextState); | |||
} | |||
componentWillReceiveProps (nextProps) { | |||
if (nextProps.isIntersecting === false && this.props.isIntersecting !== false) { | |||
requestIdleCallback(() => this.setState({ isHidden: true })); | |||
} else { | |||
this.setState({ isHidden: !nextProps.isIntersecting }); | |||
componentDidMount () { | |||
if (!this.props.intersectionObserverWrapper) { | |||
// TODO: enable IntersectionObserver optimization for notification statuses. | |||
// These are managed in notifications/index.js rather than status_list.js | |||
return; | |||
} | |||
this.props.intersectionObserverWrapper.observe( | |||
this.props.id, | |||
this.node, | |||
this.handleIntersection | |||
); | |||
} | |||
handleRef = (node) => { | |||
if (this.props.onRef) { | |||
this.props.onRef(node); | |||
if (node && node.children.length !== 0) { | |||
this.height = node.clientHeight; | |||
handleIntersection = (entry) => { | |||
// Edge 15 doesn't support isIntersecting, but we can infer it from intersectionRatio | |||
// https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/12156111/ | |||
const isIntersecting = entry.intersectionRatio > 0; | |||
this.setState((prevState) => { | |||
if (prevState.isIntersecting && !isIntersecting) { | |||
requestIdleCallback(this.hideIfNotIntersecting); | |||
} | |||
return { | |||
isIntersecting: isIntersecting, | |||
isHidden: false, | |||
}; | |||
}); | |||
} | |||
hideIfNotIntersecting = () => { | |||
// When the browser gets a chance, test if we're still not intersecting, | |||
// and if so, set our isHidden to true to trigger an unrender. The point of | |||
// this is to save DOM nodes and avoid using up too much memory. | |||
// See: https://github.com/tootsuite/mastodon/issues/2900 | |||
this.setState((prevState) => ({ isHidden: !prevState.isIntersecting })); | |||
} | |||
handleRef = (node) => { | |||
this.node = node; | |||
if (node && node.children.length !== 0) { | |||
this.height = node.clientHeight; | |||
} | |||
} | |||
@@ -107,14 +133,14 @@ class Status extends ImmutablePureComponent { | |||
render () { | |||
let media = null; | |||
let statusAvatar; | |||
const { status, account, isIntersecting, onRef, ...other } = this.props; | |||
const { isHidden } = this.state; | |||
const { status, account, ...other } = this.props; | |||
const { isIntersecting, isHidden } = this.state; | |||
if (status === null) { | |||
return null; | |||
} | |||
if (isIntersecting === false && isHidden) { | |||
if (!isIntersecting && isHidden) { | |||
return ( | |||
<div ref={this.handleRef} data-id={status.get('id')} style={{ height: `${this.height}px`, opacity: 0, overflow: 'hidden' }}> | |||
{status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])} | |||
@@ -5,6 +5,7 @@ import PropTypes from 'prop-types'; | |||
import StatusContainer from '../containers/status_container'; | |||
import LoadMore from './load_more'; | |||
import ImmutablePureComponent from 'react-immutable-pure-component'; | |||
import IntersectionObserverWrapper from '../features/ui/util/intersection_observer_wrapper'; | |||
class StatusList extends ImmutablePureComponent { | |||
@@ -26,12 +27,7 @@ class StatusList extends ImmutablePureComponent { | |||
trackScroll: true, | |||
}; | |||
state = { | |||
isIntersecting: {}, | |||
intersectionCount: 0, | |||
} | |||
statusRefQueue = [] | |||
intersectionObserverWrapper = new IntersectionObserverWrapper(); | |||
handleScroll = (e) => { | |||
const { scrollTop, scrollHeight, clientHeight } = e.target; | |||
@@ -64,53 +60,14 @@ class StatusList extends ImmutablePureComponent { | |||
} | |||
attachIntersectionObserver () { | |||
const onIntersection = (entries) => { | |||
this.setState(state => { | |||
entries.forEach(entry => { | |||
const statusId = entry.target.getAttribute('data-id'); | |||
// Edge 15 doesn't support isIntersecting, but we can infer it from intersectionRatio | |||
// https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/12156111/ | |||
state.isIntersecting[statusId] = entry.intersectionRatio > 0; | |||
}); | |||
// isIntersecting is a map of DOM data-id's to booleans (true for | |||
// intersecting, false for non-intersecting). | |||
// | |||
// We always want to return true in shouldComponentUpdate() if | |||
// this object changes, because onIntersection() is only called if | |||
// something has changed. | |||
// | |||
// Now, we *could* use an immutable map or some other structure to | |||
// diff the full map, but that would be pointless because the browser | |||
// has already informed us that something has changed. So we can just | |||
// use a regular object, which will be diffed by ImmutablePureComponent | |||
// based on reference equality (i.e. it's always "unchanged") and | |||
// then we just increment intersectionCount to force a change. | |||
return { | |||
isIntersecting: state.isIntersecting, | |||
intersectionCount: state.intersectionCount + 1, | |||
}; | |||
}); | |||
}; | |||
const options = { | |||
this.intersectionObserverWrapper.connect({ | |||
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(); | |||
this.intersectionObserverWrapper.disconnect(); | |||
} | |||
attachScrollListener () { | |||
@@ -125,15 +82,6 @@ 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(); | |||
@@ -141,7 +89,6 @@ class StatusList extends ImmutablePureComponent { | |||
render () { | |||
const { statusIds, onScrollToBottom, scrollKey, shouldUpdateScroll, isLoading, isUnread, hasMore, prepend, emptyMessage } = this.props; | |||
const { isIntersecting } = this.state; | |||
let loadMore = null; | |||
let scrollableArea = null; | |||
@@ -164,7 +111,7 @@ class StatusList extends ImmutablePureComponent { | |||
{prepend} | |||
{statusIds.map((statusId) => { | |||
return <StatusContainer key={statusId} id={statusId} isIntersecting={isIntersecting[statusId]} onRef={this.handleStatusRef} />; | |||
return <StatusContainer key={statusId} id={statusId} intersectionObserverWrapper={this.intersectionObserverWrapper} />; | |||
})} | |||
{loadMore} | |||
@@ -0,0 +1,48 @@ | |||
// Wrapper for IntersectionObserver in order to make working with it | |||
// a bit easier. We also follow this performance advice: | |||
// "If you need to observe multiple elements, it is both possible and | |||
// advised to observe multiple elements using the same IntersectionObserver | |||
// instance by calling observe() multiple times." | |||
// https://developers.google.com/web/updates/2016/04/intersectionobserver | |||
class IntersectionObserverWrapper { | |||
callbacks = {}; | |||
observerBacklog = []; | |||
observer = null; | |||
connect (options) { | |||
const onIntersection = (entries) => { | |||
entries.forEach(entry => { | |||
const id = entry.target.getAttribute('data-id'); | |||
if (this.callbacks[id]) { | |||
this.callbacks[id](entry); | |||
} | |||
}); | |||
}; | |||
this.observer = new IntersectionObserver(onIntersection, options); | |||
this.observerBacklog.forEach(([ id, node, callback ]) => { | |||
this.observe(id, node, callback); | |||
}); | |||
this.observerBacklog = null; | |||
} | |||
observe (id, node, callback) { | |||
if (!this.observer) { | |||
this.observerBacklog.push([ id, node, callback ]); | |||
} else { | |||
this.callbacks[id] = callback; | |||
this.observer.observe(node); | |||
} | |||
} | |||
disconnect () { | |||
if (this.observer) { | |||
this.observer.disconnect(); | |||
} | |||
} | |||
} | |||
export default IntersectionObserverWrapper; |