diff --git a/packages/runtime-vapor/src/apiAsyncComponent.ts b/packages/runtime-vapor/src/apiAsyncComponent.ts new file mode 100644 index 000000000..7f7085ccf --- /dev/null +++ b/packages/runtime-vapor/src/apiAsyncComponent.ts @@ -0,0 +1,251 @@ +import { + type Component, + type ComponentInternalInstance, + currentInstance, + getCurrentInstance, +} from './component' +import { isFunction, isObject } from '@vue/shared' +import { defineComponent } from './apiDefineComponent' +import { warn } from './warning' +import { ref } from '@vue/reactivity' +import { VaporErrorCodes, handleError } from './errorHandling' +// import { isKeepAlive } from './components/KeepAlive' +import { queueJob } from './scheduler' +import { createComponent } from './apiCreateComponent' +import { renderEffect } from './renderEffect' +import { createIf } from './apiCreateIf' +import { template } from '@vue/vapor' + +export type AsyncComponentResolveResult = T | { default: T } // es modules + +export type AsyncComponentLoader = () => Promise< + AsyncComponentResolveResult +> + +export interface AsyncComponentOptions { + loader: AsyncComponentLoader + loadingComponent?: Component + errorComponent?: Component + delay?: number + timeout?: number + suspensible?: boolean + onError?: ( + error: Error, + retry: () => void, + fail: () => void, + attempts: number, + ) => any +} + +export const isAsyncWrapper = (i: ComponentInternalInstance): boolean => + !!i.component.__asyncLoader + +/*! #__NO_SIDE_EFFECTS__ */ +export function defineAsyncComponent( + source: AsyncComponentLoader | AsyncComponentOptions, +): T { + if (isFunction(source)) { + source = { loader: source } + } + + const { + loader, + loadingComponent, + errorComponent, + delay = 200, + timeout, // undefined = never times out + suspensible = true, + onError: userOnError, + } = source + + let pendingRequest: Promise | null = null + let resolvedComp: Component | 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 + })) + ) + } + + return defineComponent({ + name: 'AsyncComponentWrapper', + + __asyncLoader: load, + + get __asyncResolved() { + return resolvedComp + }, + + setup() { + const instance = currentInstance! + + // already resolved + if (resolvedComp) { + return createInnerComp(resolvedComp!, instance) + } + + const onError = (err: Error) => { + pendingRequest = null + handleError( + err, + instance, + VaporErrorCodes.ASYNC_COMPONENT_LOADER, + !errorComponent /* do not throw in dev if user provided error component */, + ) + } + + // TODO: handle suspense and SSR. + // suspense-controlled or SSR. + // if ( + // (__FEATURE_SUSPENSE__ && suspensible && instance.suspense) || + // (__SSR__ && isInSSRComponentSetup) + // ) { + // return load() + // .then(comp => { + // return () => createInnerComp(comp, instance) + // }) + // .catch(err => { + // onError(err) + // return () => + // errorComponent + // ? createVNode(errorComponent as ConcreteComponent, { + // error: err, + // }) + // : null + // }) + // } + + 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) + } + + load() + .then(() => { + loaded.value = true + // TODO: handle keep-alive. + // if (instance.parent && isKeepAlive(instance.parent.vnode)) { + // // parent is keep-alive, force update so the loaded component's + // // name is taken into account + // queueJob(instance.parent.update) + // } + }) + .catch(err => { + onError(err) + error.value = err + }) + + // if (loaded.value && resolvedComp) { + // return createInnerComp(resolvedComp, instance) + // } else if (error.value && errorComponent) { + // return createComponent(errorComponent, [{ error: () => error.value }]) + // } else if (loadingComponent && !delayed.value) { + // return createComponent(loadingComponent) + // } + return { + loaded, + error, + delayed, + } + }, + render(ctx) { + const instance = getCurrentInstance()! + return [ + createIf( + () => ctx.loaded && resolvedComp, + () => { + return createInnerComp(resolvedComp!, instance) + }, + () => + createIf( + () => ctx.error && errorComponent, + () => + createComponent(errorComponent!, [{ error: () => ctx.error }]), + () => + createIf( + () => loadingComponent && !ctx.delayed, + () => createComponent(loadingComponent!), + ), + ), + ), + ] + }, + }) as T +} + +function createInnerComp(comp: Component, parent: ComponentInternalInstance) { + const { rawProps: props, rawSlots, rawDynamicSlots } = parent + const innerComp = createComponent(comp, props, rawSlots, rawDynamicSlots) + // const vnode = createVNode(comp, props, children) + // // ensure inner component inherits the async wrapper's ref owner + innerComp.refs = parent.refs + // vnode.ref = ref + // // pass the custom element callback on to the inner comp + // // and remove it from the async wrapper + // vnode.ce = ce + // delete parent.vnode.ce + + return innerComp +} diff --git a/packages/runtime-vapor/src/component.ts b/packages/runtime-vapor/src/component.ts index 228c7b78a..81d4483ca 100644 --- a/packages/runtime-vapor/src/component.ts +++ b/packages/runtime-vapor/src/component.ts @@ -116,6 +116,17 @@ export interface ObjectComponent extends ComponentInternalOptions { emits?: EmitsOptions render?(ctx: any): Block + /** + * marker for AsyncComponentWrapper + * @internal + */ + __asyncLoader?: () => Promise + /** + * the inner component resolved by the AsyncComponentWrapper + * @internal + */ + __asyncResolved?: Component + name?: string vapor?: boolean } @@ -179,6 +190,8 @@ export interface ComponentInternalInstance { emit: EmitFn emitted: Record | null attrs: Data + rawSlots: InternalSlots + rawDynamicSlots: DynamicSlots | null slots: InternalSlots refs: Data // exposed properties via expose() @@ -304,6 +317,8 @@ export function createComponentInstance( emit: null!, emitted: null, attrs: EMPTY_OBJ, + rawSlots: slots || EMPTY_OBJ, + rawDynamicSlots: dynamicSlots || null, slots: EMPTY_OBJ, refs: EMPTY_OBJ, diff --git a/packages/runtime-vapor/src/dom/templateRef.ts b/packages/runtime-vapor/src/dom/templateRef.ts index b3770739a..cdbdecbb9 100644 --- a/packages/runtime-vapor/src/dom/templateRef.ts +++ b/packages/runtime-vapor/src/dom/templateRef.ts @@ -20,6 +20,7 @@ import { } from '@vue/shared' import { warn } from '../warning' import { queuePostFlushCb } from '../scheduler' +import { isAsyncWrapper } from '../apiAsyncComponent' export type NodeRef = string | Ref | ((ref: Element) => void) export type RefEl = Element | ComponentInternalInstance @@ -36,11 +37,14 @@ export function setRef( if (!currentInstance) return const { setupState, isUnmounted } = currentInstance + const isComponent = isVaporComponent(el) + const isAsync = isComponent && isAsyncWrapper(currentInstance) + if (isUnmounted) { return } - const refValue = isVaporComponent(el) ? el.exposed || el : el + const refValue = isComponent ? el.exposed || el : el const refs = currentInstance.refs === EMPTY_OBJ diff --git a/packages/runtime-vapor/src/index.ts b/packages/runtime-vapor/src/index.ts index 60810713e..3ddc5725a 100644 --- a/packages/runtime-vapor/src/index.ts +++ b/packages/runtime-vapor/src/index.ts @@ -98,6 +98,7 @@ export { on, delegate, delegateEvents, setDynamicEvents } from './dom/event' export { setRef } from './dom/templateRef' export { defineComponent } from './apiDefineComponent' +export { defineAsyncComponent } from './apiAsyncComponent' export { type InjectionKey, inject,