This commit is contained in:
edison 2025-06-26 10:08:59 +02:00 committed by GitHub
commit 585dd3f8e1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 1127 additions and 105 deletions

View File

@ -2,13 +2,15 @@ import path from 'node:path'
import {
E2E_TIMEOUT,
setupPuppeteer,
timeout,
} from '../../../packages/vue/__tests__/e2e/e2eUtils'
import connect from 'connect'
import sirv from 'sirv'
const { page, click, text, enterValue, html } = setupPuppeteer()
const duration = process.env.CI ? 200 : 50
describe('vdom / vapor interop', () => {
const { page, click, text, enterValue } = setupPuppeteer()
let server: any
const port = '8193'
beforeAll(() => {
@ -22,12 +24,15 @@ describe('vdom / vapor interop', () => {
server.close()
})
beforeEach(async () => {
const baseUrl = `http://localhost:${port}/interop/`
await page().goto(baseUrl)
await page().waitForSelector('#app')
})
test(
'should work',
async () => {
const baseUrl = `http://localhost:${port}/interop/`
await page().goto(baseUrl)
expect(await text('.vapor > h2')).toContain('Vapor component in VDOM')
expect(await text('.vapor-prop')).toContain('hello')
@ -81,4 +86,19 @@ describe('vdom / vapor interop', () => {
},
E2E_TIMEOUT,
)
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

@ -1,9 +1,23 @@
<script setup lang="ts">
import { ref } from 'vue'
import VaporComp from './VaporComp.vue'
import { ref, defineVaporAsyncComponent, h } from 'vue'
import VaporComp from './components/VaporComp.vue'
import VdomFoo from './components/VdomFoo.vue'
const msg = ref('hello')
const passSlot = 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>
<template>
@ -19,4 +33,12 @@ const passSlot = ref(true)
<template #test v-if="passSlot">A test slot</template>
</VaporComp>
<!-- async component -->
<div class="async-component-interop">
<div class="with-vdom-component">
<AsyncVDomFoo />
</div>
</div>
<!-- async component end -->
</template>

View File

@ -27,7 +27,8 @@ const slotProp = ref('slot prop')
change slot prop
</button>
<div class="vdom-slot-in-vapor-default">
#default: <slot :foo="slotProp" />
#default:
<slot :foo="slotProp" />
</div>
<div class="vdom-slot-in-vapor-test">
#test: <slot name="test">fallback content</slot>

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 { defineComponent } from './apiDefineComponent'
import { warn } from './warning'
import { ref } from '@vue/reactivity'
import { type Ref, ref } from '@vue/reactivity'
import { ErrorCodes, handleError } from './errorHandling'
import { isKeepAlive } from './components/KeepAlive'
import { markAsyncBoundary } from './helpers/useId'
@ -24,10 +24,10 @@ export type AsyncComponentLoader<T = any> = () => Promise<
AsyncComponentResolveResult<T>
>
export interface AsyncComponentOptions<T = any> {
export interface AsyncComponentOptions<T = any, C = any> {
loader: AsyncComponentLoader<T>
loadingComponent?: Component
errorComponent?: Component
loadingComponent?: C
errorComponent?: C
delay?: number
timeout?: number
suspensible?: boolean
@ -46,75 +46,20 @@ export const isAsyncWrapper = (i: GenericComponentInstance | VNode): boolean =>
/*! #__NO_SIDE_EFFECTS__ */
export function defineAsyncComponent<
T extends Component = { new (): ComponentPublicInstance },
>(source: AsyncComponentLoader<T> | AsyncComponentOptions<T>): T {
if (isFunction(source)) {
source = { loader: source }
}
>(source: AsyncComponentLoader<T> | AsyncComponentOptions<T, Component>): T {
const {
loader,
loadingComponent,
errorComponent,
delay = 200,
hydrate: hydrateStrategy,
timeout, // undefined = never times out
suspensible = true,
onError: userOnError,
} = 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
}))
)
}
load,
getResolvedComp,
setPendingRequest,
source: {
loadingComponent,
errorComponent,
delay,
hydrate: hydrateStrategy,
timeout,
suspensible = true,
},
} = createAsyncComponentContext(source)
return defineComponent({
name: 'AsyncComponentWrapper',
@ -132,7 +77,7 @@ export function defineAsyncComponent<
}
}
: hydrate
if (resolvedComp) {
if (getResolvedComp()) {
doHydrate()
} else {
load().then(() => !instance.isUnmounted && doHydrate())
@ -140,7 +85,7 @@ export function defineAsyncComponent<
},
get __asyncResolved() {
return resolvedComp
return getResolvedComp()
},
setup() {
@ -148,12 +93,13 @@ export function defineAsyncComponent<
markAsyncBoundary(instance)
// already resolved
let resolvedComp = getResolvedComp()
if (resolvedComp) {
return () => createInnerComp(resolvedComp!, instance)
}
const onError = (err: Error) => {
pendingRequest = null
setPendingRequest(null)
handleError(
err,
instance,
@ -182,27 +128,11 @@ export function defineAsyncComponent<
})
}
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)
}
const { loaded, error, delayed } = useAsyncComponentState(
delay,
timeout,
onError,
)
load()
.then(() => {
@ -223,6 +153,7 @@ export function defineAsyncComponent<
})
return () => {
resolvedComp = getResolvedComp()
if (loaded.value && resolvedComp) {
return createInnerComp(resolvedComp, instance)
} else if (error.value && errorComponent) {
@ -252,3 +183,114 @@ function createInnerComp(
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

@ -557,6 +557,18 @@ export { startMeasure, endMeasure } from './profiling'
* @internal
*/
export { initFeatureFlags } from './featureFlags'
/**
* @internal
*/
export {
createAsyncComponentContext,
useAsyncComponentState,
isAsyncWrapper,
} from './apiAsyncComponent'
/**
* @internal
*/
export { markAsyncBoundary } from './helpers/useId'
/**
* @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,137 @@
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 { DynamicFragment } from './block'
import { renderEffect } from './renderEffect'
/*! #__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,
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,
type SchedulerJob,
callWithErrorHandling,
isAsyncWrapper,
queuePostFlushCb,
warn,
} from '@vue/runtime-dom'
@ -49,6 +50,20 @@ export function setRef(
): NodeRef | undefined {
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 refValue = getRefValue(el)

View File

@ -23,6 +23,7 @@ export class VaporFragment {
anchor?: Node
insert?: (parent: ParentNode, anchor: Node | null) => void
remove?: (parent?: ParentNode) => void
setRef?: (comp: VaporComponentInstance) => void
constructor(nodes: Block) {
this.nodes = nodes

View File

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

View File

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

View File

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