src/index.js
'use strict';
const React = require('react');
const PropTypes = require('prop-types');
const hoistNonReactStatics = require('hoist-non-react-statics');
const getDisplayName = require('react-display-name').default;
const invariant = require('invariant');
const ALL_INITIALIZERS = [];
const READY_INITIALIZERS = [];
const TIMEOUT_ERR = '_t';
function isWebpackReady(getModuleIds) {
// eslint-disable-next-line no-undef
if (typeof __webpack_modules__ !== 'object') {
return false;
}
return getModuleIds().every(moduleId => {
return (
typeof moduleId !== 'undefined' &&
// eslint-disable-next-line no-undef
typeof __webpack_modules__[moduleId] !== 'undefined'
);
});
}
function retryLoader(resolve, reject, fn, retryOpts) {
if (retryOpts.hasResolved) {
return;
}
const invokeRetry = (err) => {
const backOff = retryOpts.backOff;
if (backOff.length) {
const wait = backOff.shift();
setTimeout(() => retryLoader(resolve, reject, fn, retryOpts), wait);
}
else if (err && !retryOpts.hasResolved) {
retryOpts.hasResolved = true;
reject(err);
}
};
let _timeout;
if (retryOpts.importTimeoutMs > 0) {
_timeout = setTimeout(
() => invokeRetry(retryOpts.throwOnImportError ? new Error(TIMEOUT_ERR) : null),
retryOpts.importTimeoutMs);
}
fn()
.then(res => {
clearTimeout(_timeout);
if (!retryOpts.hasResolved) {
retryOpts.hasResolved = true;
resolve(res)
}
})
.catch((err) => {
clearTimeout(_timeout);
invokeRetry(err);
});
}
function hasLoaded(state) {
return !state.loading && !state.error && !!state.loaded;
}
function load(loader, options) {
const promise = new Promise((resolve, reject) => {
retryLoader(resolve, reject, loader, {
backOff: options.retryBackOff.slice(),
importTimeoutMs: options.importTimeoutMs,
throwOnImportError: options.throwOnImportError,
hasResolved: false
});
});
const state = {
loading: true,
loaded: null,
error: null,
};
state.promise = promise.then(loaded => {
state.loading = false;
state.loaded = loaded;
return loaded;
}).catch(err => {
state.loading = false;
state.error = err;
throw err;
});
return state;
}
function loadMap(obj, options) {
let state = {
loading: false,
loaded: {},
error: null
};
let promises = [];
try {
Object.keys(obj).forEach(key => {
let result = load(obj[key], options);
if (!result.loading) {
state.loaded[key] = result.loaded;
state.error = result.error;
} else {
state.loading = true;
}
promises.push(result.promise);
result.promise.then(res => {
state.loaded[key] = res;
}).catch(err => {
state.error = err;
});
});
} catch (err) {
state.error = err;
}
state.promise = Promise.all(promises).then(res => {
state.loading = false;
return res;
}).catch(err => {
state.loading = false;
throw err;
});
return state;
}
function resolve(obj) {
return obj && obj.__esModule ? obj.default : obj;
}
function createChunkComponent(loadFn, options) {
let opts = Object.assign({
displayName: null,
loader: null,
hoistStatics: false,
resolveDefaultImport: (imported /*, importKey */) => resolve(imported),
retryBackOff: [],
delay: 200,
timeout: null,
webpack: null,
modules: [],
}, options);
let res = null;
const hoistSubscribers = [];
const importedSubscribers = [];
let importTimeoutMs = typeof opts.timeout === 'number' ? opts.timeout : 0;
// Adjust the UI timeout to include the retry backOff options
if (opts.retryBackOff.length && typeof opts.timeout === 'number') {
opts.timeout = opts.retryBackOff.reduce((total, ms) => {
return total + ms;
}, opts.timeout * opts.retryBackOff.length);
}
return (WrappedComponent) => {
if (!opts.singleImport && typeof WrappedComponent === 'undefined') {
throw new Error('`chunks({..})([missing])` requires a component to wrap.');
}
class ChunkComponent extends React.Component {
constructor(props) {
super(props);
init(false);
this.state = {
error: res.error,
pastDelay: false,
timedOut: false,
loading: res.loading,
loaded: res.loaded
};
}
static propTypes = {
chunks: PropTypes.shape({
addChunk: PropTypes.func.isRequired,
})
};
static preloadChunk() {
return init(true);
}
static getChunkLoader() {
return init;
}
static onImported(importedHandler) {
invariant(typeof importedHandler === 'function', `"onImported" expects a single function argument.`);
return ChunkComponent.bindImportSubscriber(importedHandler, importedSubscribers);
}
static onImportedWithHoist(hoistSubscriber) {
invariant(typeof hoistSubscriber === 'function', `"onHoistImported" expects a single function argument.`);
if (!opts.hoistStatics) {
// nothing to hoist
return;
}
return ChunkComponent.bindImportSubscriber(hoistSubscriber, hoistSubscribers);
}
static bindImportSubscriber(handler, subscribers) {
if (res && res.loaded) {
handler(opts.resolveDefaultImport(res.loaded));
return;
}
subscribers.push(handler);
// return an unsubscribe function
return () => {
const idx = subscribers.indexOf(handler);
if (idx !== -1) {
return subscribers.splice(idx, 1);
}
};
}
_loadChunks() {
if (!res.loading) {
return;
}
// clear timeouts - in case 'retry' is invoked before loading is complete
this._clearTimeouts();
if (typeof opts.delay === 'number') {
if (opts.delay === 0) {
this.setState({ pastDelay: true });
} else {
this._delay = setTimeout(() => {
this.setState({ pastDelay: true });
}, opts.delay);
}
}
// This approach doesn't provide ms specific feedback, but implementation is really easy
// - an alternative is to subscribe to: res.onTimeout(() => setState(...))
// - if more accurate feedback is required, this can be implemented (w/'unsubscribe' on _clearTimeouts)
if (typeof opts.timeout === 'number') {
this._timeout = setTimeout(() => {
this.setState({ timedOut: true });
}, opts.timeout);
}
let update = () => {
hoistStatics();
if (!this._mounted) {
return;
}
this.setState({
error: res.error,
loaded: res.loaded,
loading: res.loading
});
this._clearTimeouts();
};
res.promise.then(() => {
update();
}).catch((/* err */) => {
update();
});
}
_clearTimeouts() {
clearTimeout(this._delay);
clearTimeout(this._timeout);
}
retry() {
if (hasLoaded(this.state)) {
return;
}
// reset state for retry
res = null;
this.setState({
error: null,
loading: true,
loaded: {},
pastDelay: false,
timedOut: false,
});
// attempt to load the chunk(s) again
const promise = init(false); // don't throw on err - this component can not support hoist (or logically, it'd never get here)
this._loadChunks(); // update this components state
return promise;
}
componentWillMount() {
this._mounted = true;
if (this.props.chunks && Array.isArray(opts.modules)) {
opts.modules.forEach(moduleName => {
this.props.chunks.addChunk(moduleName);
});
}
this._loadChunks();
}
componentWillUnmount() {
this._mounted = false;
this._clearTimeouts();
}
render() {
// eslint-disable-next-line no-unused-vars
const { chunks, ...passThroughProps } = this.props;
const importState = {
isLoading: this.state.loading,
hasLoaded: hasLoaded(this.state),
pastDelay: this.state.pastDelay,
timedOut: this.state.timedOut,
error: this.state.error,
loaded: this.state.loaded,
retry: () => this.retry() // binds 'this'
};
if (opts.singleImport) {
if (typeof WrappedComponent === 'undefined') {
// no wrapped component
if (importState.hasLoaded) {
return React.createElement(opts.resolveDefaultImport(this.state.loaded), passThroughProps);
}
return null;
}
const componentProps = Object.assign({}, passThroughProps, {
chunk: {
...importState,
importKeys: [],
Imported: importState.hasLoaded ? opts.resolveDefaultImport(importState.loaded) : null
}
});
return React.createElement(WrappedComponent, componentProps);
}
let componentProps = Object.assign({}, passThroughProps, {
chunk: {
...importState,
importKeys: importState.hasLoaded ? Object.keys(this.state.loaded) : [],
imported: {}
}
});
if (importState.hasLoaded) {
componentProps.chunk.imported = Object.keys(this.state.loaded).reduce((acc, importKey) => {
acc[importKey] = opts.resolveDefaultImport(this.state.loaded[importKey], importKey);
return acc;
}, componentProps.chunk.imported);
}
return React.createElement(WrappedComponent, componentProps);
}
}
// Apply chunks context to the chunk component
const ChunkHOC = withChunks(ChunkComponent);
const hasWrappedComponent = !(typeof WrappedComponent === 'undefined' || WrappedComponent === null);
const wrappedComponentName = opts.displayName || (hasWrappedComponent ? getDisplayName(WrappedComponent) : '');
ChunkHOC.displayName = opts.singleImport ? `chunk(${wrappedComponentName})` : `chunks(${wrappedComponentName})`;
let _hoisted = false;
function hoistStatics() {
if (_hoisted || !opts.hoistStatics) {
return;
}
// Only hoist the static methods once
if (!res.error && res.loaded && !_hoisted) {
// Hoist is only supported by 'chunk'
hoistNonReactStatics(ChunkHOC, opts.resolveDefaultImport(res.loaded));
_hoisted = true;
}
}
function init(throwOnImportError) {
if (!res) {
res = loadFn(opts.loader, {
retryBackOff: Array.isArray(opts.retryBackOff) ? opts.retryBackOff : [],
importTimeoutMs: importTimeoutMs,
throwOnImportError: throwOnImportError
});
if (opts.hoistStatics) {
res.promise = res.promise
.then(() => { hoistStatics(); })
.catch(err => {
// clear any subscribers on error
if (hoistSubscribers.length !== 0) {
hoistSubscribers.splice(0, hoistSubscribers.length);
}
if (importedSubscribers.length !== 0) {
importedSubscribers.splice(0, importedSubscribers.length);
}
if (throwOnImportError === true) {
// When pre-loading, any loader errors will be thrown immediately (ie: hoistStatics, timeout options)
// - hoisting implies use of static methods, which need to be available prior to rendering.
throw err;
}
})
.then(() => {
// Notify hoist subscribers
if (hoistSubscribers.length !== 0) {
const subscriberHandlers = hoistSubscribers.splice(0, hoistSubscribers.length);
subscriberHandlers.forEach(subscribeHandler => {
subscribeHandler(opts.resolveDefaultImport(res.loaded));
});
}
});
}
// Notify 'onImported' subscribers
res.promise = res.promise.then(function () {
if (importedSubscribers.length !== 0) {
const subscriberHandlers = importedSubscribers.splice(0, importedSubscribers.length);
subscriberHandlers.forEach(subscribeHandler => {
subscribeHandler(opts.resolveDefaultImport(res.loaded));
});
}
});
}
return res.promise;
}
ALL_INITIALIZERS.push(init);
if (typeof opts.webpack === 'function') {
READY_INITIALIZERS.push((throwOnImportError) => {
if (isWebpackReady(opts.webpack)) {
return init(throwOnImportError);
}
});
}
// Hoist any statics on the wrapped component
return hasWrappedComponent ? hoistNonReactStatics(ChunkHOC, WrappedComponent) : ChunkHOC;
}
}
function chunk(dynamicImport, opts = {}, webpackOpts = {}) {
if (typeof dynamicImport !== 'function') {
throw new Error('`chunk()` requires an import function.');
}
return createChunkComponent(load, { ...webpackOpts, ...opts, loader: dynamicImport, singleImport: true });
}
function chunks(dynamicImport, opts = {}, webpackOpts = {}) {
if (typeof dynamicImport !== 'object' || Array.isArray(dynamicImport) || dynamicImport === null) {
throw new Error('`chunks()` requires a map of import functions.');
}
if (typeof opts.hoistStatics !== 'undefined') {
throw new Error('`chunks()` does not support the "hoistStatics" option.');
}
return createChunkComponent(loadMap, {...webpackOpts, ...opts, loader: dynamicImport, singleImport: false });
}
function flushInitializers(initializers) {
let promises = [];
while (initializers.length) {
let init = initializers.pop();
promises.push(init(true));
}
return Promise.all(promises).then(() => {
if (initializers.length) {
return flushInitializers(initializers);
}
});
}
function preloadChunks(loaders) {
return new Promise((resolve, reject) => {
return flushInitializers(loaders).then(resolve, reject);
});
}
function preloadAll() {
return preloadChunks(ALL_INITIALIZERS);
}
function preloadReady() {
return preloadChunks(READY_INITIALIZERS);
}
function noop() {}
// HOC to access the chunks context
function withChunks(Component) {
class ChunkReporter extends React.Component {
static contextTypes = {
chunks: PropTypes.shape({
addChunk: PropTypes.func.isRequired,
})
};
render() {
const { chunks } = this.context;
return React.createElement(Component, {
...this.props,
chunks: {
addChunk: (chunks && chunks.addChunk) || noop
}
});
}
}
return hoistNonReactStatics(ChunkReporter, Component);
}
exports.chunk = chunk;
exports.chunks = chunks;
exports.preloadReady = preloadReady;
exports.preloadAll = preloadAll;
exports.preloadChunks = preloadChunks;
exports.resolve = resolve;
exports.withChunks = withChunks;
exports.TIMEOUT_ERR = TIMEOUT_ERR;