chore: Merge branch 'edison/feat/vaporAsyncComponent' into edison/testVapor
ci / test (push) Waiting to run Details
ci / continuous-release (push) Waiting to run Details

This commit is contained in:
daiwei 2025-06-23 15:53:10 +08:00
commit d922a9a153
12 changed files with 1111 additions and 97 deletions

View File

@ -292,5 +292,19 @@ describe('vdom / vapor interop', () => {
expect(await html(targetSelector)).toBe('') expect(await html(targetSelector)).toBe('')
}) })
}) })
describe('async component', () => {
const container = '.async-component-interop'
test(
'with-vdom-inner-component',
async () => {
const testContainer = `${container} .with-vdom-component`
expect(await html(testContainer)).toBe('<span>loading...</span>')
await timeout(duration)
expect(await html(testContainer)).toBe('<div>foo</div>')
},
E2E_TIMEOUT,
)
})
}) })
}) })

View File

@ -4,6 +4,8 @@ import VaporComp from './components/VaporComp.vue'
import VaporCompA from '../transition/components/VaporCompA.vue' import VaporCompA from '../transition/components/VaporCompA.vue'
import VdomComp from '../transition/components/VdomComp.vue' import VdomComp from '../transition/components/VdomComp.vue'
import VaporSlot from '../transition/components/VaporSlot.vue' import VaporSlot from '../transition/components/VaporSlot.vue'
import { defineVaporAsyncComponent, h } from 'vue'
import VdomFoo from './components/VdomFoo.vue'
const msg = ref('hello') const msg = ref('hello')
const passSlot = ref(true) const passSlot = ref(true)
@ -20,6 +22,18 @@ const enterClick = () => items.value.push('d', 'e')
import SimpleVaporComp from './components/SimpleVaporComp.vue' import SimpleVaporComp from './components/SimpleVaporComp.vue'
const disabled = ref(true) const disabled = ref(true)
const duration = typeof process !== 'undefined' && process.env.CI ? 200 : 50
const AsyncVDomFoo = defineVaporAsyncComponent({
loader: () => {
return new Promise(r => {
setTimeout(() => {
r(VdomFoo as any)
}, duration)
})
},
loadingComponent: () => h('span', 'loading...'),
})
</script> </script>
<template> <template>
@ -83,4 +97,11 @@ const disabled = ref(true)
</div> </div>
</div> </div>
<!-- teleport end--> <!-- teleport end-->
<!-- async component -->
<div class="async-component-interop">
<div class="with-vdom-component">
<AsyncVDomFoo />
</div>
</div>
<!-- async component end -->
</template> </template>

View File

@ -0,0 +1,5 @@
<script setup lang="ts"></script>
<template>
<div>foo</div>
</template>

View File

