]> git.ipfire.org Git - thirdparty/vuejs/core.git/commitdiff
feat(vapor): suspense interop with Vapor components
authordaiwei <daiwei521@126.com>
Mon, 19 May 2025 03:36:06 +0000 (11:36 +0800)
committerdaiwei <daiwei521@126.com>
Mon, 19 May 2025 03:36:06 +0000 (11:36 +0800)
packages/runtime-core/src/apiCreateApp.ts
packages/runtime-core/src/component.ts
packages/runtime-core/src/components/Suspense.ts
packages/runtime-core/src/index.ts
packages/runtime-core/src/renderer.ts
packages/runtime-vapor/src/component.ts
packages/runtime-vapor/src/vdomInterop.ts

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