From: edison Date: Mon, 20 Oct 2025 07:44:55 +0000 (+0800) Subject: feat(vapor): defineVaporAsyncComponent (#13059) X-Git-Tag: v3.6.0-alpha.3~31 X-Git-Url: http://git.ipfire.org/gitweb.cgi?a=commitdiff_plain;h=6ec403f09795fad0a061bf360479b6eccc435c5f;p=thirdparty%2Fvuejs%2Fcore.git feat(vapor): defineVaporAsyncComponent (#13059) --- diff --git a/packages-private/vapor-e2e-test/__tests__/vdomInterop.spec.ts b/packages-private/vapor-e2e-test/__tests__/vdomInterop.spec.ts index eda60f14ee..df190fe8e1 100644 --- a/packages-private/vapor-e2e-test/__tests__/vdomInterop.spec.ts +++ b/packages-private/vapor-e2e-test/__tests__/vdomInterop.spec.ts @@ -41,12 +41,6 @@ const buffer = process.env.CI ? 50 : 20 const transitionFinish = (time = duration) => timeout(time + buffer) describe('vdom / vapor interop', () => { - beforeEach(async () => { - const baseUrl = `http://localhost:${port}/interop/` - await page().goto(baseUrl) - await page().waitForSelector('#app') - }) - test( 'should work', async () => { @@ -104,6 +98,21 @@ 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, + ) + }) + describe('keepalive', () => { test( 'render vapor component', diff --git a/packages-private/vapor-e2e-test/interop/App.vue b/packages-private/vapor-e2e-test/interop/App.vue index ae7a2b8ee6..30e99a2e5e 100644 --- a/packages-private/vapor-e2e-test/interop/App.vue +++ b/packages-private/vapor-e2e-test/interop/App.vue @@ -1,6 +1,7 @@ + + diff --git a/packages/runtime-core/src/apiAsyncComponent.ts b/packages/runtime-core/src/apiAsyncComponent.ts index 1ff9fa067e..1b7d60c8b2 100644 --- a/packages/runtime-core/src/apiAsyncComponent.ts +++ b/packages/runtime-core/src/apiAsyncComponent.ts @@ -13,7 +13,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' @@ -25,10 +25,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 @@ -47,75 +47,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', @@ -129,6 +74,7 @@ export function defineAsyncComponent< // skip hydration if the component has been patched if (patched) { if (__DEV__) { + const resolvedComp = getResolvedComp()! warn( `Skipping lazy hydration for component '${getComponentName(resolvedComp!) || resolvedComp!.__file}': ` + `it was updated before lazy hydration performed.`, @@ -148,7 +94,7 @@ export function defineAsyncComponent< } } : performHydrate - if (resolvedComp) { + if (getResolvedComp()) { doHydrate() } else { load().then(() => !instance.isUnmounted && doHydrate()) @@ -156,7 +102,7 @@ export function defineAsyncComponent< }, get __asyncResolved() { - return resolvedComp + return getResolvedComp() }, setup() { @@ -164,12 +110,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, @@ -198,27 +145,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(() => { @@ -239,6 +170,7 @@ export function defineAsyncComponent< }) return () => { + resolvedComp = getResolvedComp() if (loaded.value && resolvedComp) { return createInnerComp(resolvedComp, instance) } else if (error.value && errorComponent) { @@ -268,3 +200,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 d0d4686af3..d0fae060a3 100644 --- a/packages/runtime-core/src/index.ts +++ b/packages/runtime-core/src/index.ts @@ -567,6 +567,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 */ diff --git a/packages/runtime-vapor/__tests__/apiDefineAsyncComponent.spec.ts b/packages/runtime-vapor/__tests__/apiDefineAsyncComponent.spec.ts new file mode 100644 index 0000000000..fa7f481707 --- /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 0000000000..b06e255f79 --- /dev/null +++ b/packages/runtime-vapor/src/apiDefineAsyncComponent.ts @@ -0,0 +1,138 @@ +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 + // @ts-expect-error + 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 8d5d8585a5..5ddba415ab 100644 --- a/packages/runtime-vapor/src/apiTemplateRef.ts +++ b/packages/runtime-vapor/src/apiTemplateRef.ts @@ -10,6 +10,7 @@ import { type SchedulerJob, callWithErrorHandling, createCanSetSetupRefChecker, + isAsyncWrapper, queuePostFlushCb, warn, } from '@vue/runtime-dom' @@ -60,6 +61,20 @@ export function setRef( 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) const refs = diff --git a/packages/runtime-vapor/src/component.ts b/packages/runtime-vapor/src/component.ts index 755554e996..0ab6334211 100644 --- a/packages/runtime-vapor/src/component.ts +++ b/packages/runtime-vapor/src/component.ts @@ -104,6 +104,8 @@ export interface ObjectVaporComponent name?: string vapor?: boolean + __asyncLoader?: () => Promise + __asyncResolved?: VaporComponent } interface SharedInternalOptions { diff --git a/packages/runtime-vapor/src/index.ts b/packages/runtime-vapor/src/index.ts index 6e8f4d9694..61128da8f5 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' export { VaporKeepAliveImpl as VaporKeepAlive } from './components/KeepAlive'