]> git.ipfire.org Git - thirdparty/vuejs/core.git/commitdiff
feat(suspense): support rendering of Vapor components (#14157)
authoredison <daiwei521@126.com>
Thu, 4 Dec 2025 02:10:52 +0000 (10:10 +0800)
committerGitHub <noreply@github.com>
Thu, 4 Dec 2025 02:10:52 +0000 (10:10 +0800)
packages/runtime-core/src/apiCreateApp.ts
packages/runtime-core/src/component.ts
packages/runtime-core/src/components/Suspense.ts
packages/runtime-core/src/hydration.ts
packages/runtime-core/src/renderer.ts
packages/runtime-vapor/__tests__/components/Suspense.spec.ts [new file with mode: 0644]
packages/runtime-vapor/__tests__/helpers/useId.spec.ts [new file with mode: 0644]
packages/runtime-vapor/src/component.ts
packages/runtime-vapor/src/components/Suspense.ts [new file with mode: 0644]
packages/runtime-vapor/src/vdomInterop.ts

index 2aca5f3ee1e3a91afa72b3fe6195ca875e65bfed..9bd13d600bcc3981efe8550cbe87b58fd2c6afbe 100644 (file)
@@ -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(
index b49cd685425e49fc86239832d7bccbc7bf6aff85..88faf6306a934ae91c99a6c777e423172f5125b3 100644 (file)
@@ -461,6 +461,19 @@ export interface GenericComponentInstance {
    * @internal
    */
   suspense: SuspenseBoundary | null
+  /**
+   * suspense pending batch id
+   * @internal
+   */
+  suspenseId: number
+  /**
+   * @internal
+   */
+  asyncDep: Promise<any> | null
+  /**
+   * @internal
+   */
+  asyncResolved: boolean
   /**
    * `updateTeleportCssVars`
    * For updating css vars on contained teleports
index 980ddc73c3717b70ec8f6dd99e6f616617dbb48a..b3463f0b35321bac6311f4ce627a9e9f2ab79745 100644 (file)
@@ -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()
index 32e260c7594393868a4a4806a8ac3b528e85a9b8..5188c1f1dbc08cc862ceaa88d01b45e871865899 100644 (file)
@@ -318,6 +318,7 @@ export function createHydrationFunctions(
               container,
               null,
               parentComponent,
+              parentSuspense,
             )
           } else {
             mountComponent(
index 98a8c350375c83c39db907bc9822b0386d3c08da..c3cb60fd40e4b5cf183e5df1d5ba5860421dedeb 100644 (file)
@@ -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 (file)
index 0000000..b0a4649
--- /dev/null
@@ -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<string, { code: string; vapor: boolean }> = {},
+    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: `
+    <script vapor>
+      const data = _data; 
+      const components = _components;
+      const p = new Promise(r => setTimeout(r, 5))
+      data.deps.push(p.then(() => Promise.resolve()))
+      await p
+    </script>
+    ${code}
+    `,
+      vapor: true,
+    }
+  }
+
+  test('vdom suspense: render vapor components', async () => {
+    const data = { deps: [] }
+    const { container } = await testSuspense(
+      `<script setup>
+        const components = _components;
+      </script>
+      <template>
+        <Suspense>
+          <components.VaporChild/>
+          <template #fallback>
+            <span>fallback</span>
+          </template>
+        </Suspense>
+      </template>`,
+      {
+        VaporChild: withAsyncScript(`<template><div>hi</div></template>`),
+      },
+      data,
+    )
+
+    expect(container.innerHTML).toBe(`<span>fallback</span>`)
+    expect(data.deps.length).toBe(1)
+    await Promise.all(data.deps)
+    await nextTick()
+    expect(container.innerHTML).toBe(`<div>hi</div>`)
+  })
+
+  test('vdom suspense: nested async vapor components', async () => {
+    const data = { deps: [] }
+    const { container } = await testSuspense(
+      `<script setup>
+        const components = _components;
+      </script>
+      <template>
+        <Suspense>
+          <components.AsyncOuter/>
+          <template #fallback>
+            <span>fallback</span>
+          </template>
+        </Suspense>
+      </template>`,
+      {
+        AsyncOuter: withAsyncScript(
+          `<template><components.AsyncInner/></template>`,
+        ),
+        AsyncInner: withAsyncScript(`<template><div>inner</div></template>`),
+      },
+      data,
+    )
+
+    expect(container.innerHTML).toBe(`<span>fallback</span>`)
+
+    await data.deps[0]
+    await nextTick()
+    expect(container.innerHTML).toBe(`<span>fallback</span>`)
+
+    await Promise.all(data.deps)
+    await nextTick()
+    expect(container.innerHTML).toBe(`<div>inner</div>`)
+  })
+
+  test('vdom suspense: content update before suspense resolve', async () => {
+    const data = reactive({ msg: 'foo', deps: [] })
+    const { container } = await testSuspense(
+      `<script setup>
+        const data = _data;
+        const components = _components;
+      </script>
+      <template>
+        <Suspense>
+          <components.VaporChild/>
+          <template #fallback>
+            <span>fallback {{data.msg}}</span>
+          </template>
+        </Suspense>
+      </template>`,
+      {
+        VaporChild: withAsyncScript(
+          `<template><div>{{data.msg}}</div></template>`,
+        ),
+      },
+      data,
+    )
+
+    expect(container.innerHTML).toBe(`<span>fallback foo</span>`)
+
+    data.msg = 'bar'
+    await nextTick()
+    expect(container.innerHTML).toBe(`<span>fallback bar</span>`)
+
+    await Promise.all(data.deps)
+    await nextTick()
+    expect(container.innerHTML).toBe(`<div>bar</div>`)
+  })
+
+  test('vdom suspense: unmount before suspense resolve', async () => {
+    const data = reactive({ show: true, deps: [] })
+    const { container } = await testSuspense(
+      `<script setup>
+        const data = _data;
+        const components = _components;
+      </script>
+      <template>
+        <Suspense>
+          <components.VaporChild v-if="data.show"/>
+          <template #fallback>
+            <span>fallback</span>
+          </template>
+        </Suspense>
+      </template>`,
+      {
+        VaporChild: withAsyncScript(`<template><div>child</div></template>`),
+      },
+      data,
+    )
+
+    expect(container.innerHTML).toBe(`<span>fallback</span>`)
+
+    data.show = false
+    await nextTick()
+    expect(container.innerHTML).toBe(`<!--v-if-->`)
+
+    await Promise.all(data.deps)
+    await nextTick()
+    expect(container.innerHTML).toBe(`<!--v-if-->`)
+  })
+
+  test('vdom suspense: unmount suspense after resolve', async () => {
+    const data = reactive({ show: true, deps: [] })
+    const { container } = await testSuspense(
+      `<script setup>
+        const data = _data;
+        const components = _components;
+      </script>
+      <template>
+        <Suspense v-if="data.show">
+          <components.VaporChild/>
+          <template #fallback>
+            <span>fallback</span>
+          </template>
+        </Suspense>
+      </template>`,
+      {
+        VaporChild: withAsyncScript(`<template><div>child</div></template>`),
+      },
+      data,
+    )
+
+    expect(container.innerHTML).toBe(`<span>fallback</span>`)
+
+    await Promise.all(data.deps)
+    await nextTick()
+    expect(container.innerHTML).toBe(`<div>child</div>`)
+
+    data.show = false
+    await nextTick()
+    expect(container.innerHTML).toBe(`<!--v-if-->`)
+  })
+
+  test('vdom suspense: unmount suspense before resolve', async () => {
+    const data = reactive({ show: true, deps: [] })
+    const { container } = await testSuspense(
+      `<script setup>
+        const data = _data;
+        const components = _components;
+      </script>
+      <template>
+        <Suspense v-if="data.show">
+          <components.VaporChild/>
+          <template #fallback>
+            <span>fallback</span>
+          </template>
+        </Suspense>
+      </template>`,
+      {
+        VaporChild: withAsyncScript(`<template><div>child</div></template>`),
+      },
+      data,
+    )
+
+    expect(container.innerHTML).toBe(`<span>fallback</span>`)
+
+    data.show = false
+    await nextTick()
+    expect(container.innerHTML).toBe(`<!--v-if-->`)
+
+    await Promise.all(data.deps)
+    await nextTick()
+    expect(container.innerHTML).toBe(`<!--v-if-->`)
+  })
+})
diff --git a/packages/runtime-vapor/__tests__/helpers/useId.spec.ts b/packages/runtime-vapor/__tests__/helpers/useId.spec.ts
new file mode 100644 (file)
index 0000000..7f0e7f7
--- /dev/null
@@ -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<any>[]]
+// type TestCaseFactory = () => FactoryRes | Promise<FactoryRes>
+
+// 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<any>(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<TestCaseFactory> => {
+  //       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<TestCaseFactory> => {
+  //       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<TestCaseFactory> => {
+  //       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<TestCaseFactory> => {
+  //       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 =
+  //       '<div>' +
+  //       '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
+  //       '</div>'
+  //     // 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<TestCaseFactory> => {
+  //       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<FactoryRes> => {
+  //       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 =
+  //       '<div>' +
+  //       'v-0 v-1 ' + // root
+  //       'v-0-0-0 v-0-0-1' + // async component inside async setup
+  //       '</div>'
+  //     // 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)
+  //   })
+})
index d0c9cece7e9ff0348f578ea86da87376a61125a4..9371c1a1abdac3a73341db70253b2209f25d1127 100644 (file)
@@ -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 ` +
+            `<Suspense> boundary was found in the parent component tree. ` +
+            `A component with async setup() must be nested in a <Suspense> ` +
+            `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<any> | 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 (file)
index 0000000..6496a85
--- /dev/null
@@ -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 {},
+}
index 7f051e064f134bdafbcf5ed70a5cf2db583911f6..60b5aad1f44c59fa98563e6b4a6fff863b04f131 100644 (file)
@@ -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)
   },