import { nextTick, onActivated, ref } from '@vue/runtime-dom' import { type VaporComponent, createComponent } from '../src/component' import { defineVaporAsyncComponent } from '../src/apiDefineAsyncComponent' import { makeRender } from './_utils' import { VaporKeepAlive, createIf, createTemplateRefSetter, defineVaporComponent, renderEffect, template, withVaporCtx, } from '@vue/runtime-vapor' import { setElementText } from '../src/dom/prop' const timeout = (n: number = 0) => new Promise(r => setTimeout(r, n)) const define = makeRender() describe('api: defineAsyncComponent', () => { test('simple usage', async () => { let resolve: (comp: VaporComponent) => void const Foo = defineVaporAsyncComponent( () => new Promise(r => { resolve = r as any }), ) const toggle = ref(true) const { html } = define({ setup() { return createIf( () => toggle.value, () => { return createComponent(Foo) }, ) }, }).render() expect(html()).toBe('') resolve!(() => template('resolved')()) await timeout() expect(html()).toBe('resolved') toggle.value = false await nextTick() expect(html()).toBe('') // already resolved component should update on nextTick toggle.value = true await nextTick() expect(html()).toBe('resolved') }) test('with loading component', async () => { let resolve: (comp: VaporComponent) => void const Foo = defineVaporAsyncComponent({ loader: () => new Promise(r => { resolve = r as any }), loadingComponent: () => template('loading')(), delay: 1, // defaults to 200 }) const toggle = ref(true) const { html } = define({ setup() { return createIf( () => toggle.value, () => { return createComponent(Foo) }, ) }, }).render() // due to the delay, initial mount should be empty expect(html()).toBe('') // loading show up after delay await timeout(1) expect(html()).toBe('loading') resolve!(() => template('resolved')()) await timeout() expect(html()).toBe('resolved') toggle.value = false await nextTick() expect(html()).toBe('') // already resolved component should update on nextTick without loading // state toggle.value = true await nextTick() expect(html()).toBe('resolved') }) test('with loading component + explicit delay (0)', async () => { let resolve: (comp: VaporComponent) => void const Foo = defineVaporAsyncComponent({ loader: () => new Promise(r => { resolve = r as any }), loadingComponent: () => template('loading')(), delay: 0, }) const toggle = ref(true) const { html } = define({ setup() { return createIf( () => toggle.value, () => { return createComponent(Foo) }, ) }, }).render() // with delay: 0, should show loading immediately expect(html()).toBe('loading') resolve!(() => template('resolved')()) await timeout() expect(html()).toBe('resolved') toggle.value = false await nextTick() expect(html()).toBe('') // already resolved component should update on nextTick without loading // state toggle.value = true await nextTick() expect(html()).toBe('resolved') }) test('error without error component', async () => { let resolve: (comp: VaporComponent) => void let reject: (e: Error) => void const Foo = defineVaporAsyncComponent( () => new Promise((_resolve, _reject) => { resolve = _resolve as any reject = _reject }), ) const toggle = ref(true) const { app, mount } = define({ setup() { return createIf( () => toggle.value, () => { return createComponent(Foo) }, ) }, }).create() const handler = (app.config.errorHandler = vi.fn()) const root = document.createElement('div') mount(root) expect(root.innerHTML).toBe('') const err = new Error('foo') reject!(err) await timeout() expect(handler).toHaveBeenCalled() expect(handler.mock.calls[0][0]).toBe(err) expect(root.innerHTML).toBe('') toggle.value = false await nextTick() expect(root.innerHTML).toBe('') // errored out on previous load, toggle and mock success this time toggle.value = true await nextTick() expect(root.innerHTML).toBe('') // should render this time resolve!(() => template('resolved')()) await timeout() expect(root.innerHTML).toBe('resolved') }) test('error with error component', async () => { let resolve: (comp: VaporComponent) => void let reject: (e: Error) => void const Foo = defineVaporAsyncComponent({ loader: () => new Promise((_resolve, _reject) => { resolve = _resolve as any reject = _reject }), errorComponent: (props: { error: Error }) => template(props.error.message)(), }) const toggle = ref(true) const { app, mount } = define({ setup() { return createIf( () => toggle.value, () => { return createComponent(Foo) }, ) }, }).create() const handler = (app.config.errorHandler = vi.fn()) const root = document.createElement('div') mount(root) expect(root.innerHTML).toBe('') const err = new Error('errored out') reject!(err) await timeout() expect(handler).toHaveBeenCalled() expect(root.innerHTML).toBe('errored out') toggle.value = false await nextTick() expect(root.innerHTML).toBe('') // errored out on previous load, toggle and mock success this time toggle.value = true await nextTick() expect(root.innerHTML).toBe('') // should render this time resolve!(() => template('resolved')()) await timeout() expect(root.innerHTML).toBe('resolved') }) test('error with error component, without global handler', async () => { let resolve: (comp: VaporComponent) => void let reject: (e: Error) => void const Foo = defineVaporAsyncComponent({ loader: () => new Promise((_resolve, _reject) => { resolve = _resolve as any reject = _reject }), errorComponent: (props: { error: Error }) => template(props.error.message)(), }) const toggle = ref(true) const { mount } = define({ setup() { return createIf( () => toggle.value, () => { return createComponent(Foo) }, ) }, }).create() const root = document.createElement('div') mount(root) expect(root.innerHTML).toBe('') const err = new Error('errored out') reject!(err) await timeout() expect(root.innerHTML).toBe('errored out') expect( 'Unhandled error during execution of async component loader', ).toHaveBeenWarned() toggle.value = false await nextTick() expect(root.innerHTML).toBe('') // errored out on previous load, toggle and mock success this time toggle.value = true await nextTick() expect(root.innerHTML).toBe('') // should render this time resolve!(() => template('resolved')()) await timeout() expect(root.innerHTML).toBe('resolved') }) test('error with error + loading components', async () => { let resolve: (comp: VaporComponent) => void let reject: (e: Error) => void const Foo = defineVaporAsyncComponent({ loader: () => new Promise((_resolve, _reject) => { resolve = _resolve as any reject = _reject }), errorComponent: (props: { error: Error }) => template(props.error.message)(), loadingComponent: () => template('loading')(), delay: 1, }) const toggle = ref(true) const { app, mount } = define({ setup() { return createIf( () => toggle.value, () => { return createComponent(Foo) }, ) }, }).create() const handler = (app.config.errorHandler = vi.fn()) const root = document.createElement('div') mount(root) // due to the delay, initial mount should be empty expect(root.innerHTML).toBe('') // loading show up after delay await timeout(1) expect(root.innerHTML).toBe('loading') const err = new Error('errored out') reject!(err) await timeout() expect(handler).toHaveBeenCalled() expect(root.innerHTML).toBe('errored out') toggle.value = false await nextTick() expect(root.innerHTML).toBe('') // errored out on previous load, toggle and mock success this time toggle.value = true await nextTick() expect(root.innerHTML).toBe('') // loading show up after delay await timeout(1) expect(root.innerHTML).toBe('loading') // should render this time resolve!(() => template('resolved')()) await timeout() expect(root.innerHTML).toBe('resolved') }) test('timeout without error component', async () => { let resolve: (comp: VaporComponent) => void const Foo = defineVaporAsyncComponent({ loader: () => new Promise(_resolve => { resolve = _resolve as any }), timeout: 1, }) const { app, mount } = define({ setup() { return createComponent(Foo) }, }).create() const handler = vi.fn() app.config.errorHandler = handler const root = document.createElement('div') mount(root) expect(root.innerHTML).toBe('') await timeout(1) expect(handler).toHaveBeenCalled() expect(handler.mock.calls[0][0].message).toMatch( `Async component timed out after 1ms.`, ) expect(root.innerHTML).toBe('') // if it resolved after timeout, should still work resolve!(() => template('resolved')()) await timeout() expect(root.innerHTML).toBe('resolved') }) test('timeout with error component', async () => { let resolve: (comp: VaporComponent) => void const Foo = defineVaporAsyncComponent({ loader: () => new Promise(_resolve => { resolve = _resolve as any }), timeout: 1, errorComponent: () => template('timed out')(), }) const root = document.createElement('div') const { app, mount } = define({ setup() { return createComponent(Foo) }, }).create() const handler = (app.config.errorHandler = vi.fn()) mount(root) expect(root.innerHTML).toBe('') await timeout(1) expect(handler).toHaveBeenCalled() expect(root.innerHTML).toBe('timed out') // if it resolved after timeout, should still work resolve!(() => template('resolved')()) await timeout() expect(root.innerHTML).toBe('resolved') }) test('timeout with error + loading components', async () => { let resolve: (comp: VaporComponent) => void const Foo = defineVaporAsyncComponent({ loader: () => new Promise(_resolve => { resolve = _resolve as any }), delay: 1, timeout: 16, errorComponent: () => template('timed out')(), loadingComponent: () => template('loading')(), }) const root = document.createElement('div') const { app, mount } = define({ setup() { return createComponent(Foo) }, }).create() const handler = (app.config.errorHandler = vi.fn()) mount(root) expect(root.innerHTML).toBe('') await timeout(1) expect(root.innerHTML).toBe('loading') await timeout(16) expect(root.innerHTML).toBe('timed out') expect(handler).toHaveBeenCalled() resolve!(() => template('resolved')()) await timeout() expect(root.innerHTML).toBe('resolved') }) test('timeout without error component, but with loading component', async () => { let resolve: (comp: VaporComponent) => void const Foo = defineVaporAsyncComponent({ loader: () => new Promise(_resolve => { resolve = _resolve as any }), delay: 1, timeout: 16, loadingComponent: () => template('loading')(), }) const root = document.createElement('div') const { app, mount } = define({ setup() { return createComponent(Foo) }, }).create() const handler = vi.fn() app.config.errorHandler = handler mount(root) expect(root.innerHTML).toBe('') await timeout(1) expect(root.innerHTML).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(root.innerHTML).toBe('loading') resolve!(() => template('resolved')()) await timeout() expect(root.innerHTML).toBe('resolved') }) test('retry (success)', async () => { let loaderCallCount = 0 let resolve: (comp: VaporComponent) => void let reject: (e: Error) => void const Foo = defineVaporAsyncComponent({ 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 = document.createElement('div') const { app, mount } = define({ setup() { return createComponent(Foo) }, }).create() const handler = (app.config.errorHandler = vi.fn()) mount(root) expect(root.innerHTML).toBe('') expect(loaderCallCount).toBe(1) const err = new Error('foo') reject!(err) await timeout() expect(handler).not.toHaveBeenCalled() expect(loaderCallCount).toBe(2) expect(root.innerHTML).toBe('') // should render this time resolve!(() => template('resolved')()) await timeout() expect(handler).not.toHaveBeenCalled() expect(root.innerHTML).toBe('resolved') }) test('retry (skipped)', async () => { let loaderCallCount = 0 let reject: (e: Error) => void const Foo = defineVaporAsyncComponent({ loader: () => { loaderCallCount++ return new Promise((_resolve, _reject) => { reject = _reject }) }, onError(error, retry, fail) { if (error.message.match(/bar/)) { retry() } else { fail() } }, }) const root = document.createElement('div') const { app, mount } = define({ setup() { return createComponent(Foo) }, }).create() const handler = (app.config.errorHandler = vi.fn()) mount(root) expect(root.innerHTML).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(root.innerHTML).toBe('') }) test('retry (fail w/ max retry attempts)', async () => { let loaderCallCount = 0 let reject: (e: Error) => void const Foo = defineVaporAsyncComponent({ 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 = document.createElement('div') const { app, mount } = define({ setup() { return createComponent(Foo) }, }).create() const handler = (app.config.errorHandler = vi.fn()) mount(root) expect(root.innerHTML).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(root.innerHTML).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(root.innerHTML).toBe('') }) test('template ref forwarding', async () => { let resolve: (comp: VaporComponent) => void const Foo = defineVaporAsyncComponent( () => new Promise(r => { resolve = r as any }), ) const fooRef = ref(null) const toggle = ref(true) const root = document.createElement('div') const { mount } = define({ setup() { return { fooRef, toggle } }, render() { return createIf( () => toggle.value, () => { const setTemplateRef = createTemplateRefSetter() const n0 = createComponent(Foo, null, null, true) setTemplateRef(n0, 'fooRef') return n0 }, ) }, }).create() mount(root) expect(root.innerHTML).toBe('') expect(fooRef.value).toBe(null) resolve!({ setup: (props, { expose }) => { expose({ id: 'foo', }) return template('resolved')() }, }) // first time resolve, wait for macro task since there are multiple // microtasks / .then() calls await timeout() expect(root.innerHTML).toBe('resolved') expect(fooRef.value.id).toBe('foo') toggle.value = false await nextTick() expect(root.innerHTML).toBe('') expect(fooRef.value).toBe(null) // already resolved component should update on nextTick toggle.value = true await nextTick() expect(root.innerHTML).toBe('resolved') expect(fooRef.value.id).toBe('foo') }) test('template ref forwarding should not keep stale ref callbacks before resolve', async () => { let resolve: (comp: VaporComponent) => void const Foo = defineVaporAsyncComponent( () => new Promise(r => { resolve = r as any }), ) const refA = ref(null) const refB = ref(null) const useA = ref(true) const root = document.createElement('div') const { mount } = define({ setup() { return { refA, refB, useA } }, render() { const setTemplateRef = createTemplateRefSetter() const n0 = createComponent(Foo, null, null, true) renderEffect(() => { setTemplateRef(n0, useA.value ? 'refA' : 'refB') }) return n0 }, }).create() mount(root) expect(root.innerHTML).toBe('') expect(refA.value).toBe(null) expect(refB.value).toBe(null) useA.value = false await nextTick() useA.value = true await nextTick() resolve!({ setup: (props, { expose }) => { expose({ id: 'foo', }) return template('resolved')() }, }) await timeout() expect(root.innerHTML).toBe('resolved') expect(refA.value.id).toBe('foo') expect(refB.value).toBe(null) }) test('template ref forwarding should not keep stale ref callbacks after resolve', async () => { let resolve: (comp: VaporComponent) => void const Foo = defineVaporAsyncComponent( () => new Promise(r => { resolve = r as any }), ) const refA = ref(null) const refB = ref(null) const useA = ref(true) const root = document.createElement('div') let asyncWrapper: any const { mount } = define({ setup() { return { refA, refB, useA } }, render() { const setTemplateRef = createTemplateRefSetter() const n0 = (asyncWrapper = createComponent(Foo, null, null, true)) renderEffect(() => { setTemplateRef(n0, useA.value ? 'refA' : 'refB') }) return n0 }, }).create() mount(root) expect(root.innerHTML).toBe('') expect(refA.value).toBe(null) expect(refB.value).toBe(null) resolve!({ setup: (props, { expose }) => { expose({ id: 'foo', }) return template('resolved')() }, }) await timeout() expect(root.innerHTML).toBe('resolved') expect(refA.value.id).toBe('foo') expect(refB.value).toBe(null) useA.value = false await nextTick() expect(refA.value).toBe(null) expect(refB.value.id).toBe('foo') const onUpdated = asyncWrapper.block.onUpdated if (onUpdated) onUpdated.forEach((hook: any) => hook()) await nextTick() expect(refA.value).toBe(null) expect(refB.value.id).toBe('foo') }) test('the forwarded template ref should always exist when doing multi patching', async () => { let resolve: (comp: VaporComponent) => void const Foo = defineVaporAsyncComponent( () => new Promise(r => { resolve = r as any }), ) const fooRef = ref(null) const toggle = ref(true) const updater = ref(0) const root = document.createElement('div') const { mount } = define({ setup() { return { fooRef, toggle, updater } }, render() { return createIf( () => toggle.value, () => { const setTemplateRef = createTemplateRefSetter() const n0 = createComponent(Foo, null, null, true) setTemplateRef(n0, 'fooRef') const n1 = template(``)() renderEffect(() => setElementText(n1, updater.value)) return [n0, n1] }, ) }, }).create() mount(root) expect(root.innerHTML).toBe('0') expect(fooRef.value).toBe(null) resolve!({ setup: (props, { expose }) => { expose({ id: 'foo', }) return template('resolved')() }, }) await timeout() expect(root.innerHTML).toBe( 'resolved0', ) expect(fooRef.value.id).toBe('foo') updater.value++ await nextTick() expect(root.innerHTML).toBe( 'resolved1', ) expect(fooRef.value.id).toBe('foo') toggle.value = false await nextTick() expect(root.innerHTML).toBe('') expect(fooRef.value).toBe(null) }) test.todo('with suspense', async () => {}) test.todo('suspensible: false', async () => {}) test.todo('suspense with error handling', async () => {}) test('with KeepAlive', async () => { const spy = vi.fn() let resolve: (comp: VaporComponent) => void const Foo = defineVaporAsyncComponent( () => new Promise(r => { resolve = r as any }), ) const Bar = defineVaporAsyncComponent(() => Promise.resolve( defineVaporComponent({ setup() { return template('Bar')() }, }), ), ) const toggle = ref(true) const { html } = define({ setup() { return createComponent(VaporKeepAlive, null, { default: withVaporCtx(() => createIf( () => toggle.value, () => createComponent(Foo), () => createComponent(Bar), ), ), }) }, }).render() expect(html()).toBe('') await nextTick() resolve!( defineVaporComponent({ setup() { onActivated(() => { spy() }) return template('Foo')() }, }), ) await timeout() expect(html()).toBe('Foo') expect(spy).toBeCalledTimes(1) toggle.value = false await timeout() expect(html()).toBe('Bar') }) test('with KeepAlive + include', async () => { const spy = vi.fn() let resolve: (comp: VaporComponent) => void const Foo = defineVaporAsyncComponent( () => new Promise(r => { resolve = r as any }), ) const { html } = define({ setup() { return createComponent( VaporKeepAlive, { include: () => 'Foo' }, { default: withVaporCtx(() => createComponent(Foo)), }, ) }, }).render() expect(html()).toBe('') await nextTick() resolve!( defineVaporComponent({ name: 'Foo', setup() { onActivated(() => { spy() }) return template('Foo')() }, }), ) await timeout() expect(html()).toBe('Foo') expect(spy).toBeCalledTimes(1) }) })