@ -12,7 +12,7 @@ import type { ComponentPublicInstance } from './componentPublicInstance'
import { type VNode, createVNode } from './vnode' import { type VNode, createVNode } from './vnode'
import { defineComponent } from './apiDefineComponent' import { defineComponent } from './apiDefineComponent'
import { warn } from './warning' import { warn } from './warning'
import { ref } from '@vue/reactivity' import { type Ref, ref } from '@vue/reactivity'
import { ErrorCodes, handleError } from './errorHandling' import { ErrorCodes, handleError } from './errorHandling'
import { isKeepAlive } from './components/KeepAlive' import { isKeepAlive } from './components/KeepAlive'
import { markAsyncBoundary } from './helpers/useId' import { markAsyncBoundary } from './helpers/useId'
@ -24,10 +24,10 @@ export type AsyncComponentLoader<T = any> = () => Promise<
AsyncComponentResolveResult<T> AsyncComponentResolveResult<T>
> >
export interface AsyncComponentOptions<T = any> { export interface AsyncComponentOptions<T = any, C = any> {
loader: AsyncComponentLoader<T> loader: AsyncComponentLoader<T>
loadingComponent?: Component loadingComponent?: C
errorComponent?: Component errorComponent?: C
delay?: number delay?: number
timeout?: number timeout?: number
suspensible?: boolean suspensible?: boolean
@ -46,75 +46,20 @@ export const isAsyncWrapper = (i: GenericComponentInstance | VNode): boolean =>
/*! #__NO_SIDE_EFFECTS__ */ /*! #__NO_SIDE_EFFECTS__ */
export function defineAsyncComponent< export function defineAsyncComponent<
T extends Component = { new (): ComponentPublicInstance }, T extends Component = { new (): ComponentPublicInstance },
>(source: AsyncComponentLoader<T> | AsyncComponentOptions<T>): T { >(source: AsyncComponentLoader<T> | AsyncComponentOptions<T, Component>): T {
if (isFunction(source)) {
source = { loader: source }
}
const { const {
loader, load,
getResolvedComp,
setPendingRequest,
source: {
loadingComponent, loadingComponent,
errorComponent, errorComponent,
delay = 200, delay,
hydrate: hydrateStrategy, hydrate: hydrateStrategy,
timeout, // undefined = never times out timeout,
suspensible = true, suspensible = true,
onError: userOnError, },
} = source } = createAsyncComponentContext(source)
let pendingRequest: Promise<ConcreteComponent> | null = null
let resolvedComp: ConcreteComponent | undefined
let retries = 0
const retry = () => {
retries++
pendingRequest = null
return load()
}
const load = (): Promise<ConcreteComponent> => {
let thisRequest: Promise<ConcreteComponent>
return (
pendingRequest ||
(thisRequest = pendingRequest =
loader()
.catch(err => {
err = err instanceof Error ? err : new Error(String(err))
if (userOnError) {
return new Promise((resolve, reject) => {
const userRetry = () => resolve(retry())
const userFail = () => reject(err)
userOnError(err, userRetry, userFail, retries + 1)
})
} else {
throw err
}
})
.then((comp: any) => {
if (thisRequest !== pendingRequest && pendingRequest) {
return pendingRequest
}
if (__DEV__ && !comp) {
warn(
`Async component loader resolved to undefined. ` +
`If you are using retry(), make sure to return its return value.`,
)
}
// interop module default
if (
comp &&
(comp.__esModule || comp[Symbol.toStringTag] === 'Module')
) {
comp = comp.default
}
if (__DEV__ && comp && !isObject(comp) && !isFunction(comp)) {
throw new Error(`Invalid async component load result: ${comp}`)
}
resolvedComp = comp
return comp
}))
)
}
return defineComponent({ return defineComponent({
name: 'AsyncComponentWrapper', name: 'AsyncComponentWrapper',
@ -132,7 +77,7 @@ export function defineAsyncComponent<
} }
} }
: hydrate : hydrate
if (resolvedComp) { if (getResolvedComp()) {
doHydrate() doHydrate()
} else { } else {
load().then(() => !instance.isUnmounted && doHydrate()) load().then(() => !instance.isUnmounted && doHydrate())
@ -140,7 +85,7 @@ export function defineAsyncComponent<
}, },
get __asyncResolved() { get __asyncResolved() {
return resolvedComp return getResolvedComp()
}, },
setup() { setup() {
@ -148,12 +93,13 @@ export function defineAsyncComponent<
markAsyncBoundary(instance) markAsyncBoundary(instance)
// already resolved // already resolved
let resolvedComp = getResolvedComp()
if (resolvedComp) { if (resolvedComp) {
return () => createInnerComp(resolvedComp!, instance) return () => createInnerComp(resolvedComp!, instance)
} }
const onError = (err: Error) => { const onError = (err: Error) => {
pendingRequest = null setPendingRequest(null)
handleError( handleError(
err, err,
instance, instance,
@ -182,27 +128,11 @@ export function defineAsyncComponent<
}) })
} }
const loaded = ref(false) const { loaded, error, delayed } = useAsyncComponentState(
const error = ref() delay,
const delayed = ref(!!delay) timeout,
onError,
if (delay) {
setTimeout(() => {
delayed.value = false
}, delay)
}
if (timeout != null) {
setTimeout(() => {
if (!loaded.value && !error.value) {
const err = new Error(
`Async component timed out after ${timeout}ms.`,
) )
onError(err)
error.value = err
}
}, timeout)
}
load() load()
.then(() => { .then(() => {
@ -223,6 +153,7 @@ export function defineAsyncComponent<
}) })
return () => { return () => {
resolvedComp = getResolvedComp()
if (loaded.value && resolvedComp) { if (loaded.value && resolvedComp) {
return createInnerComp(resolvedComp, instance) return createInnerComp(resolvedComp, instance)
} else if (error.value && errorComponent) { } else if (error.value && errorComponent) {
@ -252,3 +183,114 @@ function createInnerComp(
return vnode return vnode
} }
type AsyncComponentContext<T, C = ConcreteComponent> = {
load: () => Promise<C>
source: AsyncComponentOptions<T>
getResolvedComp: () => C | undefined
setPendingRequest: (request: Promise<C> | null) => void
}
// shared between core and vapor
export function createAsyncComponentContext<T, C = ConcreteComponent>(
source: AsyncComponentLoader<T> | AsyncComponentOptions<T>,
): AsyncComponentContext<T, C> {
if (isFunction(source)) {
source = { loader: source }
}
const { loader, onError: userOnError } = source
let pendingRequest: Promise<C> | null = null
let resolvedComp: C | undefined
let retries = 0
const retry = () => {
retries++
pendingRequest = null
return load()
}
const load = (): Promise<C> => {
let thisRequest: Promise<C>
return (
pendingRequest ||
(thisRequest = pendingRequest =
loader()
.catch(err => {
err = err instanceof Error ? err : new Error(String(err))
if (userOnError) {
return new Promise((resolve, reject) => {
const userRetry = () => resolve(retry())
const userFail = () => reject(err)
userOnError(err, userRetry, userFail, retries + 1)
})
} else {
throw err
}
})
.then((comp: any) => {
if (thisRequest !== pendingRequest && pendingRequest) {
return pendingRequest
}
if (__DEV__ && !comp) {
warn(
`Async component loader resolved to undefined. ` +
`If you are using retry(), make sure to return its return value.`,
)
}
if (
comp &&
(comp.__esModule || comp[Symbol.toStringTag] === 'Module')
) {
comp = comp.default
}
if (__DEV__ && comp && !isObject(comp) && !isFunction(comp)) {
throw new Error(`Invalid async component load result: ${comp}`)
}
resolvedComp = comp
return comp
}))
)
}
return {
load,
source,
getResolvedComp: () => resolvedComp,
setPendingRequest: (request: Promise<C> | null) =>
(pendingRequest = request),
}
}
// shared between core and vapor
export const useAsyncComponentState = (
delay: number | undefined,
timeout: number | undefined,
onError: (err: Error) => void,
): {
loaded: Ref<boolean>
error: Ref<Error | undefined>
delayed: Ref<boolean>
} => {
const loaded = ref(false)
const error = ref()
const delayed = ref(!!delay)
if (delay) {
setTimeout(() => {
delayed.value = false
}, delay)
}
if (timeout != null) {
setTimeout(() => {
if (!loaded.value && !error.value) {
const err = new Error(`Async component timed out after ${timeout}ms.`)
onError(err)
error.value = err
}
}, timeout)
}
return { loaded, error, delayed }
}

View File

@ -582,6 +582,15 @@ export {
isTeleportDisabled, isTeleportDisabled,
isTeleportDeferred, isTeleportDeferred,
} from './components/Teleport' } from './components/Teleport'
export {
createAsyncComponentContext,
useAsyncComponentState,
isAsyncWrapper,
} from './apiAsyncComponent'
/**
* @internal
*/
export { markAsyncBoundary } from './helpers/useId'
/** /**
* @internal * @internal
*/ */

View File

@ -0,0 +1,764 @@
import { nextTick, ref } from '@vue/runtime-dom'
import { type VaporComponent, createComponent } from '../src/component'
import { defineVaporAsyncComponent } from '../src/apiDefineAsyncComponent'
import { makeRender } from './_utils'
import {
createIf,
createTemplateRefSetter,
renderEffect,
template,
} 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('<!--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
toggle.value = true
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('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('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('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<any>(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(`<span>`)()
renderEffect(() => setElementText(n1, updater.value))
return [n0, n1]
},
)
},
}).create()
mount(root)
expect(root.innerHTML).toBe('<!--async component--><span>0</span><!--if-->')
expect(fooRef.value).toBe(null)
resolve!({
setup: (props, { expose }) => {
expose({
id: 'foo',
})
return template('resolved')()
},
})
await timeout()
expect(root.innerHTML).toBe(
'resolved<!--async component--><span>0</span><!--if-->',
)
expect(fooRef.value.id).toBe('foo')
updater.value++
await nextTick()
expect(root.innerHTML).toBe(
'resolved<!--async component--><span>1</span><!--if-->',
)
expect(fooRef.value.id).toBe('foo')
toggle.value = false
await nextTick()
expect(root.innerHTML).toBe('<!--if-->')
expect(fooRef.value).toBe(null)
})
test.todo('with suspense', async () => {})
test.todo('suspensible: false', async () => {})
test.todo('suspense with error handling', async () => {})
test.todo('with KeepAlive', async () => {})
test.todo('with KeepAlive + include', async () => {})
})

