diff --git a/babel.config.js b/babel.config.js
index 44d79417..ea8ce4a2 100644
--- a/babel.config.js
+++ b/babel.config.js
@@ -11,4 +11,9 @@ module.exports = {
['@babel/preset-env', { loose: true, targets: getTargets() }],
],
plugins: ['@babel/plugin-proposal-class-properties'],
+ env: {
+ test: {
+ plugins: ['@babel/plugin-proposal-function-bind'],
+ },
+ },
}
diff --git a/package.json b/package.json
index 3ddced5d..2bb23bdd 100644
--- a/package.json
+++ b/package.json
@@ -28,6 +28,7 @@
"@babel/core": "^7.7.7",
"@babel/node": "^7.7.7",
"@babel/plugin-proposal-class-properties": "^7.7.4",
+ "@babel/plugin-proposal-function-bind": "^7.8.3",
"@babel/plugin-transform-runtime": "^7.7.6",
"@babel/preset-env": "^7.7.7",
"@babel/preset-react": "^7.7.4",
diff --git a/packages/component/src/createLoadable.js b/packages/component/src/createLoadable.js
index dd22c647..d77c0bfa 100644
--- a/packages/component/src/createLoadable.js
+++ b/packages/component/src/createLoadable.js
@@ -6,6 +6,10 @@ import { invariant } from './util'
import Context from './Context'
import { LOADABLE_SHARED } from './shared'
+const STATUS_PENDING = 'PENDING'
+const STATUS_RESOLVED = 'RESOLVED'
+const STATUS_REJECTED = 'REJECTED'
+
function resolveConstructor(ctor) {
if (typeof ctor === 'function') {
return { requireAsync: ctor }
@@ -75,8 +79,6 @@ function createLoadable({
cacheKey: getCacheKey(props),
}
- this.promise = null
-
invariant(
!props.__chunkExtractor || ctor.requireSync,
'SSR requires `@loadable/babel-plugin`, please install it',
@@ -119,18 +121,22 @@ function createLoadable({
componentDidMount() {
this.mounted = true
- if (this.state.loading) {
- this.loadAsync()
- } else if (!this.state.error) {
- this.triggerOnLoad()
+ const cachedPromise = this.getCache()
+
+ if (cachedPromise && cachedPromise.status === STATUS_REJECTED) {
+ this.setCache()
+ this.setState({
+ error: undefined,
+ loading: true,
+ })
}
+ this.loadAsyncOnLifecycle()
}
componentDidUpdate(prevProps, prevState) {
// Component is reloaded if the cacheKey has changed
if (prevState.cacheKey !== this.state.cacheKey) {
- this.promise = null
- this.loadAsync()
+ this.loadAsyncOnLifecycle()
}
}
@@ -178,29 +184,45 @@ function createLoadable({
}
loadAsync() {
- if (!this.promise) {
- const { __chunkExtractor, forwardedRef, ...props } = this.props
- this.promise = ctor
- .requireAsync(props)
+ const { __chunkExtractor, forwardedRef, ...props } = this.props
+
+ let promise = this.getCache()
+
+ if (!promise) {
+ promise = ctor.requireAsync(props)
+ promise.status = STATUS_PENDING
+
+ this.setCache(promise)
+
+ const cachedPromise = promise
+
+ promise = promise
.then(loadedModule => {
- const result = resolve(loadedModule, this.props, Loadable)
- if (options.suspense) {
- this.setCache(result)
- }
- this.safeSetState(
- {
- result: resolve(loadedModule, this.props, Loadable),
- loading: false,
- },
- () => this.triggerOnLoad(),
- )
+ cachedPromise.status = STATUS_RESOLVED
+ return loadedModule
})
.catch(error => {
- this.safeSetState({ error, loading: false })
+ cachedPromise.status = STATUS_REJECTED
+ throw error
})
}
- return this.promise
+ return promise
+ }
+
+ loadAsyncOnLifecycle() {
+ this.loadAsync()
+ .then(loadedModule => {
+ const result = resolve(loadedModule, this.props, { Loadable })
+ this.safeSetState(
+ {
+ result,
+ loading: false,
+ },
+ () => this.triggerOnLoad(),
+ )
+ })
+ .catch(error => this.safeSetState({ error, loading: false }))
}
render() {
@@ -213,15 +235,10 @@ function createLoadable({
const { error, loading, result } = this.state
if (options.suspense) {
- const cachedResult = this.getCache()
- if (!cachedResult) throw this.loadAsync()
- return render({
- loading: false,
- fallback: null,
- result: cachedResult,
- options,
- props: { ...props, ref: forwardedRef },
- })
+ const cachedPromise = this.getCache()
+ if (!cachedPromise || cachedPromise.status === STATUS_PENDING) {
+ throw this.loadAsync()
+ }
}
if (error) {
@@ -235,8 +252,6 @@ function createLoadable({
}
return render({
- loading,
- fallback,
result,
options,
props: { ...props, ref: forwardedRef },
diff --git a/packages/component/src/library.js b/packages/component/src/library.js
index b7e82dec..8d945dc6 100644
--- a/packages/component/src/library.js
+++ b/packages/component/src/library.js
@@ -11,8 +11,8 @@ export const { loadable, lazy } = createLoadable({
}
}
},
- render({ result, loading, props }) {
- if (!loading && props.children) {
+ render({ result, props }) {
+ if (props.children) {
return props.children(result)
}
diff --git a/packages/component/src/loadable.test.js b/packages/component/src/loadable.test.js
index e400f150..b3691062 100644
--- a/packages/component/src/loadable.test.js
+++ b/packages/component/src/loadable.test.js
@@ -1,3 +1,4 @@
+/* eslint-disable max-classes-per-file */
/* eslint-disable import/no-extraneous-dependencies, react/no-multi-comp */
import 'regenerator-runtime/runtime'
import '@testing-library/jest-dom/extend-expect'
@@ -7,14 +8,13 @@ import loadable, { lazy } from './index'
afterEach(cleanup)
-function createLoadFunction() {
- const ref = {}
- const fn = jest.fn(() => ref.promise)
- ref.promise = new Promise((resolve, reject) => {
- fn.resolve = resolve
- fn.reject = reject
- })
- return fn
+const unresolvableLoad = jest.fn(() => new Promise(() => {}))
+
+function mockDelayedResolvedValueOnce(resolvedValue) {
+ return this.mockImplementationOnce(
+ () =>
+ new Promise(resolve => setTimeout(() => resolve(resolvedValue), 1000)),
+ )
}
class Catch extends React.Component {
@@ -29,6 +29,35 @@ class Catch extends React.Component {
}
}
+class ErrorBoundary extends React.Component {
+ constructor(props) {
+ super(props)
+
+ this.state = {
+ error: false,
+ retries: props.retries || 0,
+ }
+ }
+
+ componentDidCatch() {
+ this.setState(prevState => ({
+ error: true,
+ retries: prevState.retries - 1,
+ }))
+ }
+
+ render() {
+ const { children, fallback } = this.props
+ const { error, retries } = this.state
+
+ if (error) {
+ return (retries >= 0 && children) || fallback || null
+ }
+
+ return children || null
+ }
+}
+
describe('#loadable', () => {
beforeEach(() => {
jest.spyOn(console, 'error').mockImplementation(() => {})
@@ -40,44 +69,39 @@ describe('#loadable', () => {
})
it('renders nothing without a fallback', () => {
- const load = createLoadFunction()
- const Component = loadable(load)
+ const Component = loadable(unresolvableLoad)
const { container } = render()
expect(container).toBeEmpty()
})
it('uses option fallback if specified', () => {
- const load = createLoadFunction()
- const Component = loadable(load, { fallback: 'progress' })
+ const Component = loadable(unresolvableLoad, { fallback: 'progress' })
const { container } = render()
expect(container).toHaveTextContent('progress')
})
it('uses props fallback if specified', () => {
- const load = createLoadFunction()
- const Component = loadable(load)
+ const Component = loadable(unresolvableLoad)
const { container } = render()
expect(container).toHaveTextContent('progress')
})
it('should use props fallback instead of option fallback if specified', () => {
- const load = createLoadFunction()
- const Component = loadable(load, { fallback: 'opt fallback' })
+ const Component = loadable(unresolvableLoad, { fallback: 'opt fallback' })
const { container } = render()
expect(container).toHaveTextContent('prop fallback')
})
it('mounts component when loaded', async () => {
- const load = createLoadFunction()
+ const load = jest.fn().mockResolvedValue({ default: () => 'loaded' })
const Component = loadable(load)
const { container } = render()
expect(container).toBeEmpty()
- load.resolve({ default: () => 'loaded' })
await wait(() => expect(container).toHaveTextContent('loaded'))
})
it('supports preload', async () => {
- const load = createLoadFunction()
+ const load = jest.fn().mockResolvedValue({ default: () => 'loaded' })
const Component = loadable(load)
expect(load).not.toHaveBeenCalled()
Component.preload({ foo: 'bar' })
@@ -85,28 +109,26 @@ describe('#loadable', () => {
expect(load).toHaveBeenCalledTimes(1)
const { container } = render()
expect(container).toBeEmpty()
- load.resolve({ default: () => 'loaded' })
await wait(() => expect(container).toHaveTextContent('loaded'))
expect(load).toHaveBeenCalledTimes(2)
})
it('supports commonjs default export', async () => {
- const load = createLoadFunction()
+ const load = jest.fn().mockResolvedValue(() => 'loaded')
const Component = loadable(load)
const { container } = render()
- load.resolve(() => 'loaded')
await wait(() => expect(container).toHaveTextContent('loaded'))
})
it('supports non-default export via resolveComponent', async () => {
- const load = createLoadFunction()
const importedModule = { exported: () => 'loaded' }
+ const load = jest.fn().mockResolvedValue(importedModule)
const resolveComponent = jest.fn(({ exported: component }) => component)
const Component = loadable(load, {
resolveComponent,
})
const { container } = render()
- load.resolve(importedModule)
+
await wait(() => expect(container).toHaveTextContent('loaded'))
expect(resolveComponent).toHaveBeenCalledWith(importedModule, {
someProp: '123',
@@ -116,18 +138,16 @@ describe('#loadable', () => {
})
it('forwards props', async () => {
- const load = createLoadFunction()
+ const load = jest.fn().mockResolvedValue({ default: ({ name }) => name })
const Component = loadable(load)
const { container } = render()
- load.resolve({ default: ({ name }) => name })
await wait(() => expect(container).toHaveTextContent('James Bond'))
})
it('should update component if props change', async () => {
- const load = createLoadFunction()
+ const load = jest.fn().mockResolvedValue({ default: ({ value }) => value })
const Component = loadable(load)
const { container } = render()
- load.resolve({ default: ({ value }) => value })
await wait(() => expect(container).toHaveTextContent('first'))
render(, { container })
await wait(() => expect(container).toHaveTextContent('second'))
@@ -135,10 +155,9 @@ describe('#loadable', () => {
})
it('calls load func if cacheKey change', async () => {
- const load = createLoadFunction()
+ const load = jest.fn().mockResolvedValue({ default: ({ value }) => value })
const Component = loadable(load, { cacheKey: ({ value }) => value })
const { container } = render()
- load.resolve({ default: ({ value }) => value })
await wait(() => expect(container).toHaveTextContent('first'))
expect(load).toHaveBeenCalledTimes(1)
render(, { container })
@@ -147,13 +166,12 @@ describe('#loadable', () => {
})
it('calls load func if resolve change', async () => {
- const load = createLoadFunction()
+ const load = jest.fn().mockResolvedValue({ default: ({ value }) => value })
const Component = loadable({
requireAsync: load,
resolve: ({ value }) => value,
})
const { container } = render()
- load.resolve({ default: ({ value }) => value })
await wait(() => expect(container).toHaveTextContent('first'))
expect(load).toHaveBeenCalledTimes(1)
render(, { container })
@@ -192,18 +210,17 @@ describe('#loadable', () => {
})
it('forwards ref', async () => {
- const load = createLoadFunction()
+ const load = jest.fn().mockResolvedValue({
+ default: React.forwardRef((props, fref) =>
),
+ })
const Component = loadable(load)
const ref = React.createRef()
render()
- load.resolve({
- default: React.forwardRef((props, fref) => ),
- })
await wait(() => expect(ref.current.tagName).toBe('DIV'))
})
it('throws when an error occurs', async () => {
- const load = createLoadFunction()
+ const load = jest.fn().mockRejectedValue(new Error('boom'))
const Component = loadable(load)
const { container } = render(
@@ -211,14 +228,30 @@ describe('#loadable', () => {
,
)
expect(container).toBeEmpty()
- load.reject(new Error('boom'))
await wait(() => expect(container).toHaveTextContent('error'))
})
+
+ it('supports retry from Error Boundary', async () => {
+ const load = jest
+ .fn()
+ .mockRejectedValueOnce(new Error('Error Boundary'))
+ .mockResolvedValueOnce({ default: () => 'loaded' })
+
+ const Component = loadable(load)
+ const { container } = render(
+
+
+ ,
+ )
+ expect(container).toBeEmpty()
+
+ await wait(() => expect(container).toHaveTextContent('loaded'))
+ })
})
describe('#lazy', () => {
it('supports Suspense', async () => {
- const load = createLoadFunction()
+ const load = jest.fn().mockResolvedValue({ default: () => 'loaded' })
const Component = lazy(load)
const { container } = render(
@@ -226,20 +259,111 @@ describe('#lazy', () => {
,
)
expect(container).toHaveTextContent('progress')
- load.resolve({ default: () => 'loaded' })
+ await wait(() => expect(container).not.toHaveTextContent('progress'))
+ expect(container).toHaveTextContent('loaded')
+ })
+
+ it('should only render both components when both resolve', async () => {
+ const load = jest
+ .fn()
+ .mockResolvedValueOnce({ default: ({ text }) => text })
+ ::mockDelayedResolvedValueOnce({ default: ({ text }) => text })
+
+ const Component = lazy(load)
+
+ const { container } = render(
+
+ <>
+
+
+ >
+ ,
+ )
+ expect(container).toHaveTextContent('progress')
+ await wait(() => expect(container).not.toHaveTextContent('progress'))
+ expect(container.textContent).toBe('AB')
+ })
+
+ it("should render multiple elements of the same async component under contextual Suspense'", async () => {
+ const load = jest.fn().mockResolvedValue({ default: ({ text }) => text })
+ const Component = lazy(load)
+ const { container } = render(
+ <>
+
+
+
+
+
+
+ >,
+ )
+ expect(container).toHaveTextContent('progressA progressB')
+
+ await wait(() => expect(container).not.toHaveTextContent('progress'))
+ expect(container).toHaveTextContent('AB')
+ })
+
+ it("shouldn't trigger nested Suspense for same lazy component", async () => {
+ const load = jest.fn().mockResolvedValue({ default: ({ text }) => text })
+ const Component = lazy(load)
+ const { container } = render(
+ <>
+
+
+
+
+
+
+ >,
+ )
+ expect(container.textContent).toBe('progressA')
+
+ await wait(() => expect(container).not.toHaveTextContent('progressA'))
+ expect(container).toHaveTextContent('AB')
+ })
+
+ it('should support Error Boundary', async () => {
+ const load = jest.fn().mockRejectedValue(new Error('Error Boundary'))
+ const Component = lazy(load)
+ const { container } = render(
+
+
+
+
+ ,
+ )
+ expect(container).toHaveTextContent('progress')
+ await wait(() => expect(container).toHaveTextContent('error'))
+ })
+
+ it('should support retry from Error Boundary', async () => {
+ const load = jest
+ .fn()
+ .mockRejectedValueOnce(new Error('Error Boundary'))
+ .mockResolvedValueOnce({ default: () => 'loaded' })
+
+ const Component = lazy(load)
+ const { container } = render(
+
+
+
+
+ ,
+ )
+ expect(container).toHaveTextContent('progress')
+
await wait(() => expect(container).toHaveTextContent('loaded'))
})
})
describe('#loadable.lib', () => {
it('loads library as render prop', async () => {
- const load = createLoadFunction()
+ const library = { it: 'is', a: 'lib' }
+ const load = jest.fn().mockResolvedValue(library)
const Lib = loadable.lib(load)
const renderFn = jest.fn(() => 'loaded')
const { container } = render({renderFn})
expect(container).toBeEmpty()
- const library = { it: 'is', a: 'lib' }
- load.resolve(library)
await wait(() => expect(container).toHaveTextContent('loaded'))
expect(renderFn).toHaveBeenCalledWith(library)
})
@@ -247,7 +371,8 @@ describe('#loadable.lib', () => {
describe('#lazy.lib', () => {
it('supports Suspense', async () => {
- const load = createLoadFunction()
+ const library = { it: 'is', a: 'lib' }
+ const load = jest.fn().mockResolvedValue(library)
const Lib = lazy.lib(load)
const renderFn = jest.fn(() => 'loaded')
const { container } = render(
@@ -256,9 +381,21 @@ describe('#lazy.lib', () => {
,
)
expect(container).toHaveTextContent('progress')
- const library = { it: 'is', a: 'lib' }
- load.resolve(library)
await wait(() => expect(container).toHaveTextContent('loaded'))
- expect(container).toHaveTextContent('loaded')
+ })
+
+ it('supports Error Boundary', async () => {
+ const load = jest.fn().mockRejectedValue(new Error('Error Boundary'))
+ const Lib = lazy.lib(load)
+ const renderFn = jest.fn(() => 'loaded')
+ const { container } = render(
+
+
+ {renderFn}
+
+ ,
+ )
+ expect(container).toHaveTextContent('progress')
+ await wait(() => expect(container).toHaveTextContent('error'))
})
})
diff --git a/yarn.lock b/yarn.lock
index 3b378e27..d32ba8b7 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -185,6 +185,11 @@
resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.0.0.tgz#bbb3fbee98661c569034237cc03967ba99b4f250"
integrity sha512-CYAOUCARwExnEixLdB6sDm2dIJ/YgEAKDM1MOeMeZu9Ld/bDgVo8aiWrXwcY7OBh+1Ea2uUcVRcxKk0GJvW7QA==
+"@babel/helper-plugin-utils@^7.8.3":
+ version "7.8.3"
+ resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.8.3.tgz#9ea293be19babc0f52ff8ca88b34c3611b208670"
+ integrity sha512-j+fq49Xds2smCUNYmEHF9kGNkhbet6yVIBp4e6oeQpH1RUs/Ir06xUKzDjDkGcaaokPiTNs2JBWHjaE4csUkZQ==
+
"@babel/helper-regex@^7.0.0", "@babel/helper-regex@^7.4.4":
version "7.5.5"
resolved "https://registry.yarnpkg.com/@babel/helper-regex/-/helper-regex-7.5.5.tgz#0aa6824f7100a2e0e89c1527c23936c152cab351"
@@ -300,6 +305,14 @@
"@babel/helper-plugin-utils" "^7.0.0"
"@babel/plugin-syntax-dynamic-import" "^7.7.4"
+"@babel/plugin-proposal-function-bind@^7.8.3":
+ version "7.8.3"
+ resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-function-bind/-/plugin-proposal-function-bind-7.8.3.tgz#e34a1e984771b84b6e5322745edeadca7e500ced"
+ integrity sha512-6q7VAHJQa9x4P6Lm6h6KHoJUEhx2r1buFKseHICe0ogb1LWxducO4tsQp3hd/7BVBo485YBsn6tJnpuwWm/9cA==
+ dependencies:
+ "@babel/helper-plugin-utils" "^7.8.3"
+ "@babel/plugin-syntax-function-bind" "^7.8.3"
+
"@babel/plugin-proposal-json-strings@^7.7.4":
version "7.7.4"
resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-json-strings/-/plugin-proposal-json-strings-7.7.4.tgz#7700a6bfda771d8dc81973249eac416c6b4c697d"
@@ -353,6 +366,13 @@
dependencies:
"@babel/helper-plugin-utils" "^7.0.0"
+"@babel/plugin-syntax-function-bind@^7.8.3":
+ version "7.8.3"
+ resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-function-bind/-/plugin-syntax-function-bind-7.8.3.tgz#17d722cd8efc9bb9cf8bc59327f2b26295b352f7"
+ integrity sha512-gEYag4Q3CfqlQcJQQw/KSWdV2husGOnIsOsRlyzkoaNqj2V/V/CSdSJDCGSl67oJ1bdIYP6TjORWPH561dSJpA==
+ dependencies:
+ "@babel/helper-plugin-utils" "^7.8.3"
+
"@babel/plugin-syntax-json-strings@^7.7.4":
version "7.7.4"
resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.7.4.tgz#86e63f7d2e22f9e27129ac4e83ea989a382e86cc"