From: daiwei Date: Mon, 19 May 2025 03:36:06 +0000 (+0800) Subject: feat(vapor): suspense interop with Vapor components X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=771379cf7d7517b5dd2514d8b49a37dd08dc9c14;p=thirdparty%2Fvuejs%2Fcore.git feat(vapor): suspense interop with Vapor components --- diff --git a/packages/runtime-core/src/apiCreateApp.ts b/packages/runtime-core/src/apiCreateApp.ts index 5bdd204cfa..f9208014c4 100644 --- a/packages/runtime-core/src/apiCreateApp.ts +++ b/packages/runtime-core/src/apiCreateApp.ts @@ -27,7 +27,7 @@ import { warn } from './warning' import type { VNode } from './vnode' import { devtoolsInitApp, devtoolsUnmountApp } from './devtools' import { NO, extend, isFunction, isObject } from '@vue/shared' -import { version } from '.' +import { type SuspenseBoundary, version } from '.' import { installAppCompatProperties } from './compat/global' import type { NormalizedPropsOptions } from './componentProps' import type { ObjectEmitsOptions } from './componentEmits' @@ -182,6 +182,8 @@ export interface VaporInteropInterface { container: any, anchor: any, parentComponent: ComponentInternalInstance | null, + parentSuspense: SuspenseBoundary | null, + isSingleRoot?: boolean, ): GenericComponentInstance // VaporComponentInstance update(n1: VNode, n2: VNode, shouldUpdate: boolean): void unmount(vnode: VNode, doRemove?: boolean): void diff --git a/packages/runtime-core/src/component.ts b/packages/runtime-core/src/component.ts index f6ff8803c8..86fc717eb2 100644 --- a/packages/runtime-core/src/component.ts +++ b/packages/runtime-core/src/component.ts @@ -438,6 +438,19 @@ export interface GenericComponentInstance { * @internal */ suspense: SuspenseBoundary | null + /** + * suspense pending batch id + * @internal + */ + suspenseId: number + /** + * @internal + */ + asyncDep: Promise | null + /** + * @internal + */ + asyncResolved: boolean // lifecycle /** diff --git a/packages/runtime-core/src/components/Suspense.ts b/packages/runtime-core/src/components/Suspense.ts index 0f6f69c652..205e234d7a 100644 --- a/packages/runtime-core/src/components/Suspense.ts +++ b/packages/runtime-core/src/components/Suspense.ts @@ -692,7 +692,7 @@ function createSuspenseBoundary( if (isInPendingSuspense) { suspense.deps++ } - const hydratedEl = instance.vnode.el + const hydratedEl = instance.vapor ? null : instance.vnode.el instance .asyncDep!.catch(err => { handleError(err, instance, ErrorCodes.SETUP_FUNCTION) @@ -709,37 +709,44 @@ function createSuspenseBoundary( } // retry from this component instance.asyncResolved = true - const { vnode } = instance - if (__DEV__) { - pushWarningContext(vnode) - } - handleSetupResult(instance, asyncSetupResult, false) - if (hydratedEl) { - // vnode may have been replaced if an update happened before the - // async dep is resolved. - vnode.el = hydratedEl - } - const placeholder = !hydratedEl && instance.subTree.el - setupRenderEffect( - instance, - vnode, - // component may have been moved before resolve. - // if this is not a hydration, instance.subTree will be the comment - // placeholder. - parentNode(hydratedEl || instance.subTree.el!)!, - // anchor will not be used if this is hydration, so only need to - // consider the comment placeholder case. - hydratedEl ? null : next(instance.subTree), - suspense, - namespace, - optimized, - ) - if (placeholder) { - remove(placeholder) - } - updateHOCHostEl(instance, vnode.el) - if (__DEV__) { - popWarningContext() + + // vapor component + if (instance.vapor) { + // @ts-expect-error + setupRenderEffect(asyncSetupResult) + } else { + const { vnode } = instance + if (__DEV__) { + pushWarningContext(vnode) + } + handleSetupResult(instance, asyncSetupResult, false) + if (hydratedEl) { + // vnode may have been replaced if an update happened before the + // async dep is resolved. + vnode.el = hydratedEl + } + const placeholder = !hydratedEl && instance.subTree.el + setupRenderEffect( + instance, + vnode, + // component may have been moved before resolve. + // if this is not a hydration, instance.subTree will be the comment + // placeholder. + parentNode(hydratedEl || instance.subTree.el!)!, + // anchor will not be used if this is hydration, so only need to + // consider the comment placeholder case. + hydratedEl ? null : next(instance.subTree), + suspense, + namespace, + optimized, + ) + if (placeholder) { + remove(placeholder) + } + updateHOCHostEl(instance, vnode.el) + if (__DEV__) { + popWarningContext() + } } // only decrease deps count if suspense is not already resolved if (isInPendingSuspense && --suspense.deps === 0) { diff --git a/packages/runtime-core/src/index.ts b/packages/runtime-core/src/index.ts index e309554f2f..eacee712a6 100644 --- a/packages/runtime-core/src/index.ts +++ b/packages/runtime-core/src/index.ts @@ -557,3 +557,7 @@ export { startMeasure, endMeasure } from './profiling' * @internal */ export { initFeatureFlags } from './featureFlags' +/** + * @internal + */ +export { getComponentName } from './component' diff --git a/packages/runtime-core/src/renderer.ts b/packages/runtime-core/src/renderer.ts index 5a18d62a8e..423eeb971a 100644 --- a/packages/runtime-core/src/renderer.ts +++ b/packages/runtime-core/src/renderer.ts @@ -1169,6 +1169,7 @@ function baseCreateRenderer( container, anchor, parentComponent, + parentSuspense, ) } else { getVaporInterface(parentComponent, n2).update( diff --git a/packages/runtime-vapor/src/component.ts b/packages/runtime-vapor/src/component.ts index 548babebf8..05ddfe8806 100644 --- a/packages/runtime-vapor/src/component.ts +++ b/packages/runtime-vapor/src/component.ts @@ -15,6 +15,7 @@ import { currentInstance, endMeasure, expose, + getComponentName, nextUid, popWarningContext, pushWarningContext, @@ -35,7 +36,13 @@ import { resetTracking, unref, } from '@vue/reactivity' -import { EMPTY_OBJ, invokeArrayFns, isFunction, isString } from '@vue/shared' +import { + EMPTY_OBJ, + invokeArrayFns, + isFunction, + isPromise, + isString, +} from '@vue/shared' import { type DynamicPropsSource, type RawProps, @@ -137,6 +144,7 @@ export function createComponent( appContext: GenericAppContext = (currentInstance && currentInstance.appContext) || emptyContext, + parentSuspense?: SuspenseBoundary | null, ): VaporComponentInstance { const _insertionParent = insertionParent const _insertionAnchor = insertionAnchor @@ -180,6 +188,7 @@ export function createComponent( rawProps as RawProps, rawSlots as RawSlots, appContext, + parentSuspense, ) if (__DEV__) { @@ -207,56 +216,24 @@ export function createComponent( ]) || EMPTY_OBJ : EMPTY_OBJ - if (__DEV__ && !isBlock(setupResult)) { - if (isFunction(component)) { - warn(`Functional vapor component must return a block directly.`) - instance.block = [] - } else if (!component.render) { + const isAsyncSetup = isPromise(setupResult) + if (__FEATURE_SUSPENSE__ && isAsyncSetup) { + // async setup returned Promise. + // bail here and wait for re-entry. + instance.asyncDep = setupResult + if (__DEV__ && !instance.suspense) { + const name = getComponentName(component, true) ?? 'Anonymous' warn( - `Vapor component setup() returned non-block value, and has no render function.`, + `Component <${name}>: setup function returned a promise, but no ` + + ` boundary was found in the parent component tree. ` + + `A component with async setup() must be nested in a ` + + `in order to be rendered.`, ) - instance.block = [] - } else { - instance.devtoolsRawSetupState = setupResult - // TODO make the proxy warn non-existent property access during dev - instance.setupState = proxyRefs(setupResult) - devRender(instance) - - // HMR - if (component.__hmrId) { - registerHMR(instance) - instance.isSingleRoot = isSingleRoot - instance.hmrRerender = hmrRerender.bind(null, instance) - instance.hmrReload = hmrReload.bind(null, instance) - } - } - } else { - // component has a render function but no setup function - // (typically components with only a template and no state) - if (!setupFn && component.render) { - instance.block = callWithErrorHandling( - component.render, - instance, - ErrorCodes.RENDER_FUNCTION, - ) - } else { - // in prod result can only be block - instance.block = setupResult as Block } } - // single root, inherit attrs - if ( - instance.hasFallthrough && - component.inheritAttrs !== false && - instance.block instanceof Element && - Object.keys(instance.attrs).length - ) { - renderEffect(() => { - isApplyingFallthroughProps = true - setDynamicProps(instance.block as Element, [instance.attrs]) - isApplyingFallthroughProps = false - }) + if (!isAsyncSetup) { + handleSetupResult(setupResult, component, instance, isSingleRoot, setupFn) } resetTracking() @@ -269,7 +246,7 @@ export function createComponent( onScopeDispose(() => unmountComponent(instance), true) - if (!isHydrating && _insertionParent) { + if (!isHydrating && _insertionParent && !isAsyncSetup) { insert(instance.block, _insertionParent, _insertionAnchor) } @@ -342,6 +319,9 @@ export class VaporComponentInstance implements GenericComponentInstance { ids: [string, number, number] // for suspense suspense: SuspenseBoundary | null + suspenseId: number + asyncDep: Promise | null + asyncResolved: boolean hasFallthrough: boolean @@ -380,6 +360,7 @@ export class VaporComponentInstance implements GenericComponentInstance { rawProps?: RawProps | null, rawSlots?: RawSlots | null, appContext?: GenericAppContext, + suspense?: SuspenseBoundary | null, ) { this.vapor = true this.uid = nextUid() @@ -403,12 +384,13 @@ export class VaporComponentInstance implements GenericComponentInstance { this.emit = emit.bind(null, this) this.expose = expose.bind(null, this) this.refs = EMPTY_OBJ - this.emitted = - this.exposed = - this.exposeProxy = - this.propsDefaults = - this.suspense = - null + this.emitted = this.exposed = this.exposeProxy = this.propsDefaults = null + + // suspense related + this.suspense = suspense! + this.suspenseId = suspense ? suspense.pendingId : 0 + this.asyncDep = null + this.asyncResolved = false this.isMounted = this.isUnmounted = @@ -545,3 +527,70 @@ export function getExposed( ) } } + +export function handleSetupResult( + setupResult: any, + component: VaporComponent, + instance: VaporComponentInstance, + isSingleRoot: boolean | undefined, + setupFn: VaporSetupFn | undefined, +): void { + if (__DEV__) { + pushWarningContext(instance) + } + if (__DEV__ && !isBlock(setupResult)) { + if (isFunction(component)) { + warn(`Functional vapor component must return a block directly.`) + instance.block = [] + } else if (!component.render) { + warn( + `Vapor component setup() returned non-block value, and has no render function.`, + ) + instance.block = [] + } else { + instance.devtoolsRawSetupState = setupResult + // TODO make the proxy warn non-existent property access during dev + instance.setupState = proxyRefs(setupResult) + devRender(instance) + + // HMR + if (component.__hmrId) { + registerHMR(instance) + instance.isSingleRoot = isSingleRoot + instance.hmrRerender = hmrRerender.bind(null, instance) + instance.hmrReload = hmrReload.bind(null, instance) + } + } + } else { + // component has a render function but no setup function + // (typically components with only a template and no state) + if (!setupFn && component.render) { + instance.block = callWithErrorHandling( + component.render, + instance, + ErrorCodes.RENDER_FUNCTION, + ) + } else { + // in prod result can only be block + instance.block = setupResult as Block + } + } + + // single root, inherit attrs + if ( + instance.hasFallthrough && + component.inheritAttrs !== false && + instance.block instanceof Element && + Object.keys(instance.attrs).length + ) { + renderEffect(() => { + isApplyingFallthroughProps = true + setDynamicProps(instance.block as Element, [instance.attrs]) + isApplyingFallthroughProps = false + }) + } + + if (__DEV__) { + popWarningContext() + } +} diff --git a/packages/runtime-vapor/src/vdomInterop.ts b/packages/runtime-vapor/src/vdomInterop.ts index 77228fd72a..373be8b653 100644 --- a/packages/runtime-vapor/src/vdomInterop.ts +++ b/packages/runtime-vapor/src/vdomInterop.ts @@ -23,6 +23,7 @@ import { type VaporComponent, VaporComponentInstance, createComponent, + handleSetupResult, mountComponent, unmountComponent, } from './component' @@ -39,7 +40,14 @@ const vaporInteropImpl: Omit< VaporInteropInterface, 'vdomMount' | 'vdomUnmount' | 'vdomSlot' > = { - mount(vnode, container, anchor, parentComponent) { + mount( + vnode, + container, + anchor, + parentComponent, + parentSuspense, + isSingleRoot, + ) { const selfAnchor = (vnode.el = vnode.anchor = createTextNode()) container.insertBefore(selfAnchor, anchor) const prev = currentInstance @@ -48,19 +56,41 @@ const vaporInteropImpl: Omit< const propsRef = shallowRef(vnode.props) const slotsRef = shallowRef(vnode.children) + const component = vnode.type as any as VaporComponent // @ts-expect-error const instance = (vnode.component = createComponent( - vnode.type as any as VaporComponent, + component, { $: [() => propsRef.value], } as RawProps, { _: slotsRef, // pass the slots ref } as any as RawSlots, + isSingleRoot, + undefined, + parentSuspense, )) instance.rawPropsRef = propsRef instance.rawSlotsRef = slotsRef - mountComponent(instance, container, selfAnchor) + if (__FEATURE_SUSPENSE__ && instance.asyncDep) { + parentSuspense && + parentSuspense.registerDep( + instance as any, + setupResult => { + handleSetupResult( + setupResult, + component, + instance, + isSingleRoot, + isFunction(component) ? component : component.setup, + ) + mountComponent(instance, container, selfAnchor) + }, + false, + ) + } else { + mountComponent(instance, container, selfAnchor) + } simpleSetCurrentInstance(prev) return instance },