View File

@ -0,0 +1,139 @@
import {
type AsyncComponentLoader,
type AsyncComponentOptions,
ErrorCodes,
createAsyncComponentContext,
currentInstance,
handleError,
markAsyncBoundary,
useAsyncComponentState,
} from '@vue/runtime-dom'
import { defineVaporComponent } from './apiDefineComponent'
import {
type VaporComponent,
type VaporComponentInstance,
createComponent,
} from './component'
import { renderEffect } from './renderEffect'
import { DynamicFragment } from './fragment'
/*! #__NO_SIDE_EFFECTS__ */
export function defineVaporAsyncComponent<T extends VaporComponent>(
source: AsyncComponentLoader<T> | AsyncComponentOptions<T>,
): T {
const {
load,
getResolvedComp,
setPendingRequest,
source: {
loadingComponent,
errorComponent,
delay,
// hydrate: hydrateStrategy,
timeout,
// suspensible = true,
},
} = createAsyncComponentContext<T, VaporComponent>(source)
return defineVaporComponent({
name: 'VaporAsyncComponentWrapper',
__asyncLoader: load,
// __asyncHydrate(el, instance, hydrate) {
// // TODO async hydrate
// },
get __asyncResolved() {
return getResolvedComp()
},
setup() {
const instance = currentInstance as VaporComponentInstance
markAsyncBoundary(instance)
const frag = __DEV__
? new DynamicFragment('async component')
: new DynamicFragment()
// already resolved
let resolvedComp = getResolvedComp()
if (resolvedComp) {
frag.update(() => createInnerComp(resolvedComp!, instance))
return frag
}
const onError = (err: Error) => {
setPendingRequest(null)
handleError(
err,
instance,
ErrorCodes.ASYNC_COMPONENT_LOADER,
!errorComponent /* do not throw in dev if user provided error component */,
)
}
// TODO suspense-controlled or SSR.
const { loaded, error, delayed } = useAsyncComponentState(
delay,
timeout,
onError,
)
load()
.then(() => {
loaded.value = true
// TODO parent is keep-alive, force update so the loaded component's
// name is taken into account
})
.catch(err => {
onError(err)
error.value = err
})
renderEffect(() => {
resolvedComp = getResolvedComp()
let render
if (loaded.value && resolvedComp) {
render = () => createInnerComp(resolvedComp!, instance, frag)
} else if (error.value && errorComponent) {
render = () =>
createComponent(errorComponent, { error: () => error.value })
} else if (loadingComponent && !delayed.value) {
render = () => createComponent(loadingComponent)
}
frag.update(render)
})
return frag
},
}) as T
}
function createInnerComp(
comp: VaporComponent,
parent: VaporComponentInstance,
frag?: DynamicFragment,
): VaporComponentInstance {
const { rawProps, rawSlots, isSingleRoot, appContext } = parent
const instance = createComponent(
comp,
rawProps,
rawSlots,
isSingleRoot,
undefined,
undefined,
appContext,
)
// set ref
frag && frag.setRef && frag.setRef(instance)
// TODO custom element
// pass the custom element callback on to the inner comp
// and remove it from the async wrapper
// i.ce = ce
// delete parent.ce
return instance
}

