* 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 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)); | |||
} | |||
} |