import { type VaporComponent, createComponent } from '../src/component'
import { defineVaporAsyncComponent } from '../src/apiDefineAsyncComponent'
import { makeRender } from './_utils'
-import { createIf, template } from '@vue/runtime-vapor'
+import { createIf, createTemplateRefSetter, template } from '@vue/runtime-vapor'
const timeout = (n: number = 0) => new Promise(r => setTimeout(r, n))
await nextTick()
expect(html()).toBe('resolved<!--async component--><!--if-->')
})
+
+ 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('<!--async component--><!--if-->')
+
+ // loading show up after delay
+ await timeout(1)
+ expect(html()).toBe('loading<!--async component--><!--if-->')
+
+ resolve!(() => template('resolved')())
+ await timeout()
+ expect(html()).toBe('resolved<!--async component--><!--if-->')
+
+ toggle.value = false
+ await nextTick()
+ expect(html()).toBe('<!--if-->')
+
+ // already resolved component should update on nextTick without loading
+ // state
+ toggle.value = true
+ await nextTick()
+ expect(html()).toBe('resolved<!--async component--><!--if-->')
+ })
+
+ 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<!--async component--><!--if-->')
+
+ resolve!(() => template('resolved')())
+ await timeout()
+ expect(html()).toBe('resolved<!--async component--><!--if-->')
+
+ toggle.value = false
+ await nextTick()
+ expect(html()).toBe('<!--if-->')
+
+ // already resolved component should update on nextTick without loading
+ // state
+ toggle.value = true
+ await nextTick()
+ expect(html()).toBe('resolved<!--async component--><!--if-->')
+ })
+
+ 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('<!--async component--><!--if-->')
+
+ const err = new Error('foo')
+ reject!(err)
+ await timeout()
+ expect(handler).toHaveBeenCalled()
+ expect(handler.mock.calls[0][0]).toBe(err)
+ expect(root.innerHTML).toBe('<!--async component--><!--if-->')
+
+ toggle.value = false
+ await nextTick()
+ expect(root.innerHTML).toBe('<!--if-->')
+
+ // errored out on previous load, toggle and mock success this time
+ toggle.value = true
+ await nextTick()
+ expect(root.innerHTML).toBe('<!--async component--><!--if-->')
+
+ // should render this time
+ resolve!(() => template('resolved')())
+ await timeout()
+ expect(root.innerHTML).toBe('resolved<!--async component--><!--if-->')
+ })
+
+ 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('<!--async component--><!--if-->')
+
+ const err = new Error('errored out')
+ reject!(err)
+ await timeout()
+ expect(handler).toHaveBeenCalled()
+ expect(root.innerHTML).toBe('errored out<!--async component--><!--if-->')
+
+ toggle.value = false
+ await nextTick()
+ expect(root.innerHTML).toBe('<!--if-->')
+
+ // errored out on previous load, toggle and mock success this time
+ toggle.value = true
+ await nextTick()
+ expect(root.innerHTML).toBe('<!--async component--><!--if-->')
+
+ // should render this time
+ resolve!(() => template('resolved')())
+ await timeout()
+ expect(root.innerHTML).toBe('resolved<!--async component--><!--if-->')
+ })
+
+ 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('<!--async component--><!--if-->')
+
+ const err = new Error('errored out')
+ reject!(err)
+ await timeout()
+ expect(root.innerHTML).toBe('errored out<!--async component--><!--if-->')
+ expect(
+ 'Unhandled error during execution of async component loader',
+ ).toHaveBeenWarned()
+
+ toggle.value = false
+ await nextTick()
+ expect(root.innerHTML).toBe('<!--if-->')
+
+ // errored out on previous load, toggle and mock success this time
+ toggle.value = true
+ await nextTick()
+ expect(root.innerHTML).toBe('<!--async component--><!--if-->')
+
+ // should render this time
+ resolve!(() => template('resolved')())
+ await timeout()
+ expect(root.innerHTML).toBe('resolved<!--async component--><!--if-->')
+ })
+
+ 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('<!--async component--><!--if-->')
+
+ // loading show up after delay
+ await timeout(1)
+ expect(root.innerHTML).toBe('loading<!--async component--><!--if-->')
+
+ const err = new Error('errored out')
+ reject!(err)
+ await timeout()
+ expect(handler).toHaveBeenCalled()
+ expect(root.innerHTML).toBe('errored out<!--async component--><!--if-->')
+
+ toggle.value = false
+ await nextTick()
+ expect(root.innerHTML).toBe('<!--if-->')
+
+ // errored out on previous load, toggle and mock success this time
+ toggle.value = true
+ await nextTick()
+ expect(root.innerHTML).toBe('<!--async component--><!--if-->')
+
+ // loading show up after delay
+ await timeout(1)
+ expect(root.innerHTML).toBe('loading<!--async component--><!--if-->')
+
+ // should render this time
+ resolve!(() => template('resolved')())
+ await timeout()
+ expect(root.innerHTML).toBe('resolved<!--async component--><!--if-->')
+ })
+
+ 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('<!--async component-->')
+
+ await timeout(1)
+ expect(handler).toHaveBeenCalled()
+ expect(handler.mock.calls[0][0].message).toMatch(
+ `Async component timed out after 1ms.`,
+ )
+ expect(root.innerHTML).toBe('<!--async component-->')
+
+ // if it resolved after timeout, should still work
+ resolve!(() => template('resolved')())
+ await timeout()
+ expect(root.innerHTML).toBe('resolved<!--async component-->')
+ })
+
+ 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('<!--async component-->')
+
+ await timeout(1)
+ expect(handler).toHaveBeenCalled()
+ expect(root.innerHTML).toBe('timed out<!--async component-->')
+
+ // if it resolved after timeout, should still work
+ resolve!(() => template('resolved')())
+ await timeout()
+ expect(root.innerHTML).toBe('resolved<!--async component-->')
+ })
+
+ 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('<!--async component-->')
+ await timeout(1)
+ expect(root.innerHTML).toBe('loading<!--async component-->')
+
+ await timeout(16)
+ expect(root.innerHTML).toBe('timed out<!--async component-->')
+ expect(handler).toHaveBeenCalled()
+
+ resolve!(() => template('resolved')())
+ await timeout()
+ expect(root.innerHTML).toBe('resolved<!--async component-->')
+ })
+
+ 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('<!--async component-->')
+ await timeout(1)
+ expect(root.innerHTML).toBe('loading<!--async component-->')
+
+ 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<!--async component-->')
+
+ resolve!(() => template('resolved')())
+ await timeout()
+ expect(root.innerHTML).toBe('resolved<!--async component-->')
+ })
+
+ test.todo('with suspense', async () => {})
+
+ test.todo('suspensible: false', async () => {})
+
+ test.todo('suspense with error handling', async () => {})
+
+ 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('<!--async component-->')
+ 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('<!--async component-->')
+
+ // should render this time
+ resolve!(() => template('resolved')())
+ await timeout()
+ expect(handler).not.toHaveBeenCalled()
+ expect(root.innerHTML).toBe('resolved<!--async component-->')
+ })
+
+ 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('<!--async component-->')
+ 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('<!--async component-->')
+ })
+
+ 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('<!--async component-->')
+ 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('<!--async component-->')
+
+ // 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('<!--async component-->')
+ })
+
+ test.todo('template ref forwarding', async () => {
+ let resolve: (comp: VaporComponent) => void
+ const Foo = defineVaporAsyncComponent(
+ () =>
+ new Promise(r => {
+ resolve = r as any
+ }),
+ )
+
+ const fooRef = ref<any>(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('<!--async component--><!--if-->')
+ 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<!--async component--><!--if-->')
+ expect(fooRef.value.id).toBe('foo')
+
+ toggle.value = false
+ await nextTick()
+ expect(root.innerHTML).toBe('<!--if-->')
+ expect(fooRef.value).toBe(null)
+
+ // already resolved component should update on nextTick
+ toggle.value = true
+ await nextTick()
+ expect(root.innerHTML).toBe('resolved<!--async component--><!--if-->')
+ expect(fooRef.value.id).toBe('foo')
+ })
+
+ test.todo(
+ 'the forwarded template ref should always exist when doing multi patching',
+ async () => {},
+ )
+
+ test.todo('with KeepAlive', async () => {})
+
+ test.todo('with KeepAlive + include', async () => {})
})