View File

@ -9,6 +9,7 @@ import {
ErrorCodes, ErrorCodes,
type SchedulerJob, type SchedulerJob,
callWithErrorHandling, callWithErrorHandling,
isAsyncWrapper,
queuePostFlushCb, queuePostFlushCb,
warn, warn,
} from '@vue/runtime-dom' } from '@vue/runtime-dom'
@ -49,6 +50,20 @@ export function setRef(
): NodeRef | undefined { ): NodeRef | undefined {
if (!instance || instance.isUnmounted) return if (!instance || instance.isUnmounted) return
const isVaporComp = isVaporComponent(el)
if (isVaporComp && isAsyncWrapper(el as VaporComponentInstance)) {
const i = el as VaporComponentInstance
const frag = i.block as DynamicFragment
// async component not resolved yet
if (!i.type.__asyncResolved) {
frag.setRef = i => setRef(instance, i, ref, oldRef, refFor)
return
}
// set ref to the inner component instead
el = frag.nodes as VaporComponentInstance
}
const setupState: any = __DEV__ ? instance.setupState || {} : null const setupState: any = __DEV__ ? instance.setupState || {} : null
const refValue = getRefValue(el) const refValue = getRefValue(el)

View File

@ -106,6 +106,8 @@ export interface ObjectVaporComponent
name?: string name?: string
vapor?: boolean vapor?: boolean
__asyncLoader?: () => Promise<VaporComponent>
__asyncResolved?: VaporComponent
} }
interface SharedInternalOptions { interface SharedInternalOptions {

View File

@ -186,7 +186,7 @@ export function getAttrFromRawProps(rawProps: RawProps, key: string): unknown {
source = dynamicSources[i] source = dynamicSources[i]
isDynamic = isFunction(source) isDynamic = isFunction(source)
source = isDynamic ? (source as Function)() : source source = isDynamic ? (source as Function)() : source
if (hasOwn(source, key)) { if (source && hasOwn(source, key)) {
const value = isDynamic ? source[key] : source[key]() const value = isDynamic ? source[key] : source[key]()
if (merged) { if (merged) {
merged.push(value) merged.push(value)

View File

@ -21,6 +21,7 @@ import {
applyTransitionHooks, applyTransitionHooks,
applyTransitionLeaveHooks, applyTransitionLeaveHooks,
} from './components/Transition' } from './components/Transition'
import type { VaporComponentInstance } from './component'
export class VaporFragment implements TransitionOptions { export class VaporFragment implements TransitionOptions {
$key?: any $key?: any
@ -38,6 +39,7 @@ export class VaporFragment implements TransitionOptions {
target?: ParentNode | null target?: ParentNode | null
targetAnchor?: Node | null targetAnchor?: Node | null
getNodes?: () => Block getNodes?: () => Block
setRef?: (comp: VaporComponentInstance) => void
constructor(nodes: Block) { constructor(nodes: Block) {
this.nodes = nodes this.nodes = nodes

View File

@ -1,6 +1,7 @@
// public APIs // public APIs
export { createVaporApp, createVaporSSRApp } from './apiCreateApp' export { createVaporApp, createVaporSSRApp } from './apiCreateApp'
export { defineVaporComponent } from './apiDefineComponent' export { defineVaporComponent } from './apiDefineComponent'
export { defineVaporAsyncComponent } from './apiDefineAsyncComponent'
export { vaporInteropPlugin } from './vdomInterop' export { vaporInteropPlugin } from './vdomInterop'
export type { VaporDirective } from './directives/custom' export type { VaporDirective } from './directives/custom'
export { VaporTeleportImpl as VaporTeleport } from './components/Teleport' export { VaporTeleportImpl as VaporTeleport } from './components/Teleport'