diff --git a/packages/runtime-core/src/apiCreateApp.ts b/packages/runtime-core/src/apiCreateApp.ts index 5bdd204cfad..31e8ee85df7 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,7 @@ export interface VaporInteropInterface { container: any, anchor: any, parentComponent: ComponentInternalInstance | null, + parentSuspense: SuspenseBoundary | null, ): 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 f6ff8803c87..849173ac7a5 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 /** @@ -926,7 +939,7 @@ function setupStatefulComponent( // bail here and wait for re-entry. instance.asyncDep = setupResult if (__DEV__ && !instance.suspense) { - const name = Component.name ?? 'Anonymous' + const name = getComponentName(Component) ?? 'Anonymous' warn( `Component <${name}>: setup function returned a promise, but no ` + ` boundary was found in the parent component tree. ` + diff --git a/packages/runtime-core/src/components/Suspense.ts b/packages/runtime-core/src/components/Suspense.ts index 0f6f69c6526..e4a76c63ff7 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,46 @@ 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) + + // vapor component + if (instance.vapor) { + // @ts-expect-error + setupRenderEffect(asyncSetupResult) } - updateHOCHostEl(instance, vnode.el) - if (__DEV__) { - popWarningContext() + // vdom component + 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 e309554f2f6..eacee712a6d 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 5a18d62a8e1..3a68b4493ee 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( @@ -2425,7 +2426,7 @@ function baseCreateRenderer( const getNextHostNode: NextFn = vnode => { if (vnode.shapeFlag & ShapeFlags.COMPONENT) { if ((vnode.type as ConcreteComponent).__vapor) { - return hostNextSibling((vnode.component! as any).block) + return hostNextSibling(vnode.anchor!) } return getNextHostNode(vnode.component!.subTree) } diff --git a/packages/runtime-vapor/__tests__/_utils.ts b/packages/runtime-vapor/__tests__/_utils.ts index 0ed64554478..729d42de78c 100644 --- a/packages/runtime-vapor/__tests__/_utils.ts +++ b/packages/runtime-vapor/__tests__/_utils.ts @@ -2,6 +2,10 @@ import { createVaporApp } from '../src' import type { App } from '@vue/runtime-dom' import type { VaporComponent, VaporComponentInstance } from '../src/component' import type { RawProps } from '../src/componentProps' +import { compileScript, parse } from '@vue/compiler-sfc' +import * as runtimeVapor from '../src' +import * as runtimeDom from '@vue/runtime-dom' +import * as VueServerRenderer from '@vue/server-renderer' export interface RenderContext { component: VaporComponent @@ -82,3 +86,50 @@ export function makeRender( return define } + +export { runtimeDom, runtimeVapor, VueServerRenderer } +export function compile( + sfc: string, + data: runtimeDom.Ref, + components: Record = {}, + { + vapor = true, + ssr = false, + }: { + vapor?: boolean | undefined + ssr?: boolean | undefined + } = {}, +): any { + if (!sfc.includes(`const data = _data; const components = _components;` + + sfc + } + const descriptor = parse(sfc).descriptor + + const script = compileScript(descriptor, { + id: 'x', + isProd: true, + inlineTemplate: true, + genDefaultAs: '__sfc__', + vapor, + templateOptions: { + ssr, + }, + }) + + const code = + script.content + .replace(/\bimport {/g, 'const {') + .replace(/ as _/g, ': _') + .replace(/} from ['"]vue['"]/g, `} = Vue`) + .replace(/} from "vue\/server-renderer"/g, '} = VueServerRenderer') + + '\nreturn __sfc__' + + return new Function('Vue', 'VueServerRenderer', '_data', '_components', code)( + { ...runtimeDom, ...runtimeVapor }, + VueServerRenderer, + data, + components, + ) +} diff --git a/packages/runtime-vapor/__tests__/components/Suspense.spec.ts b/packages/runtime-vapor/__tests__/components/Suspense.spec.ts new file mode 100644 index 00000000000..18d33795a03 --- /dev/null +++ b/packages/runtime-vapor/__tests__/components/Suspense.spec.ts @@ -0,0 +1,244 @@ +import { nextTick, reactive } from 'vue' +import { compile, runtimeDom, runtimeVapor } from '../_utils' + +describe.todo('VaporSuspense', () => {}) + +describe('vapor / vdom interop', () => { + async function testSuspense( + code: string, + components: Record = {}, + data: any = {}, + { vapor = false } = {}, + ) { + const clientComponents: any = {} + for (const key in components) { + const comp = components[key] + let code = comp.code + const isVaporComp = !!comp.vapor + clientComponents[key] = compile(code, data, clientComponents, { + vapor: isVaporComp, + }) + } + + const clientComp = compile(code, data, clientComponents, { + vapor, + }) + + const app = (vapor ? runtimeVapor.createVaporApp : runtimeDom.createApp)( + clientComp, + ) + app.use(runtimeVapor.vaporInteropPlugin) + + const container = document.createElement('div') + document.body.appendChild(container) + app.mount(container) + return { container } + } + + function withAsyncScript(code: string) { + return { + code: ` + + ${code} + `, + vapor: true, + } + } + + test('vdom suspense: render vapor components', async () => { + const data = { deps: [] } + const { container } = await testSuspense( + ` + `, + { + VaporChild: withAsyncScript(``), + }, + data, + ) + + expect(container.innerHTML).toBe(`fallback`) + expect(data.deps.length).toBe(1) + await Promise.all(data.deps) + await nextTick() + expect(container.innerHTML).toBe(`
hi
`) + }) + + test('vdom suspense: nested async vapor components', async () => { + const data = { deps: [] } + const { container } = await testSuspense( + ` + `, + { + AsyncOuter: withAsyncScript( + ``, + ), + AsyncInner: withAsyncScript(``), + }, + data, + ) + + expect(container.innerHTML).toBe(`fallback`) + + await data.deps[0] + await nextTick() + expect(container.innerHTML).toBe(`fallback`) + + await Promise.all(data.deps) + await nextTick() + expect(container.innerHTML).toBe(`
inner
`) + }) + + test('vdom suspense: content update before suspense resolve', async () => { + const data = reactive({ msg: 'foo', deps: [] }) + const { container } = await testSuspense( + ` + `, + { + VaporChild: withAsyncScript( + ``, + ), + }, + data, + ) + + expect(container.innerHTML).toBe(`fallback foo`) + + data.msg = 'bar' + await nextTick() + expect(container.innerHTML).toBe(`fallback bar`) + + await Promise.all(data.deps) + await nextTick() + expect(container.innerHTML).toBe(`
bar
`) + }) + + test('vdom suspense: unmount before suspense resolve', async () => { + const data = reactive({ show: true, deps: [] }) + const { container } = await testSuspense( + ` + `, + { + VaporChild: withAsyncScript(``), + }, + data, + ) + + expect(container.innerHTML).toBe(`fallback`) + + data.show = false + await nextTick() + expect(container.innerHTML).toBe(``) + + await Promise.all(data.deps) + await nextTick() + expect(container.innerHTML).toBe(``) + }) + + test('vdom suspense: unmount suspense after resolve', async () => { + const data = reactive({ show: true, deps: [] }) + const { container } = await testSuspense( + ` + `, + { + VaporChild: withAsyncScript(``), + }, + data, + ) + + expect(container.innerHTML).toBe(`fallback`) + + await Promise.all(data.deps) + await nextTick() + expect(container.innerHTML).toBe(`
child
`) + + data.show = false + await nextTick() + expect(container.innerHTML).toBe(``) + }) + + test('vdom suspense: unmount suspense before resolve', async () => { + const data = reactive({ show: true, deps: [] }) + const { container } = await testSuspense( + ` + `, + { + VaporChild: withAsyncScript(``), + }, + data, + ) + + expect(container.innerHTML).toBe(`fallback`) + + data.show = false + await nextTick() + expect(container.innerHTML).toBe(``) + + await Promise.all(data.deps) + await nextTick() + expect(container.innerHTML).toBe(``) + }) +}) diff --git a/packages/runtime-vapor/src/component.ts b/packages/runtime-vapor/src/component.ts index 548babebf8b..b169f012609 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, @@ -60,6 +67,7 @@ import { import { hmrReload, hmrRerender } from './hmr' import { isHydrating, locateHydrationNode } from './dom/hydration' import { insertionAnchor, insertionParent } from './insertionState' +import { parentSuspense } from './components/Suspense' export { currentInstance } from '@vue/runtime-dom' @@ -180,6 +188,7 @@ export function createComponent( rawProps as RawProps, rawSlots as RawSlots, appContext, + parentSuspense, ) if (__DEV__) { @@ -207,56 +216,29 @@ 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 (isAsyncSetup) { + if (__FEATURE_SUSPENSE__) { + // async setup returned Promise. + // bail here and wait for re-entry. + instance.asyncDep = setupResult + if (__DEV__ && !instance.suspense) { + const name = getComponentName(component) ?? 'Anonymous' + warn( + `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.`, + ) + } + } else if (__DEV__) { warn( - `Vapor component setup() returned non-block value, and has no render function.`, + `setup() returned a Promise, but the version of Vue you are using ` + + `does not support it yet.`, ) - 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 - }) + handleSetupResult(setupResult, component, instance, isSingleRoot, setupFn) } resetTracking() @@ -269,7 +251,7 @@ export function createComponent( onScopeDispose(() => unmountComponent(instance), true) - if (!isHydrating && _insertionParent) { + if (!isHydrating && _insertionParent && !isAsyncSetup) { insert(instance.block, _insertionParent, _insertionAnchor) } @@ -342,6 +324,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 +365,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 +389,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 = @@ -496,6 +483,30 @@ export function mountComponent( parent: ParentNode, anchor?: Node | null | 0, ): void { + if ( + __FEATURE_SUSPENSE__ && + instance.suspense && + instance.asyncDep && + !instance.asyncResolved + ) { + const component = instance.type as any + instance.suspense.registerDep( + instance as any, + (setupResult: any) => { + handleSetupResult( + setupResult, + component, + instance, + undefined, + isFunction(component) ? component : component.setup, + ) + mountComponent(instance, parent, anchor) + }, + false, + ) + return + } + if (__DEV__) { startMeasure(instance, `mount`) } @@ -545,3 +556,70 @@ export function getExposed( ) } } + +export function handleSetupResult( + setupResult: any, + component: VaporComponent, + instance: VaporComponentInstance, + isSingleRoot?: boolean, + setupFn?: VaporSetupFn, +): 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/components/Suspense.ts b/packages/runtime-vapor/src/components/Suspense.ts new file mode 100644 index 00000000000..5dafe584265 --- /dev/null +++ b/packages/runtime-vapor/src/components/Suspense.ts @@ -0,0 +1,13 @@ +import type { SuspenseBoundary } from '@vue/runtime-dom' + +export let parentSuspense: SuspenseBoundary | null = null +export function setParentSuspense(suspense: SuspenseBoundary | null): void { + parentSuspense = suspense +} + +// TODO +export const VaporSuspenseImpl = { + name: 'VaporSuspense', + __isSuspense: true, + process(): void {}, +} diff --git a/packages/runtime-vapor/src/vdomInterop.ts b/packages/runtime-vapor/src/vdomInterop.ts index 77228fd72a0..b93eb903f65 100644 --- a/packages/runtime-vapor/src/vdomInterop.ts +++ b/packages/runtime-vapor/src/vdomInterop.ts @@ -33,13 +33,14 @@ import type { RawSlots, VaporSlot } from './componentSlots' import { renderEffect } from './renderEffect' import { createTextNode } from './dom/node' import { optimizePropertyLookup } from './dom/prop' +import { setParentSuspense } from './components/Suspense' // mounting vapor components and slots in vdom const vaporInteropImpl: Omit< VaporInteropInterface, 'vdomMount' | 'vdomUnmount' | 'vdomSlot' > = { - mount(vnode, container, anchor, parentComponent) { + mount(vnode, container, anchor, parentComponent, parentSuspense) { const selfAnchor = (vnode.el = vnode.anchor = createTextNode()) container.insertBefore(selfAnchor, anchor) const prev = currentInstance @@ -48,15 +49,22 @@ const vaporInteropImpl: Omit< const propsRef = shallowRef(vnode.props) const slotsRef = shallowRef(vnode.children) + if (__FEATURE_SUSPENSE__) { + setParentSuspense(parentSuspense) + } + + 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, + undefined, + undefined, )) instance.rawPropsRef = propsRef instance.rawSlotsRef = slotsRef @@ -77,8 +85,30 @@ const vaporInteropImpl: Omit< unmount(vnode, doRemove) { const container = doRemove ? vnode.anchor!.parentNode : undefined - if (vnode.component) { - unmountComponent(vnode.component as any, container) + const instance = vnode.component as any as VaporComponentInstance + if (instance) { + // the async component may not be resolved yet, block is null + if (instance.block) { + unmountComponent(instance, container) + } + // A component with async dep inside a pending suspense is unmounted before + // its async dep resolves. This should remove the dep from the suspense, and + // cause the suspense to resolve immediately if that was the last dep. + const parentSuspense = instance.suspense + if ( + __FEATURE_SUSPENSE__ && + parentSuspense && + parentSuspense.pendingBranch && + !parentSuspense.isUnmounted && + instance.asyncDep && + !instance.asyncResolved && + instance.suspenseId === parentSuspense.pendingId + ) { + parentSuspense.deps-- + if (parentSuspense.deps === 0) { + parentSuspense.resolve() + } + } } else if (vnode.vb) { remove(vnode.vb, container) }