Lazy load components (#3879)
* feat: Lazy-load routes * feat: Lazy-load modals * feat: Lazy-load columns * refactor: Simplify Bundle API * feat: Optimize bundles * feat: Prevent flashing the waiting state * feat: Preload commonly used bundles * feat: Lazy load Compose reducers * feat: Lazy load Notifications reducer * refactor: Move all dynamic imports into one file * fix: Minor bugs * fix: Manually hydrate the lazy-loaded reducers * refactor: Move all dynamic imports to async-components * fix: Loading modal style * refactor: Avoid converting the raw state for each lazy hydration * refactor: Remove unused component * refactor: Maintain modal name * fix: Add as=script to preload link * chore: Fix lint error * fix(components/bundle): Check if timestamp is set when computing elapsed * fix: Load compose reducers for the onboarding modalmaster
parent
00df69bc89
commit
348d6f5e75
@ -0,0 +1,25 @@ |
||||
export const BUNDLE_FETCH_REQUEST = 'BUNDLE_FETCH_REQUEST'; |
||||
export const BUNDLE_FETCH_SUCCESS = 'BUNDLE_FETCH_SUCCESS'; |
||||
export const BUNDLE_FETCH_FAIL = 'BUNDLE_FETCH_FAIL'; |
||||
|
||||
export function fetchBundleRequest(skipLoading) { |
||||
return { |
||||
type: BUNDLE_FETCH_REQUEST, |
||||
skipLoading, |
||||
}; |
||||
} |
||||
|
||||
export function fetchBundleSuccess(skipLoading) { |
||||
return { |
||||
type: BUNDLE_FETCH_SUCCESS, |
||||
skipLoading, |
||||
}; |
||||
} |
||||
|
||||
export function fetchBundleFail(error, skipLoading) { |
||||
return { |
||||
type: BUNDLE_FETCH_FAIL, |
||||
error, |
||||
skipLoading, |
||||
}; |
||||
} |
@ -0,0 +1,96 @@ |
||||
import React from 'react'; |
||||
import PropTypes from 'prop-types'; |
||||
|
||||
const emptyComponent = () => null; |
||||
const noop = () => { }; |
||||
|
||||
class Bundle extends React.Component { |
||||
|
||||
static propTypes = { |
||||
fetchComponent: PropTypes.func.isRequired, |
||||
loading: PropTypes.func, |
||||
error: PropTypes.func, |
||||
children: PropTypes.func.isRequired, |
||||
renderDelay: PropTypes.number, |
||||
onRender: PropTypes.func, |
||||
onFetch: PropTypes.func, |
||||
onFetchSuccess: PropTypes.func, |
||||
onFetchFail: PropTypes.func, |
||||
} |
||||
|
||||
static defaultProps = { |
||||
loading: emptyComponent, |
||||
error: emptyComponent, |
||||
renderDelay: 0, |
||||
onRender: noop, |
||||
onFetch: noop, |
||||
onFetchSuccess: noop, |
||||
onFetchFail: noop, |
||||
} |
||||
|
||||
state = { |
||||
mod: undefined, |
||||
forceRender: false, |
||||
} |
||||
|
||||
componentWillMount() { |
||||
this.load(this.props); |
||||
} |
||||
|
||||
componentWillReceiveProps(nextProps) { |
||||
if (nextProps.fetchComponent !== this.props.fetchComponent) { |
||||
this.load(nextProps); |
||||
} |
||||
} |
||||
|
||||
componentDidUpdate () { |
||||
this.props.onRender(); |
||||
} |
||||
|
||||
componentWillUnmount () { |
||||
if (this.timeout) { |
||||
clearTimeout(this.timeout); |
||||
} |
||||
} |
||||
|
||||
load = (props) => { |
||||
const { fetchComponent, onFetch, onFetchSuccess, onFetchFail, renderDelay } = props || this.props; |
||||
|
||||
this.setState({ mod: undefined }); |
||||
onFetch(); |
||||
|
||||
if (renderDelay !== 0) { |
||||
this.timestamp = new Date(); |
||||
this.timeout = setTimeout(() => this.setState({ forceRender: true }), renderDelay); |
||||
} |
||||
|
||||
return fetchComponent() |
||||
.then((mod) => { |
||||
this.setState({ mod: mod.default }); |
||||
onFetchSuccess(); |
||||
}) |
||||
.catch((error) => { |
||||
this.setState({ mod: null }); |
||||
onFetchFail(error); |
||||
}); |
||||
} |
||||
|
||||
render() { |
||||
const { loading: Loading, error: Error, children, renderDelay } = this.props; |
||||
const { mod, forceRender } = this.state; |
||||
const elapsed = this.timestamp ? (new Date() - this.timestamp) : renderDelay; |
||||
|
||||
if (mod === undefined) { |
||||
return (elapsed >= renderDelay || forceRender) ? <Loading /> : null; |
||||
} |
||||
|
||||
if (mod === null) { |
||||
return <Error onRetry={this.load} />; |
||||
} |
||||
|
||||
return children(mod); |
||||
} |
||||
|
||||
} |
||||
|
||||
export default Bundle; |
@ -0,0 +1,44 @@ |
||||
import React from 'react'; |
||||
import PropTypes from 'prop-types'; |
||||
import { defineMessages, injectIntl } from 'react-intl'; |
||||
|
||||
import Column from './column'; |
||||
import ColumnHeader from './column_header'; |
||||
import ColumnBackButtonSlim from '../../../components/column_back_button_slim'; |
||||
import IconButton from '../../../components/icon_button'; |
||||
|
||||
const messages = defineMessages({ |
||||
title: { id: 'bundle_column_error.title', defaultMessage: 'Network error' }, |
||||
body: { id: 'bundle_column_error.body', defaultMessage: 'Something went wrong while loading this component.' }, |
||||
retry: { id: 'bundle_column_error.retry', defaultMessage: 'Try again' }, |
||||
}); |
||||
|
||||
class BundleColumnError extends React.Component { |
||||
|
||||
static propTypes = { |
||||
onRetry: PropTypes.func.isRequired, |
||||
intl: PropTypes.object.isRequired, |
||||
} |
||||
|
||||
handleRetry = () => { |
||||
this.props.onRetry(); |
||||
} |
||||
|
||||
render () { |
||||
const { intl: { formatMessage } } = this.props; |
||||
|
||||
return ( |
||||
<Column> |
||||
<ColumnHeader icon='exclamation-circle' type={formatMessage(messages.title)} /> |
||||
<ColumnBackButtonSlim /> |
||||
<div className='error-column'> |
||||
<IconButton title={formatMessage(messages.retry)} icon='refresh' onClick={this.handleRetry} size={64} /> |
||||
{formatMessage(messages.body)} |
||||
</div> |
||||
</Column> |
||||
); |
||||
} |
||||
|
||||
} |
||||
|
||||
export default injectIntl(BundleColumnError); |
@ -0,0 +1,53 @@ |
||||
import React from 'react'; |
||||
import PropTypes from 'prop-types'; |
||||
import { defineMessages, injectIntl } from 'react-intl'; |
||||
|
||||
import IconButton from '../../../components/icon_button'; |
||||
|
||||
const messages = defineMessages({ |
||||
error: { id: 'bundle_modal_error.message', defaultMessage: 'Something went wrong while loading this component.' }, |
||||
retry: { id: 'bundle_modal_error.retry', defaultMessage: 'Try again' }, |
||||
close: { id: 'bundle_modal_error.close', defaultMessage: 'Close' }, |
||||
}); |
||||
|
||||
class BundleModalError extends React.Component { |
||||
|
||||
static propTypes = { |
||||
onRetry: PropTypes.func.isRequired, |
||||
onClose: PropTypes.func.isRequired, |
||||
intl: PropTypes.object.isRequired, |
||||
} |
||||
|
||||
handleRetry = () => { |
||||
this.props.onRetry(); |
||||
} |
||||
|
||||
render () { |
||||
const { onClose, intl: { formatMessage } } = this.props; |
||||
|
||||
// Keep the markup in sync with <ModalLoading />
|
||||
// (make sure they have the same dimensions)
|
||||
return ( |
||||
<div className='modal-root__modal error-modal'> |
||||
<div className='error-modal__body'> |
||||
<IconButton title={formatMessage(messages.retry)} icon='refresh' onClick={this.handleRetry} size={64} /> |
||||
{formatMessage(messages.error)} |
||||
</div> |
||||
|
||||
<div className='error-modal__footer'> |
||||
<div> |
||||
<button |
||||
onClick={onClose} |
||||
className='error-modal__nav onboarding-modal__skip' |
||||
> |
||||
{formatMessage(messages.close)} |
||||
</button> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
} |
||||
|
||||
export default injectIntl(BundleModalError); |
@ -0,0 +1,13 @@ |
||||
import React from 'react'; |
||||
|
||||
import Column from '../../../components/column'; |
||||
import ColumnHeader from '../../../components/column_header'; |
||||
|
||||
const ColumnLoading = () => ( |
||||
<Column> |
||||
<ColumnHeader icon=' ' title='' multiColumn={false} /> |
||||
<div className='scrollable' /> |
||||
</Column> |
||||
); |
||||
|
||||
export default ColumnLoading; |
@ -0,0 +1,20 @@ |
||||
import React from 'react'; |
||||
|
||||
import LoadingIndicator from '../../../components/loading_indicator'; |
||||
|
||||
// Keep the markup in sync with <BundleModalError />
|
||||
// (make sure they have the same dimensions)
|
||||
const ModalLoading = () => ( |
||||
<div className='modal-root__modal error-modal'> |
||||
<div className='error-modal__body'> |
||||
<LoadingIndicator /> |
||||
</div> |
||||
<div className='error-modal__footer'> |
||||
<div> |
||||
<button className='error-modal__nav onboarding-modal__skip' /> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
); |
||||
|
||||
export default ModalLoading; |
@ -0,0 +1,19 @@ |
||||
import { connect } from 'react-redux'; |
||||
|
||||
import Bundle from '../components/bundle'; |
||||
|
||||
import { fetchBundleRequest, fetchBundleSuccess, fetchBundleFail } from '../../../actions/bundles'; |
||||
|
||||
const mapDispatchToProps = dispatch => ({ |
||||
onFetch () { |
||||
dispatch(fetchBundleRequest()); |
||||
}, |
||||
onFetchSuccess () { |
||||
dispatch(fetchBundleSuccess()); |
||||
}, |
||||
onFetchFail (error) { |
||||
dispatch(fetchBundleFail(error)); |
||||
}, |
||||
}); |
||||
|
||||
export default connect(null, mapDispatchToProps)(Bundle); |
@ -0,0 +1,143 @@ |
||||
import { store } from '../../../containers/mastodon'; |
||||
import { injectAsyncReducer } from '../../../store/configureStore'; |
||||
|
||||
// NOTE: When lazy-loading reducers, make sure to add them
|
||||
// to application.html.haml (if the component is preloaded there)
|
||||
|
||||
export function EmojiPicker () { |
||||
return import(/* webpackChunkName: "emojione_picker" */'emojione-picker'); |
||||
} |
||||
|
||||
export function Compose () { |
||||
return Promise.all([ |
||||
import(/* webpackChunkName: "features/compose" */'../../compose'), |
||||
import(/* webpackChunkName: "reducers/compose" */'../../../reducers/compose'), |
||||
import(/* webpackChunkName: "reducers/media_attachments" */'../../../reducers/media_attachments'), |
||||
import(/* webpackChunkName: "reducers/search" */'../../../reducers/search'), |
||||
]).then(([component, composeReducer, mediaAttachmentsReducer, searchReducer]) => { |
||||
injectAsyncReducer(store, 'compose', composeReducer.default); |
||||
injectAsyncReducer(store, 'media_attachments', mediaAttachmentsReducer.default); |
||||
injectAsyncReducer(store, 'search', searchReducer.default); |
||||
|
||||
return component; |
||||
}); |
||||
} |
||||
|
||||
export function Notifications () { |
||||
return Promise.all([ |
||||
import(/* webpackChunkName: "features/notifications" */'../../notifications'), |
||||
import(/* webpackChunkName: "reducers/notifications" */'../../../reducers/notifications'), |
||||
]).then(([component, notificationsReducer]) => { |
||||
injectAsyncReducer(store, 'notifications', notificationsReducer.default); |
||||
|
||||
return component; |
||||
}); |
||||
} |
||||
|
||||
export function HomeTimeline () { |
||||
return import(/* webpackChunkName: "features/home_timeline" */'../../home_timeline'); |
||||
} |
||||
|
||||
export function PublicTimeline () { |
||||
return import(/* webpackChunkName: "features/public_timeline" */'../../public_timeline'); |
||||
} |
||||
|
||||
export function CommunityTimeline () { |
||||
return import(/* webpackChunkName: "features/community_timeline" */'../../community_timeline'); |
||||
} |
||||
|
||||
export function HashtagTimeline () { |
||||
return import(/* webpackChunkName: "features/hashtag_timeline" */'../../hashtag_timeline'); |
||||
} |
||||
|
||||
export function Status () { |
||||
return import(/* webpackChunkName: "features/status" */'../../status'); |
||||
} |
||||
|
||||
export function GettingStarted () { |
||||
return import(/* webpackChunkName: "features/getting_started" */'../../getting_started'); |
||||
} |
||||
|
||||
export function AccountTimeline () { |
||||
return import(/* webpackChunkName: "features/account_timeline" */'../../account_timeline'); |
||||
} |
||||
|
||||
export function AccountGallery () { |
||||
return import(/* webpackChunkName: "features/account_gallery" */'../../account_gallery'); |
||||
} |
||||
|
||||
export function Followers () { |
||||
return import(/* webpackChunkName: "features/followers" */'../../followers'); |
||||
} |
||||
|
||||
export function Following () { |
||||
return import(/* webpackChunkName: "features/following" */'../../following'); |
||||
} |
||||
|
||||
export function Reblogs () { |
||||
return import(/* webpackChunkName: "features/reblogs" */'../../reblogs'); |
||||
} |
||||
|
||||
export function Favourites () { |
||||
return import(/* webpackChunkName: "features/favourites" */'../../favourites'); |
||||
} |
||||
|
||||
export function FollowRequests () { |
||||
return import(/* webpackChunkName: "features/follow_requests" */'../../follow_requests'); |
||||
} |
||||
|
||||
export function GenericNotFound () { |
||||
return import(/* webpackChunkName: "features/generic_not_found" */'../../generic_not_found'); |
||||
} |
||||
|
||||
export function FavouritedStatuses () { |
||||
return import(/* webpackChunkName: "features/favourited_statuses" */'../../favourited_statuses'); |
||||
} |
||||
|
||||
export function Blocks () { |
||||
return import(/* webpackChunkName: "features/blocks" */'../../blocks'); |
||||
} |
||||
|
||||
export function Mutes () { |
||||
return import(/* webpackChunkName: "features/mutes" */'../../mutes'); |
||||
} |
||||
|
||||
export function MediaModal () { |
||||
return import(/* webpackChunkName: "modals/media_modal" */'../components/media_modal'); |
||||
} |
||||
|
||||
export function OnboardingModal () { |
||||
return Promise.all([ |
||||
import(/* webpackChunkName: "modals/onboarding_modal" */'../components/onboarding_modal'), |
||||
import(/* webpackChunkName: "reducers/compose" */'../../../reducers/compose'), |
||||
import(/* webpackChunkName: "reducers/media_attachments" */'../../../reducers/media_attachments'), |
||||
]).then(([component, composeReducer, mediaAttachmentsReducer]) => { |
||||
injectAsyncReducer(store, 'compose', composeReducer.default); |
||||
injectAsyncReducer(store, 'media_attachments', mediaAttachmentsReducer.default); |
||||
return component; |
||||
}); |
||||
} |
||||
|
||||
export function VideoModal () { |
||||
return import(/* webpackChunkName: "modals/video_modal" */'../components/video_modal'); |
||||
} |
||||
|
||||
export function BoostModal () { |
||||
return import(/* webpackChunkName: "modals/boost_modal" */'../components/boost_modal'); |
||||
} |
||||
|
||||
export function ConfirmationModal () { |
||||
return import(/* webpackChunkName: "modals/confirmation_modal" */'../components/confirmation_modal'); |
||||
} |
||||
|
||||
export function ReportModal () { |
||||
return import(/* webpackChunkName: "modals/report_modal" */'../components/report_modal'); |
||||
} |
||||
|
||||
export function MediaGallery () { |
||||
return import(/* webpackChunkName: "status/MediaGallery" */'../../../components/media_gallery'); |
||||
} |
||||
|
||||
export function VideoPlayer () { |
||||
return import(/* webpackChunkName: "status/VideoPlayer" */'../../../components/video_player'); |
||||
} |
@ -0,0 +1,65 @@ |
||||
import React from 'react'; |
||||
import PropTypes from 'prop-types'; |
||||
import Switch from 'react-router-dom/Switch'; |
||||
import Route from 'react-router-dom/Route'; |
||||
|
||||
import ColumnLoading from '../components/column_loading'; |
||||
import BundleColumnError from '../components/bundle_column_error'; |
||||
import BundleContainer from '../containers/bundle_container'; |
||||
|
||||
// Small wrapper to pass multiColumn to the route components
|
||||
export const WrappedSwitch = ({ multiColumn, children }) => ( |
||||
<Switch> |
||||
{React.Children.map(children, child => React.cloneElement(child, { multiColumn }))} |
||||
</Switch> |
||||
); |
||||
|
||||
WrappedSwitch.propTypes = { |
||||
multiColumn: PropTypes.bool, |
||||
children: PropTypes.node, |
||||
}; |
||||
|
||||
// Small Wraper to extract the params from the route and pass
|
||||
// them to the rendered component, together with the content to
|
||||
// be rendered inside (the children)
|
||||
export class WrappedRoute extends React.Component { |
||||
|
||||
static propTypes = { |
||||
component: PropTypes.func.isRequired, |
||||
content: PropTypes.node, |
||||
multiColumn: PropTypes.bool, |
||||
} |
||||
|
||||
renderComponent = ({ match }) => { |
||||
this.match = match; // Needed for this.renderBundle
|
||||
|
||||
const { component } = this.props; |
||||
|
||||
return ( |
||||
<BundleContainer fetchComponent={component} loading={this.renderLoading} error={this.renderError}> |
||||
{this.renderBundle} |
||||
</BundleContainer> |
||||
); |
||||
} |
||||
|
||||
renderLoading = () => { |
||||
return <ColumnLoading />; |
||||
} |
||||
|
||||
renderError = (props) => { |
||||
return <BundleColumnError {...props} />; |
||||
} |
||||
|
||||
renderBundle = (Component) => { |
||||
const { match: { params }, props: { content, multiColumn } } = this; |
||||
|
||||
return <Component params={params} multiColumn={multiColumn}>{content}</Component>; |
||||
} |
||||
|
||||
render () { |
||||
const { component: Component, content, ...rest } = this.props; |
||||
|
||||
return <Route {...rest} render={this.renderComponent} />; |
||||
} |
||||
|
||||
} |
@ -1,15 +1,36 @@ |
||||
import { createStore, applyMiddleware, compose } from 'redux'; |
||||
import thunk from 'redux-thunk'; |
||||
import appReducer from '../reducers'; |
||||
import appReducer, { createReducer } from '../reducers'; |
||||
import { hydrateStoreLazy } from '../actions/store'; |
||||
import { hydrateAction } from '../containers/mastodon'; |
||||
import loadingBarMiddleware from '../middleware/loading_bar'; |
||||
import errorsMiddleware from '../middleware/errors'; |
||||
import soundsMiddleware from '../middleware/sounds'; |
||||
|
||||
export default function configureStore() { |
||||
return createStore(appReducer, compose(applyMiddleware( |
||||
const store = createStore(appReducer, compose(applyMiddleware( |
||||
thunk, |
||||
loadingBarMiddleware({ promiseTypeSuffixes: ['REQUEST', 'SUCCESS', 'FAIL'] }), |
||||
errorsMiddleware(), |
||||
soundsMiddleware() |
||||
), window.devToolsExtension ? window.devToolsExtension() : f => f)); |
||||
|
||||
store.asyncReducers = { }; |
||||
|
||||
return store; |
||||
}; |
||||
|
||||
export function injectAsyncReducer(store, name, asyncReducer) { |
||||
if (!store.asyncReducers[name]) { |
||||
// Keep track that we injected this reducer
|
||||
store.asyncReducers[name] = asyncReducer; |
||||
|
||||
// Add the current reducer to the store
|
||||
store.replaceReducer(createReducer(store.asyncReducers)); |
||||
|
||||
// The state this reducer handles defaults to its initial state (stored inside the reducer)
|
||||
// But that state may be out of date because of the server-side hydration, so we replay
|
||||
// the hydration action but only for this reducer (all async reducers must listen for this dynamic action)
|
||||
store.dispatch(hydrateStoreLazy(name, hydrateAction.state)); |
||||
} |
||||
} |
||||
|
Loading…
Reference in new issue