* 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 modalpull/4/head
@ -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 { createStore, applyMiddleware, compose } from 'redux'; | ||||
import thunk from 'redux-thunk'; | 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 loadingBarMiddleware from '../middleware/loading_bar'; | ||||
import errorsMiddleware from '../middleware/errors'; | import errorsMiddleware from '../middleware/errors'; | ||||
import soundsMiddleware from '../middleware/sounds'; | import soundsMiddleware from '../middleware/sounds'; | ||||
export default function configureStore() { | export default function configureStore() { | ||||
return createStore(appReducer, compose(applyMiddleware( | |||||
const store = createStore(appReducer, compose(applyMiddleware( | |||||
thunk, | thunk, | ||||
loadingBarMiddleware({ promiseTypeSuffixes: ['REQUEST', 'SUCCESS', 'FAIL'] }), | loadingBarMiddleware({ promiseTypeSuffixes: ['REQUEST', 'SUCCESS', 'FAIL'] }), | ||||
errorsMiddleware(), | errorsMiddleware(), | ||||
soundsMiddleware() | soundsMiddleware() | ||||
), window.devToolsExtension ? window.devToolsExtension() : f => f)); | ), 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)); | |||||
} | |||||
} |