import { defineAsyncComponent, h, Component, ref, nextTick, Suspense } from '../src' import { createApp, nodeOps, serializeInner } from '@vue/runtime-test' const timeout = (n: number = 0) => new Promise(r => setTimeout(r, n)) describe('api: defineAsyncComponent', () => { test('simple usage', async () => { let resolve: (comp: Component) => void const Foo = defineAsyncComponent( () => new Promise(r => { resolve = r as any }) ) const toggle = ref(true) const root = nodeOps.createElement('div') createApp({ render: () => (toggle.value ? h(Foo) : null) }).mount(root) expect(serializeInner(root)).toBe('') resolve!(() => 'resolved') // first time resolve, wait for macro task since there are multiple // microtasks / .then() calls await timeout() expect(serializeInner(root)).toBe('resolved') toggle.value = false await nextTick() expect(serializeInner(root)).toBe('') // already resolved component should update on nextTick toggle.value = true await nextTick() expect(serializeInner(root)).toBe('resolved') }) test('with loading component', async () => { let resolve: (comp: Component) => void const Foo = defineAsyncComponent({ loader: () => new Promise(r => { resolve = r as any }), loadingComponent: () => 'loading', delay: 1 // defaults to 200 }) const toggle = ref(true) const root = nodeOps.createElement('div') createApp({ render: () => (toggle.value ? h(Foo) : null) }).mount(root) // due to the delay, initial mount should be empty expect(serializeInner(root)).toBe('') // loading show up after delay await timeout(1) expect(serializeInner(root)).toBe('loading') resolve!(() => 'resolved') await timeout() expect(serializeInner(root)).toBe('resolved') toggle.value = false await nextTick() expect(serializeInner(root)).toBe('') // already resolved component should update on nextTick without loading // state toggle.value = true await nextTick() expect(serializeInner(root)).toBe('resolved') }) test('with loading component + explicit delay (0)', async () => { let resolve: (comp: Component) => void const Foo = defineAsyncComponent({ loader: () => new Promise(r => { resolve = r as any }), loadingComponent: () => 'loading', delay: 0 }) const toggle = ref(true) const root = nodeOps.createElement('div') createApp({ render: () => (toggle.value ? h(Foo) : null) }).mount(root) // with delay: 0, should show loading immediately expect(serializeInner(root)).toBe('loading') resolve!(() => 'resolved') await timeout() expect(serializeInner(root)).toBe('resolved') toggle.value = false await nextTick() expect(serializeInner(root)).toBe('') // already resolved component should update on nextTick without loading // state toggle.value = true await nextTick() expect(serializeInner(root)).toBe('resolved') }) test('error without error component', async () => { let resolve: (comp: Component) => void let reject: (e: Error) => void const Foo = defineAsyncComponent( () => new Promise((_resolve, _reject) => { resolve = _resolve as any reject = _reject }) ) const toggle = ref(true) const root = nodeOps.createElement('div') const app = createApp({ render: () => (toggle.value ? h(Foo) : null) }) const handler = (app.config.errorHandler = jest.fn()) app.mount(root) expect(serializeInner(root)).toBe('') const err = new Error('foo') reject!(err) await timeout() expect(handler).toHaveBeenCalled() expect(handler.mock.calls[0][0]).toBe(err) expect(serializeInner(root)).toBe('') toggle.value = false await nextTick() expect(serializeInner(root)).toBe('') // errored out on previous load, toggle and mock success this time toggle.value = true await nextTick() expect(serializeInner(root)).toBe('') // should render this time resolve!(() => 'resolved') await timeout() expect(serializeInner(root)).toBe('resolved') }) test('error with error component', async () => { let resolve: (comp: Component) => void let reject: (e: Error) => void const Foo = defineAsyncComponent({ loader: () => new Promise((_resolve, _reject) => { resolve = _resolve as any reject = _reject }), errorComponent: (props: { error: Error }) => props.error.message }) const toggle = ref(true) const root = nodeOps.createElement('div') const app = createApp({ render: () => (toggle.value ? h(Foo) : null) }) const handler = (app.config.errorHandler = jest.fn()) app.mount(root) expect(serializeInner(root)).toBe('') const err = new Error('errored out') reject!(err) await timeout() expect(handler).toHaveBeenCalled() expect(serializeInner(root)).toBe('errored out') toggle.value = false await nextTick() expect(serializeInner(root)).toBe('') // errored out on previous load, toggle and mock success this time toggle.value = true await nextTick() expect(serializeInner(root)).toBe('') // should render this time resolve!(() => 'resolved') await timeout() expect(serializeInner(root)).toBe('resolved') }) test('error with error + loading components', async () => { let resolve: (comp: Component) => void let reject: (e: Error) => void const Foo = defineAsyncComponent({ loader: () => new Promise((_resolve, _reject) => { resolve = _resolve as any reject = _reject }), errorComponent: (props: { error: Error }) => props.error.message, loadingComponent: () => 'loading', delay: 1 }) const toggle = ref(true) const root = nodeOps.createElement('div') const app = createApp({ render: () => (toggle.value ? h(Foo) : null) }) const handler = (app.config.errorHandler = jest.fn()) app.mount(root) // due to the delay, initial mount should be empty expect(serializeInner(root)).toBe('') // loading show up after delay await timeout(1) expect(serializeInner(root)).toBe('loading') const err = new Error('errored out') reject!(err) await timeout() expect(handler).toHaveBeenCalled() expect(serializeInner(root)).toBe('errored out') toggle.value = false await nextTick() expect(serializeInner(root)).toBe('') // errored out on previous load, toggle and mock success this time toggle.value = true await nextTick() expect(serializeInner(root)).toBe('') // loading show up after delay await timeout(1) expect(serializeInner(root)).toBe('loading') // should render this time resolve!(() => 'resolved') await timeout() expect(serializeInner(root)).toBe('resolved') }) test('timeout without error component', async () => { let resolve: (comp: Component) => void const Foo = defineAsyncComponent({ loader: () => new Promise(_resolve => { resolve = _resolve as any }), timeout: 1 }) const root = nodeOps.createElement('div') const app = createApp({ render: () => h(Foo) }) const handler = (app.config.errorHandler = jest.fn()) app.mount(root) expect(serializeInner(root)).toBe('') await timeout(1) expect(handler).toHaveBeenCalled() expect(handler.mock.calls[0][0].message).toMatch( `Async component timed out after 1ms.` ) expect(serializeInner(root)).toBe('') // if it resolved after timeout, should still work resolve!(() => 'resolved') await timeout() expect(serializeInner(root)).toBe('resolved') }) test('timeout with error component', async () => { let resolve: (comp: Component) => void const Foo = defineAsyncComponent({ loader: () => new Promise(_resolve => { resolve = _resolve as any }), timeout: 1, errorComponent: () => 'timed out' }) const root = nodeOps.createElement('div') const app = createApp({ render: () => h(Foo) }) const handler = (app.config.errorHandler = jest.fn()) app.mount(root) expect(serializeInner(root)).toBe('') await timeout(1) expect(handler).toHaveBeenCalled() expect(serializeInner(root)).toBe('timed out') // if it resolved after timeout, should still work resolve!(() => 'resolved') await timeout() expect(serializeInner(root)).toBe('resolved') }) test('timeout with error + loading components', async () => { let resolve: (comp: Component) => void const Foo = defineAsyncComponent({ loader: () => new Promise(_resolve => { resolve = _resolve as any }), delay: 1, timeout: 16, errorComponent: () => 'timed out', loadingComponent: () => 'loading' }) const root = nodeOps.createElement('div') const app = createApp({ render: () => h(Foo) }) const handler = (app.config.errorHandler = jest.fn()) app.mount(root) expect(serializeInner(root)).toBe('') await timeout(1) expect(serializeInner(root)).toBe('loading') await timeout(16) expect(serializeInner(root)).toBe('timed out') expect(handler).toHaveBeenCalled() resolve!(() => 'resolved') await timeout() expect(serializeInner(root)).toBe('resolved') }) test('timeout without error component, but with loading component', async () => { let resolve: (comp: Component) => void const Foo = defineAsyncComponent({ loader: () => new Promise(_resolve => { resolve = _resolve as any }), delay: 1, timeout: 16, loadingComponent: () => 'loading' }) const root = nodeOps.createElement('div') const app = createApp({ render: () => h(Foo) }) const handler = (app.config.errorHandler = jest.fn()) app.mount(root) expect(serializeInner(root)).toBe('') await timeout(1) expect(serializeInner(root)).toBe('loading') await timeout(16) expect(handler).toHaveBeenCalled() expect(handler.mock.calls[0][0].message).toMatch( `Async component timed out after 16ms.` ) // should still display loading expect(serializeInner(root)).toBe('loading') resolve!(() => 'resolved') await timeout() expect(serializeInner(root)).toBe('resolved') }) test('with suspense', async () => { let resolve: (comp: Component) => void const Foo = defineAsyncComponent( () => new Promise(_resolve => { resolve = _resolve as any }) ) const root = nodeOps.createElement('div') const app = createApp({ render: () => h(Suspense, null, { default: () => [h(Foo), ' & ', h(Foo)], fallback: () => 'loading' }) }) app.mount(root) expect(serializeInner(root)).toBe('loading') resolve!(() => 'resolved') await timeout() expect(serializeInner(root)).toBe('resolved & resolved') }) test('suspensible: false', async () => { let resolve: (comp: Component) => void const Foo = defineAsyncComponent({ loader: () => new Promise(_resolve => { resolve = _resolve as any }), suspensible: false }) const root = nodeOps.createElement('div') const app = createApp({ render: () => h(Suspense, null, { default: () => [h(Foo), ' & ', h(Foo)], fallback: () => 'loading' }) }) app.mount(root) // should not show suspense fallback expect(serializeInner(root)).toBe(' & ') resolve!(() => 'resolved') await timeout() expect(serializeInner(root)).toBe('resolved & resolved') }) test('suspense with error handling', async () => { let reject: (e: Error) => void const Foo = defineAsyncComponent( () => new Promise((_resolve, _reject) => { reject = _reject }) ) const root = nodeOps.createElement('div') const app = createApp({ render: () => h(Suspense, null, { default: () => [h(Foo), ' & ', h(Foo)], fallback: () => 'loading' }) }) const handler = (app.config.errorHandler = jest.fn()) app.mount(root) expect(serializeInner(root)).toBe('loading') reject!(new Error('no')) await timeout() expect(handler).toHaveBeenCalled() expect(serializeInner(root)).toBe(' & ') }) test('retry (success)', async () => { let loaderCallCount = 0 let resolve: (comp: Component) => void let reject: (e: Error) => void const Foo = defineAsyncComponent({ loader: () => { loaderCallCount++ return new Promise((_resolve, _reject) => { resolve = _resolve as any reject = _reject }) }, onError(error, retry, fail) { if (error.message.match(/foo/)) { retry() } else { fail() } } }) const root = nodeOps.createElement('div') const app = createApp({ render: () => h(Foo) }) const handler = (app.config.errorHandler = jest.fn()) app.mount(root) expect(serializeInner(root)).toBe('') expect(loaderCallCount).toBe(1) const err = new Error('foo') reject!(err) await timeout() expect(handler).not.toHaveBeenCalled() expect(loaderCallCount).toBe(2) expect(serializeInner(root)).toBe('') // should render this time resolve!(() => 'resolved') await timeout() expect(handler).not.toHaveBeenCalled() expect(serializeInner(root)).toBe('resolved') }) test('retry (skipped)', async () => { let loaderCallCount = 0 let reject: (e: Error) => void const Foo = defineAsyncComponent({ loader: () => { loaderCallCount++ return new Promise((_resolve, _reject) => { reject = _reject }) }, onError(error, retry, fail) { if (error.message.match(/bar/)) { retry() } else { fail() } } }) const root = nodeOps.createElement('div') const app = createApp({ render: () => h(Foo) }) const handler = (app.config.errorHandler = jest.fn()) app.mount(root) expect(serializeInner(root)).toBe('') expect(loaderCallCount).toBe(1) const err = new Error('foo') reject!(err) await timeout() // should fail because retryWhen returns false expect(handler).toHaveBeenCalled() expect(handler.mock.calls[0][0]).toBe(err) expect(loaderCallCount).toBe(1) expect(serializeInner(root)).toBe('') }) test('retry (fail w/ max retry attempts)', async () => { let loaderCallCount = 0 let reject: (e: Error) => void const Foo = defineAsyncComponent({ loader: () => { loaderCallCount++ return new Promise((_resolve, _reject) => { reject = _reject }) }, onError(error, retry, fail, attempts) { if (error.message.match(/foo/) && attempts <= 1) { retry() } else { fail() } } }) const root = nodeOps.createElement('div') const app = createApp({ render: () => h(Foo) }) const handler = (app.config.errorHandler = jest.fn()) app.mount(root) expect(serializeInner(root)).toBe('') expect(loaderCallCount).toBe(1) // first retry const err = new Error('foo') reject!(err) await timeout() expect(handler).not.toHaveBeenCalled() expect(loaderCallCount).toBe(2) expect(serializeInner(root)).toBe('') // 2nd retry, should fail due to reaching maxRetries reject!(err) await timeout() expect(handler).toHaveBeenCalled() expect(handler.mock.calls[0][0]).toBe(err) expect(loaderCallCount).toBe(2) expect(serializeInner(root)).toBe('') }) })