diff --git a/packages/runtime-core/src/apiCreateApp.ts b/packages/runtime-core/src/apiCreateApp.ts index 2aca5f3ee1e..9bd13d600bc 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, hasOwn, isFunction, isObject } from '@vue/shared' -import { type TransitionHooks, version } from '.' +import { type SuspenseBoundary, type TransitionHooks, version } from '.' import { installAppCompatProperties } from './compat/global' import type { NormalizedPropsOptions } from './componentProps' import type { ObjectEmitsOptions } from './componentEmits' @@ -187,6 +187,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 @@ -198,6 +199,7 @@ export interface VaporInteropInterface { container: any, anchor: any, parentComponent: ComponentInternalInstance | null, + parentSuspense: SuspenseBoundary | null, ): Node hydrateSlot(vnode: VNode, node: any): Node activate( diff --git a/packages/runtime-core/src/component.ts b/packages/runtime-core/src/component.ts index b49cd685425..88faf6306a9 100644 --- a/packages/runtime-core/src/component.ts +++ b/packages/runtime-core/src/component.ts @@ -461,6 +461,19 @@ export interface GenericComponentInstance { * @internal */ suspense: SuspenseBoundary | null + /** + * suspense pending batch id + * @internal + */ + suspenseId: number + /** + * @internal + */ + asyncDep: Promise | null + /** + * @internal + */ + asyncResolved: boolean /** * `updateTeleportCssVars` * For updating css vars on contained teleports diff --git a/packages/runtime-core/src/components/Suspense.ts b/packages/runtime-core/src/components/Suspense.ts index 980ddc73c37..b3463f0b353 100644 --- a/packages/runtime-core/src/components/Suspense.ts +++ b/packages/runtime-core/src/components/Suspense.ts @@ -11,7 +11,10 @@ import { openBlock, } from '../vnode' import { ShapeFlags, isArray, isFunction, toNumber } from '@vue/shared' -import { type ComponentInternalInstance, handleSetupResult } from '../component' +import type { + ComponentInternalInstance, + GenericComponentInstance, +} from '../component' import type { Slots } from '../componentSlots' import { type ElementNamespace, @@ -19,17 +22,11 @@ import { type RendererElement, type RendererInternals, type RendererNode, - type SetupRenderEffectFn, queuePostRenderEffect, } from '../renderer' import { queuePostFlushCb } from '../scheduler' import { filterSingleRoot, updateHOCHostEl } from '../componentRenderUtils' -import { - assertNumber, - popWarningContext, - pushWarningContext, - warn, -} from '../warning' +import { assertNumber, warn } from '../warning' import { ErrorCodes, handleError } from '../errorHandling' import { NULL_DYNAMIC_COMPONENT } from '../helpers/resolveAssets' @@ -436,9 +433,8 @@ export interface SuspenseBoundary { ): void next(): RendererNode | null registerDep( - instance: ComponentInternalInstance, - setupRenderEffect: SetupRenderEffectFn, - optimized: boolean, + instance: GenericComponentInstance, + onResolve: (setupResult: unknown) => void, ): void unmount(parentSuspense: SuspenseBoundary | null, doRemove?: boolean): void } @@ -474,7 +470,7 @@ function createSuspenseBoundary( m: move, um: unmount, n: next, - o: { parentNode, remove }, + o: { parentNode }, } = rendererInternals // if set `suspensible: true`, set the current suspense as a dep of parent suspense @@ -701,12 +697,12 @@ function createSuspenseBoundary( return suspense.activeBranch && next(suspense.activeBranch) }, - registerDep(instance, setupRenderEffect, optimized) { + registerDep(instance, onResolve) { const isInPendingSuspense = !!suspense.pendingBranch if (isInPendingSuspense) { suspense.deps++ } - const hydratedEl = instance.vnode.el + instance .asyncDep!.catch(err => { handleError(err, instance, ErrorCodes.SETUP_FUNCTION) @@ -723,40 +719,8 @@ 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) { - // clean up placeholder reference - vnode.placeholder = null - remove(placeholder) - } - updateHOCHostEl(instance, vnode.el) - if (__DEV__) { - popWarningContext() - } + onResolve(asyncSetupResult) + // only decrease deps count if suspense is not already resolved if (isInPendingSuspense && --suspense.deps === 0) { suspense.resolve() diff --git a/packages/runtime-core/src/hydration.ts b/packages/runtime-core/src/hydration.ts index 32e260c7594..5188c1f1dbc 100644 --- a/packages/runtime-core/src/hydration.ts +++ b/packages/runtime-core/src/hydration.ts @@ -318,6 +318,7 @@ export function createHydrationFunctions( container, null, parentComponent, + parentSuspense, ) } else { mountComponent( diff --git a/packages/runtime-core/src/renderer.ts b/packages/runtime-core/src/renderer.ts index 98a8c350375..c3cb60fd40e 100644 --- a/packages/runtime-core/src/renderer.ts +++ b/packages/runtime-core/src/renderer.ts @@ -24,6 +24,7 @@ import { type LifecycleHook, createComponentInstance, getComponentPublicInstance, + handleSetupResult, setupComponent, } from './component' import { @@ -1186,6 +1187,7 @@ function baseCreateRenderer( container, anchor, parentComponent, + parentSuspense, ) } } else { @@ -1272,9 +1274,45 @@ function baseCreateRenderer( // setup() is async. This component relies on async logic to be resolved // before proceeding if (__FEATURE_SUSPENSE__ && instance.asyncDep) { - parentSuspense && - parentSuspense.registerDep(instance, setupRenderEffect, optimized) - + if (parentSuspense) { + const hydratedEl = instance.vnode.el + parentSuspense.registerDep(instance, setupResult => { + const { vnode } = instance + if (__DEV__) { + pushWarningContext(vnode) + } + handleSetupResult(instance, setupResult, 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. + hostParentNode(hydratedEl || instance.subTree.el!)!, + // anchor will not be used if this is hydration, so only need to + // consider the comment placeholder case. + hydratedEl ? null : getNextHostNode(instance.subTree), + parentSuspense, + namespace, + optimized, + ) + if (placeholder) { + // clean up placeholder reference + vnode.placeholder = null + hostRemove(placeholder) + } + updateHOCHostEl(instance, vnode.el) + if (__DEV__) { + popWarningContext() + } + }) + } // Give it a placeholder if this is not hydration // TODO handle self-defined fallback if (!initialVNode.el) { 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..b0a4649dc7d --- /dev/null +++ b/packages/runtime-vapor/__tests__/components/Suspense.spec.ts @@ -0,0 +1,248 @@ +import { nextTick, reactive } from 'vue' +import { compile, runtimeDom, runtimeVapor } from '../_utils' + +describe.todo('VaporSuspense', () => {}) + +describe('vdom interop', () => { + beforeEach(() => { + document.body.innerHTML = '' + }) + + 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/__tests__/helpers/useId.spec.ts b/packages/runtime-vapor/__tests__/helpers/useId.spec.ts new file mode 100644 index 00000000000..7f0e7f7e2f0 --- /dev/null +++ b/packages/runtime-vapor/__tests__/helpers/useId.spec.ts @@ -0,0 +1,301 @@ +// TODO: Suspense is required to be implemented for this test to pass + +// /** +// * @vitest-environment jsdom +// */ +// import { +// type App, +// Suspense, +// createApp, +// defineAsyncComponent, +// defineComponent, +// h, +// onServerPrefetch, +// useId, +// } from 'vue' +// import { renderToString } from '@vue/server-renderer' + +// type FactoryRes = [App, Promise[]] +// type TestCaseFactory = () => FactoryRes | Promise + +// async function runOnClient(factory: TestCaseFactory) { +// const [app, deps] = await factory() +// const root = document.createElement('div') +// app.mount(root) +// await Promise.all(deps) +// await promiseWithDelay(null, 0) +// return root.innerHTML +// } + +// async function runOnServer(factory: TestCaseFactory) { +// const [app, _] = await factory() +// return (await renderToString(app)) +// .replace(//g, '') // remove fragment wrappers +// .trim() +// } + +// async function getOutput(factory: TestCaseFactory) { +// const clientResult = await runOnClient(factory) +// const serverResult = await runOnServer(factory) +// expect(serverResult).toBe(clientResult) +// return clientResult +// } + +// function promiseWithDelay(res: any, delay: number) { +// return new Promise(r => { +// setTimeout(() => r(res), delay) +// }) +// } + +// const BasicComponentWithUseId = defineComponent({ +// setup() { +// const id1 = useId() +// const id2 = useId() +// return () => [id1, ' ', id2] +// }, +// }) + +describe.todo('useId', () => { + // test('basic', async () => { + // expect( + // await getOutput(() => { + // const app = createApp(BasicComponentWithUseId) + // return [app, []] + // }), + // ).toBe('v-0 v-1') + // }) + // test('with config.idPrefix', async () => { + // expect( + // await getOutput(() => { + // const app = createApp(BasicComponentWithUseId) + // app.config.idPrefix = 'foo' + // return [app, []] + // }), + // ).toBe('foo-0 foo-1') + // }) + // test('async component', async () => { + // const factory = ( + // delay1: number, + // delay2: number, + // ): ReturnType => { + // const p1 = promiseWithDelay(BasicComponentWithUseId, delay1) + // const p2 = promiseWithDelay(BasicComponentWithUseId, delay2) + // const AsyncOne = defineAsyncComponent(() => p1) + // const AsyncTwo = defineAsyncComponent(() => p2) + // const app = createApp({ + // setup() { + // const id1 = useId() + // const id2 = useId() + // return () => [id1, ' ', id2, ' ', h(AsyncOne), ' ', h(AsyncTwo)] + // }, + // }) + // return [app, [p1, p2]] + // } + // const expected = + // 'v-0 v-1 ' + // root + // 'v-0-0 v-0-1 ' + // inside first async subtree + // 'v-1-0 v-1-1' // inside second async subtree + // // assert different async resolution order does not affect id stable-ness + // expect(await getOutput(() => factory(0, 16))).toBe(expected) + // expect(await getOutput(() => factory(16, 0))).toBe(expected) + // }) + // test('serverPrefetch', async () => { + // const factory = ( + // delay1: number, + // delay2: number, + // ): ReturnType => { + // const p1 = promiseWithDelay(null, delay1) + // const p2 = promiseWithDelay(null, delay2) + // const SPOne = defineComponent({ + // async serverPrefetch() { + // await p1 + // }, + // render() { + // return h(BasicComponentWithUseId) + // }, + // }) + // const SPTwo = defineComponent({ + // async serverPrefetch() { + // await p2 + // }, + // render() { + // return h(BasicComponentWithUseId) + // }, + // }) + // const app = createApp({ + // setup() { + // const id1 = useId() + // const id2 = useId() + // return () => [id1, ' ', id2, ' ', h(SPOne), ' ', h(SPTwo)] + // }, + // }) + // return [app, [p1, p2]] + // } + // const expected = + // 'v-0 v-1 ' + // root + // 'v-0-0 v-0-1 ' + // inside first async subtree + // 'v-1-0 v-1-1' // inside second async subtree + // // assert different async resolution order does not affect id stable-ness + // expect(await getOutput(() => factory(0, 16))).toBe(expected) + // expect(await getOutput(() => factory(16, 0))).toBe(expected) + // }) + // test('components with serverPrefetch', async () => { + // const factory = (): ReturnType => { + // const SPOne = defineComponent({ + // setup() { + // onServerPrefetch(() => {}) + // return () => h(BasicComponentWithUseId) + // }, + // }) + // const SPTwo = defineComponent({ + // render() { + // return h(BasicComponentWithUseId) + // }, + // }) + // const app = createApp({ + // setup() { + // const id1 = useId() + // const id2 = useId() + // return () => [id1, ' ', id2, ' ', h(SPOne), ' ', h(SPTwo)] + // }, + // }) + // return [app, []] + // } + // const expected = + // 'v-0 v-1 ' + // root + // 'v-0-0 v-0-1 ' + // inside first async subtree + // 'v-2 v-3' // inside second async subtree + // // assert different async resolution order does not affect id stable-ness + // expect(await getOutput(() => factory())).toBe(expected) + // expect(await getOutput(() => factory())).toBe(expected) + // }) + // test('async setup()', async () => { + // const factory = ( + // delay1: number, + // delay2: number, + // ): ReturnType => { + // const p1 = promiseWithDelay(null, delay1) + // const p2 = promiseWithDelay(null, delay2) + // const ASOne = defineComponent({ + // async setup() { + // await p1 + // return {} + // }, + // render() { + // return h(BasicComponentWithUseId) + // }, + // }) + // const ASTwo = defineComponent({ + // async setup() { + // await p2 + // return {} + // }, + // render() { + // return h(BasicComponentWithUseId) + // }, + // }) + // const app = createApp({ + // setup() { + // const id1 = useId() + // const id2 = useId() + // return () => + // h(Suspense, null, { + // default: h('div', [id1, ' ', id2, ' ', h(ASOne), ' ', h(ASTwo)]), + // }) + // }, + // }) + // return [app, [p1, p2]] + // } + // const expected = + // '
' + + // 'v-0 v-1 ' + // root + // 'v-0-0 v-0-1 ' + // inside first async subtree + // 'v-1-0 v-1-1' + // inside second async subtree + // '
' + // // assert different async resolution order does not affect id stable-ness + // expect(await getOutput(() => factory(0, 16))).toBe(expected) + // expect(await getOutput(() => factory(16, 0))).toBe(expected) + // }) + // test('deep nested', async () => { + // const factory = (): ReturnType => { + // const p = Promise.resolve() + // const One = { + // async setup() { + // const id = useId() + // await p + // return () => [id, ' ', h(Two), ' ', h(Three)] + // }, + // } + // const Two = { + // async setup() { + // const id = useId() + // await p + // return () => [id, ' ', h(Three), ' ', h(Three)] + // }, + // } + // const Three = { + // async setup() { + // const id = useId() + // return () => id + // }, + // } + // const app = createApp({ + // setup() { + // return () => + // h(Suspense, null, { + // default: h(One), + // }) + // }, + // }) + // return [app, [p]] + // } + // const expected = + // 'v-0 ' + // One + // 'v-0-0 ' + // Two + // 'v-0-0-0 v-0-0-1 ' + // Three + Three nested in Two + // 'v-0-1' // Three after Two + // // assert different async resolution order does not affect id stable-ness + // expect(await getOutput(() => factory())).toBe(expected) + // expect(await getOutput(() => factory())).toBe(expected) + // }) + // test('async component inside async setup, already resolved', async () => { + // const factory = async ( + // delay1: number, + // delay2: number, + // ): Promise => { + // const p1 = promiseWithDelay(null, delay1) + // const p2 = promiseWithDelay(BasicComponentWithUseId, delay2) + // const AsyncInner = defineAsyncComponent(() => p2) + // const AsyncSetup = defineComponent({ + // async setup() { + // await p1 + // return {} + // }, + // render() { + // return h(AsyncInner) + // }, + // }) + // const app = createApp({ + // setup() { + // const id1 = useId() + // const id2 = useId() + // return () => + // h(Suspense, null, { + // default: h('div', [id1, ' ', id2, ' ', h(AsyncSetup)]), + // }) + // }, + // }) + // // the async component may have already been resolved + // await AsyncInner.__asyncLoader() + // return [app, [p1, p2]] + // } + // const expected = + // '
' + + // 'v-0 v-1 ' + // root + // 'v-0-0-0 v-0-0-1' + // async component inside async setup + // '
' + // // assert different async resolution order does not affect id stable-ness + // expect(await getOutput(async () => factory(0, 16))).toBe(expected) + // expect(await getOutput(() => factory(16, 0))).toBe(expected) + // }) +}) diff --git a/packages/runtime-vapor/src/component.ts b/packages/runtime-vapor/src/component.ts index d0c9cece7e9..9371c1a1abd 100644 --- a/packages/runtime-vapor/src/component.ts +++ b/packages/runtime-vapor/src/component.ts @@ -20,6 +20,7 @@ import { getFunctionalFallthrough, isAsyncWrapper, isKeepAlive, + markAsyncBoundary, nextUid, popWarningContext, pushWarningContext, @@ -55,6 +56,7 @@ import { invokeArrayFns, isArray, isFunction, + isPromise, isString, } from '@vue/shared' import { @@ -106,6 +108,7 @@ import { } from './insertionState' import { DynamicFragment, isFragment } from './fragment' import type { VaporElement } from './apiDefineVaporCustomElement' +import { parentSuspense, setParentSuspense } from './components/Suspense' export { currentInstance } from '@vue/runtime-dom' @@ -201,6 +204,11 @@ export function createComponent( const parentInstance = getParentInstance() + let prevSuspense: SuspenseBoundary | null = null + if (__FEATURE_SUSPENSE__ && parentInstance && parentInstance.suspense) { + prevSuspense = setParentSuspense(parentInstance.suspense) + } + if ( (isSingleRoot || // transition has attrs fallthrough @@ -339,6 +347,10 @@ export function createComponent( endMeasure(instance, 'init') } + if (__FEATURE_SUSPENSE__ && parentInstance && parentInstance.suspense) { + setParentSuspense(prevSuspense) + } + // restore currentSlotConsumer to previous value after setupFn is called setCurrentSlotConsumer(prevSlotConsumer) onScopeDispose(() => unmountComponent(instance), true) @@ -373,71 +385,35 @@ export function setupComponent( ]) || 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 || instance.sp) && !isAsyncWrapper(instance)) { + // async setup / serverPrefetch, mark as async boundary for useId() + markAsyncBoundary(instance) + } + + 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 { - if (__DEV__ || __FEATURE_PROD_DEVTOOLS__) { - instance.devtoolsRawSetupState = setupResult - } - instance.setupState = proxyRefs(setupResult) - if (__DEV__) { - instance.setupState = createDevSetupStateProxy(instance) - } - devRender(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 && - Object.keys(instance.attrs).length - ) { - const root = getRootElement( - instance.block, - // attach attrs to root dynamic fragments for applying during each update - frag => (frag.attrs = instance.attrs), - false, - ) - if (root) { - renderEffect(() => { - const attrs = - isFunction(component) && !isVaporTransition(component) - ? getFunctionalFallthrough(instance.attrs) - : instance.attrs - if (attrs) applyFallthroughProps(root, attrs) - }) - } else if ( - __DEV__ && - ((!instance.accessedAttrs && - isArray(instance.block) && - instance.block.length) || - // preventing attrs fallthrough on Teleport - // consistent with VDOM Teleport behavior - instance.block instanceof TeleportFragment) - ) { - warnExtraneousAttributes(instance.attrs) - } + handleSetupResult(setupResult, component, instance, setupFn) } setActiveSub(prevSub) @@ -561,6 +537,9 @@ export class VaporComponentInstance implements GenericComponentInstance { ids: [string, number, number] // for suspense suspense: SuspenseBoundary | null + suspenseId: number + asyncDep: Promise | null + asyncResolved: boolean // for HMR and vapor custom element // all render effects associated with this instance @@ -639,12 +618,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 = parentSuspense + this.suspenseId = parentSuspense ? parentSuspense.pendingId : 0 + this.asyncDep = null + this.asyncResolved = false this.isMounted = this.isUnmounted = @@ -815,6 +795,25 @@ export function mountComponent( parent: ParentNode, anchor?: Node | null | 0, ): void { + if ( + __FEATURE_SUSPENSE__ && + instance.suspense && + instance.asyncDep && + !instance.asyncResolved + ) { + const component = instance.type + instance.suspense.registerDep(instance, setupResult => { + handleSetupResult( + setupResult, + component, + instance, + isFunction(component) ? component : component.setup, + ) + mountComponent(instance, parent, anchor) + }) + return + } + if (instance.shapeFlag! & ShapeFlags.COMPONENT_KEPT_ALIVE) { findParentKeepAlive(instance)!.activate(instance, parent, anchor) return @@ -949,3 +948,85 @@ export function getRootElement( function isVaporTransition(component: VaporComponent): boolean { return getComponentName(component) === 'VaporTransition' } + +function handleSetupResult( + setupResult: any, + component: VaporComponent, + instance: VaporComponentInstance, + setupFn: VaporSetupFn | undefined, +) { + 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 { + if (__DEV__ || __FEATURE_PROD_DEVTOOLS__) { + instance.devtoolsRawSetupState = setupResult + } + instance.setupState = proxyRefs(setupResult) + if (__DEV__) { + instance.setupState = createDevSetupStateProxy(instance) + } + devRender(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 && + Object.keys(instance.attrs).length + ) { + const root = getRootElement( + instance.block, + // attach attrs to root dynamic fragments for applying during each update + frag => (frag.attrs = instance.attrs), + false, + ) + if (root) { + renderEffect(() => { + const attrs = + isFunction(component) && !isVaporTransition(component) + ? getFunctionalFallthrough(instance.attrs) + : instance.attrs + if (attrs) applyFallthroughProps(root, attrs) + }) + } else if ( + __DEV__ && + ((!instance.accessedAttrs && + isArray(instance.block) && + instance.block.length) || + // preventing attrs fallthrough on Teleport + // consistent with VDOM Teleport behavior + instance.block instanceof TeleportFragment) + ) { + warnExtraneousAttributes(instance.attrs) + } + } + + 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..6496a85570f --- /dev/null +++ b/packages/runtime-vapor/src/components/Suspense.ts @@ -0,0 +1,20 @@ +import type { SuspenseBoundary } from '@vue/runtime-dom' + +export let parentSuspense: SuspenseBoundary | null = null + +export function setParentSuspense( + suspense: SuspenseBoundary | null, +): SuspenseBoundary | null { + try { + return parentSuspense + } finally { + parentSuspense = suspense + } +} + +// TODO: implement this +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 7f051e064f1..60b5aad1f44 100644 --- a/packages/runtime-vapor/src/vdomInterop.ts +++ b/packages/runtime-vapor/src/vdomInterop.ts @@ -12,6 +12,7 @@ import { type RendererNode, type ShallowRef, type Slots, + type SuspenseBoundary, type TransitionHooks, type VNode, type VNodeNormalizedRef, @@ -80,6 +81,7 @@ import { deactivate, findParentKeepAlive, } from './components/KeepAlive' +import { setParentSuspense } from './components/Suspense' export const interopKey: unique symbol = Symbol(`interop`) @@ -88,7 +90,7 @@ const vaporInteropImpl: Omit< VaporInteropInterface, 'vdomMount' | 'vdomUnmount' | 'vdomSlot' > = { - mount(vnode, container, anchor, parentComponent) { + mount(vnode, container, anchor, parentComponent, parentSuspense) { let selfAnchor = (vnode.el = vnode.anchor = createTextNode()) if (isHydrating) { // avoid vdom hydration children mismatch by the selfAnchor, delay its insertion @@ -110,6 +112,11 @@ const vaporInteropImpl: Omit< const propsRef = shallowRef(props) const slotsRef = shallowRef(vnode.children) + let prevSuspense: SuspenseBoundary | null = null + if (__FEATURE_SUSPENSE__ && parentSuspense) { + prevSuspense = setParentSuspense(parentSuspense) + } + const dynamicPropSource: (() => any)[] & { [interopKey]?: boolean } = [ () => propsRef.value, ] @@ -140,6 +147,11 @@ const vaporInteropImpl: Omit< vnode.transition as VaporTransitionHooks, ) } + + if (__FEATURE_SUSPENSE__ && parentSuspense) { + setParentSuspense(prevSuspense) + } + mountComponent(instance, container, selfAnchor) simpleSetCurrentInstance(prev) return instance @@ -157,8 +169,12 @@ 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) + } } else if (vnode.vb) { remove(vnode.vb, container) } @@ -197,9 +213,9 @@ const vaporInteropImpl: Omit< insert(vnode.anchor as any, container, anchor) }, - hydrate(vnode, node, container, anchor, parentComponent) { + hydrate(vnode, node, container, anchor, parentComponent, parentSuspense) { vaporHydrateNode(node, () => - this.mount(vnode, container, anchor, parentComponent), + this.mount(vnode, container, anchor, parentComponent, parentSuspense), ) return _next(node) },