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
09f5091102
commit
5efcea69d2
8 changed files with 146 additions and 13 deletions
|
@ -32,12 +32,44 @@ class Status extends ImmutablePureComponent {
|
||||||
onOpenMedia: PropTypes.func,
|
onOpenMedia: PropTypes.func,
|
||||||
onOpenVideo: PropTypes.func,
|
onOpenVideo: PropTypes.func,
|
||||||
onBlock: PropTypes.func,
|
onBlock: PropTypes.func,
|
||||||
|
onRef: PropTypes.func,
|
||||||
|
isIntersecting: PropTypes.bool,
|
||||||
me: PropTypes.number,
|
me: PropTypes.number,
|
||||||
boostModal: PropTypes.bool,
|
boostModal: PropTypes.bool,
|
||||||
autoPlayGif: PropTypes.bool,
|
autoPlayGif: PropTypes.bool,
|
||||||
muted: 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 = () => {
|
handleClick = () => {
|
||||||
const { status } = this.props;
|
const { status } = this.props;
|
||||||
this.context.router.push(`/statuses/${status.getIn(['reblog', 'id'], status.get('id'))}`);
|
this.context.router.push(`/statuses/${status.getIn(['reblog', 'id'], status.get('id'))}`);
|
||||||
|
@ -52,12 +84,22 @@ class Status extends ImmutablePureComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
let media = '';
|
let media = null;
|
||||||
let statusAvatar;
|
let statusAvatar;
|
||||||
const { status, account, ...other } = this.props;
|
const { status, account, isIntersecting, onRef, ...other } = this.props;
|
||||||
|
const { isHidden } = this.state;
|
||||||
|
|
||||||
if (status === null) {
|
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') {
|
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)) };
|
const displayNameHTML = { __html: emojify(escapeTextContentForBrowser(displayName)) };
|
||||||
|
|
||||||
return (
|
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'>
|
||||||
<div className='status__prepend-icon-wrapper'><i className='fa fa-fw fa-retweet status__prepend-icon' /></div>
|
<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={displayNameHTML} /></a> }} />
|
||||||
|
@ -98,7 +140,7 @@ class Status extends ImmutablePureComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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'>
|
<div className='status__info'>
|
||||||
<a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} /></a>
|
<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,
|
trackScroll: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
state = {
|
||||||
|
isIntersecting: [{ }],
|
||||||
|
}
|
||||||
|
|
||||||
|
statusRefQueue = []
|
||||||
|
|
||||||
handleScroll = (e) => {
|
handleScroll = (e) => {
|
||||||
const { scrollTop, scrollHeight, clientHeight } = e.target;
|
const { scrollTop, scrollHeight, clientHeight } = e.target;
|
||||||
const offset = scrollHeight - scrollTop - clientHeight;
|
const offset = scrollHeight - scrollTop - clientHeight;
|
||||||
|
@ -42,6 +48,7 @@ class StatusList extends ImmutablePureComponent {
|
||||||
|
|
||||||
componentDidMount () {
|
componentDidMount () {
|
||||||
this.attachScrollListener();
|
this.attachScrollListener();
|
||||||
|
this.attachIntersectionObserver();
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidUpdate (prevProps) {
|
componentDidUpdate (prevProps) {
|
||||||
|
@ -52,6 +59,39 @@ class StatusList extends ImmutablePureComponent {
|
||||||
|
|
||||||
componentWillUnmount () {
|
componentWillUnmount () {
|
||||||
this.detachScrollListener();
|
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 () {
|
attachScrollListener () {
|
||||||
|
@ -66,6 +106,15 @@ class StatusList extends ImmutablePureComponent {
|
||||||
this.node = c;
|
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) => {
|
handleLoadMore = (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
this.props.onScrollToBottom();
|
this.props.onScrollToBottom();
|
||||||
|
@ -73,10 +122,11 @@ class StatusList extends ImmutablePureComponent {
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { statusIds, onScrollToBottom, scrollKey, shouldUpdateScroll, isLoading, isUnread, hasMore, prepend, emptyMessage } = this.props;
|
const { statusIds, onScrollToBottom, scrollKey, shouldUpdateScroll, isLoading, isUnread, hasMore, prepend, emptyMessage } = this.props;
|
||||||
|
const isIntersecting = this.state.isIntersecting[0];
|
||||||
|
|
||||||
let loadMore = '';
|
let loadMore = null;
|
||||||
let scrollableArea = '';
|
let scrollableArea = null;
|
||||||
let unread = '';
|
let unread = null;
|
||||||
|
|
||||||
if (!isLoading && statusIds.size > 0 && hasMore) {
|
if (!isLoading && statusIds.size > 0 && hasMore) {
|
||||||
loadMore = <LoadMore onClick={this.handleLoadMore} />;
|
loadMore = <LoadMore onClick={this.handleLoadMore} />;
|
||||||
|
@ -95,7 +145,7 @@ class StatusList extends ImmutablePureComponent {
|
||||||
{prepend}
|
{prepend}
|
||||||
|
|
||||||
{statusIds.map((statusId) => {
|
{statusIds.map((statusId) => {
|
||||||
return <StatusContainer key={statusId} id={statusId} />;
|
return <StatusContainer key={statusId} id={statusId} isIntersecting={isIntersecting[statusId]} onRef={this.handleStatusRef} />;
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{loadMore}
|
{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';
|
import main from '../mastodon/main';
|
||||||
|
|
||||||
if (!window.Intl || !Object.assign || !Number.isNaN ||
|
const needsBasePolyfills = !(
|
||||||
!window.Symbol || !Array.prototype.includes) {
|
window.Intl &&
|
||||||
// load polyfills dynamically
|
Object.assign &&
|
||||||
import('../mastodon/polyfills').then(main).catch(e => {
|
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
|
console.error(e); // eslint-disable-line no-console
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -554,6 +554,14 @@
|
||||||
border-bottom: 1px solid lighten($ui-base-color, 8%);
|
border-bottom: 1px solid lighten($ui-base-color, 8%);
|
||||||
cursor: default;
|
cursor: default;
|
||||||
|
|
||||||
|
@keyframes fade {
|
||||||
|
0% { opacity: 0; }
|
||||||
|
100% { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
opacity: 1;
|
||||||
|
animation: fade 0.3s linear;
|
||||||
|
|
||||||
&.status-direct {
|
&.status-direct {
|
||||||
background: lighten($ui-base-color, 8%);
|
background: lighten($ui-base-color, 8%);
|
||||||
|
|
||||||
|
|
|
@ -55,6 +55,7 @@
|
||||||
"glob": "^7.1.1",
|
"glob": "^7.1.1",
|
||||||
"http-link-header": "^0.8.0",
|
"http-link-header": "^0.8.0",
|
||||||
"immutable": "^3.8.1",
|
"immutable": "^3.8.1",
|
||||||
|
"intersection-observer": "^0.2.1",
|
||||||
"intl": "^1.2.5",
|
"intl": "^1.2.5",
|
||||||
"is-nan": "^1.2.1",
|
"is-nan": "^1.2.1",
|
||||||
"js-yaml": "^3.8.3",
|
"js-yaml": "^3.8.3",
|
||||||
|
@ -92,6 +93,7 @@
|
||||||
"redux": "^3.6.0",
|
"redux": "^3.6.0",
|
||||||
"redux-immutable": "^3.1.0",
|
"redux-immutable": "^3.1.0",
|
||||||
"redux-thunk": "^2.2.0",
|
"redux-thunk": "^2.2.0",
|
||||||
|
"requestidlecallback": "^0.3.0",
|
||||||
"reselect": "^2.5.4",
|
"reselect": "^2.5.4",
|
||||||
"rimraf": "^2.6.1",
|
"rimraf": "^2.6.1",
|
||||||
"sass-loader": "^6.0.3",
|
"sass-loader": "^6.0.3",
|
||||||
|
|
|
@ -3341,6 +3341,10 @@ interpret@^1.0.0:
|
||||||
version "1.0.1"
|
version "1.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/interpret/-/interpret-1.0.1.tgz#d579fb7f693b858004947af39fa0db49f795602c"
|
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:
|
intl:
|
||||||
version "1.2.5"
|
version "1.2.5"
|
||||||
resolved "https://registry.yarnpkg.com/intl/-/intl-1.2.5.tgz#82244a2190c4e419f8371f5aa34daa3420e2abde"
|
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"
|
tunnel-agent "~0.4.1"
|
||||||
uuid "^3.0.0"
|
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:
|
require-directory@^2.1.1:
|
||||||
version "2.1.1"
|
version "2.1.1"
|
||||||
resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42"
|
resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42"
|
||||||
|
|
Loading…
Reference in a new issue