diff --git a/packages-private/vapor-e2e-test/__tests__/vdomInterop.spec.ts b/packages-private/vapor-e2e-test/__tests__/vdomInterop.spec.ts index 360f48085a1..32461df61af 100644 --- a/packages-private/vapor-e2e-test/__tests__/vdomInterop.spec.ts +++ b/packages-private/vapor-e2e-test/__tests__/vdomInterop.spec.ts @@ -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() -describe('vdom / vapor interop', () => { - const { page, click, text, enterValue } = setupPuppeteer() +const duration = process.env.CI ? 200 : 50 +describe('vdom / vapor interop', () => { 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('loading...') + + await timeout(duration) + expect(await html(testContainer)).toBe('
foo
') + }, + E2E_TIMEOUT, + ) + }) }) diff --git a/packages-private/vapor-e2e-test/interop/App.vue b/packages-private/vapor-e2e-test/interop/App.vue index 772a6989dd7..c8c6c945da1 100644 --- a/packages-private/vapor-e2e-test/interop/App.vue +++ b/packages-private/vapor-e2e-test/interop/App.vue @@ -1,9 +1,23 @@ diff --git a/packages-private/vapor-e2e-test/interop/VaporComp.vue b/packages-private/vapor-e2e-test/interop/components/VaporComp.vue similarity index 96% rename from packages-private/vapor-e2e-test/interop/VaporComp.vue rename to packages-private/vapor-e2e-test/interop/components/VaporComp.vue index 88a60c782c0..09b08154ae3 100644 --- a/packages-private/vapor-e2e-test/interop/VaporComp.vue +++ b/packages-private/vapor-e2e-test/interop/components/VaporComp.vue @@ -27,7 +27,8 @@ const slotProp = ref('slot prop') change slot prop
- #default: + #default: +
#test: fallback content diff --git a/packages-private/vapor-e2e-test/interop/VdomComp.vue b/packages-private/vapor-e2e-test/interop/components/VdomComp.vue similarity index 100% rename from packages-private/vapor-e2e-test/interop/VdomComp.vue rename to packages-private/vapor-e2e-test/interop/components/VdomComp.vue diff --git a/packages-private/vapor-e2e-test/interop/components/VdomFoo.vue b/packages-private/vapor-e2e-test/interop/components/VdomFoo.vue new file mode 100644 index 00000000000..ee13cfbb1ab --- /dev/null +++ b/packages-private/vapor-e2e-test/interop/components/VdomFoo.vue @@ -0,0 +1,5 @@ + + + diff --git a/packages/runtime-core/src/apiAsyncComponent.ts b/packages/runtime-core/src/apiAsyncComponent.ts index 07e7fc67fef..8eee833ac4b 100644 --- a/packages/runtime-core/src/apiAsyncComponent.ts +++ b/packages/runtime-core/src/apiAsyncComponent.ts @@ -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 = () => Promise< AsyncComponentResolveResult > -export interface AsyncComponentOptions { +export interface AsyncComponentOptions { loader: AsyncComponentLoader - 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 | AsyncComponentOptions): T { - if (isFunction(source)) { - source = { loader: source } - } - +>(source: AsyncComponentLoader | AsyncComponentOptions): T { const { - loader, - loadingComponent, - errorComponent, - delay = 200, - hydrate: hydrateStrategy, - timeout, // undefined = never times out - suspensible = true, - onError: userOnError, - } = source - - let pendingRequest: Promise | null = null - let resolvedComp: ConcreteComponent | undefined - - let retries = 0 - const retry = () => { - retries++ - pendingRequest = null - return load() - } - - const load = (): Promise => { - let thisRequest: Promise - 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 = { + load: () => Promise + source: AsyncComponentOptions + getResolvedComp: () => C | undefined + setPendingRequest: (request: Promise | null) => void +} + +// shared between core and vapor +export function createAsyncComponentContext( + source: AsyncComponentLoader | AsyncComponentOptions, +): AsyncComponentContext { + if (isFunction(source)) { + source = { loader: source } + } + + const { loader, onError: userOnError } = source + let pendingRequest: Promise | null = null + let resolvedComp: C | undefined + + let retries = 0 + const retry = () => { + retries++ + pendingRequest = null + return load() + } + + const load = (): Promise => { + let thisRequest: Promise + 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 | null) => + (pendingRequest = request), + } +} + +// shared between core and vapor +export const useAsyncComponentState = ( + delay: number | undefined, + timeout: number | undefined, + onError: (err: Error) => void, +): { + loaded: Ref + error: Ref + delayed: Ref +} => { + 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 } +} diff --git a/packages/runtime-core/src/index.ts b/packages/runtime-core/src/index.ts index c7150e38e80..62677d732b8 100644 --- a/packages/runtime-core/src/index.ts +++ b/packages/runtime-core/src/index.ts @@ -557,3 +557,15 @@ export { startMeasure, endMeasure } from './profiling' * @internal */ export { initFeatureFlags } from './featureFlags' +/** + * @internal + */ +export { + createAsyncComponentContext, + useAsyncComponentState, + isAsyncWrapper, +} from './apiAsyncComponent' +/** + * @internal + */ +export { markAsyncBoundary } from './helpers/useId' diff --git a/packages/runtime-vapor/__tests__/apiDefineAsyncComponent.spec.ts b/packages/runtime-vapor/__tests__/apiDefineAsyncComponent.spec.ts new file mode 100644 index 00000000000..fa7f481707c --- /dev/null +++ b/packages/runtime-vapor/__tests__/apiDefineAsyncComponent.spec.ts @@ -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('') + 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('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.todo('with KeepAlive', async () => {}) + + test.todo('with KeepAlive + include', async () => {}) +}) diff --git a/packages/runtime-vapor/src/apiDefineAsyncComponent.ts b/packages/runtime-vapor/src/apiDefineAsyncComponent.ts new file mode 100644 index 00000000000..ddd91c06c8b --- /dev/null +++ b/packages/runtime-vapor/src/apiDefineAsyncComponent.ts @@ -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( + source: AsyncComponentLoader | AsyncComponentOptions, +): T { + const { + load, + getResolvedComp, + setPendingRequest, + source: { + loadingComponent, + errorComponent, + delay, + // hydrate: hydrateStrategy, + timeout, + // suspensible = true, + }, + } = createAsyncComponentContext(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 +} diff --git a/packages/runtime-vapor/src/apiTemplateRef.ts b/packages/runtime-vapor/src/apiTemplateRef.ts index c5a6c5fb2b6..1ec0d65ef10 100644 --- a/packages/runtime-vapor/src/apiTemplateRef.ts +++ b/packages/runtime-vapor/src/apiTemplateRef.ts @@ -9,6 +9,7 @@ import { ErrorCodes, type SchedulerJob, callWithErrorHandling, + isAsyncWrapper, queuePostFlushCb, warn, } from '@vue/runtime-dom' @@ -20,6 +21,7 @@ import { isString, remove, } from '@vue/shared' +import type { DynamicFragment } from './block' export type NodeRef = string | Ref | ((ref: Element) => void) export type RefEl = Element | VaporComponentInstance @@ -48,8 +50,24 @@ 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 = isVaporComponent(el) ? getExposed(el) || el : el + const refValue = isVaporComp + ? getExposed(el as VaporComponentInstance) || el + : el const refs = instance.refs === EMPTY_OBJ ? (instance.refs = {}) : instance.refs diff --git a/packages/runtime-vapor/src/block.ts b/packages/runtime-vapor/src/block.ts index b782afd38d3..ca2b97a0681 100644 --- a/packages/runtime-vapor/src/block.ts +++ b/packages/runtime-vapor/src/block.ts @@ -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 diff --git a/packages/runtime-vapor/src/component.ts b/packages/runtime-vapor/src/component.ts index 548babebf8b..4dfc6a09b1d 100644 --- a/packages/runtime-vapor/src/component.ts +++ b/packages/runtime-vapor/src/component.ts @@ -92,6 +92,8 @@ export interface ObjectVaporComponent name?: string vapor?: boolean + __asyncLoader?: () => Promise + __asyncResolved?: VaporComponent } interface SharedInternalOptions { diff --git a/packages/runtime-vapor/src/componentProps.ts b/packages/runtime-vapor/src/componentProps.ts index a5e9daad229..fcff365c7a5 100644 --- a/packages/runtime-vapor/src/componentProps.ts +++ b/packages/runtime-vapor/src/componentProps.ts @@ -182,7 +182,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) diff --git a/packages/runtime-vapor/src/index.ts b/packages/runtime-vapor/src/index.ts index 682532fa4d8..7cd81c3e102 100644 --- a/packages/runtime-vapor/src/index.ts +++ b/packages/runtime-vapor/src/index.ts @@ -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'