]> git.ipfire.org Git - thirdparty/vuejs/core.git/commitdiff
wip(vapor): vapor in vdom interop
authorEvan You <evan@vuejs.org>
Sun, 2 Feb 2025 14:28:35 +0000 (22:28 +0800)
committerEvan You <evan@vuejs.org>
Sun, 2 Feb 2025 15:15:39 +0000 (23:15 +0800)
14 files changed:
packages/runtime-core/src/apiCreateApp.ts
packages/runtime-core/src/component.ts
packages/runtime-core/src/components/KeepAlive.ts
packages/runtime-core/src/components/Suspense.ts
packages/runtime-core/src/components/Teleport.ts
packages/runtime-core/src/index.ts
packages/runtime-core/src/renderer.ts
packages/runtime-vapor/__tests__/_utils.ts
packages/runtime-vapor/__tests__/apiWatch.spec.ts
packages/runtime-vapor/__tests__/component.spec.ts
packages/runtime-vapor/src/component.ts
packages/runtime-vapor/src/index.ts
packages/runtime-vapor/src/vdomInterop.ts [new file with mode: 0644]
playground/src/main.ts

index 9162b0e004c09702fedf2eeb920ac8df554c220e..8083e9d1983b59028d5352137103c06e59fd8eff 100644 (file)
@@ -1,5 +1,6 @@
 import {
   type Component,
+  type ComponentInternalInstance,
   type ConcreteComponent,
   type Data,
   type GenericComponent,
@@ -171,6 +172,26 @@ export interface AppConfig extends GenericAppConfig {
    * @deprecated use config.compilerOptions.isCustomElement
    */
   isCustomElement?: (tag: string) => boolean
+
+  /**
+   * @internal
+   */
+  vapor?: VaporInVDOMInterface
+}
+
+/**
+ * @internal
+ */
+export interface VaporInVDOMInterface {
+  mount(
+    vnode: VNode,
+    container: any,
+    anchor: any,
+    parentComponent: ComponentInternalInstance | null,
+  ): GenericComponentInstance // VaporComponentInstance
+  update(n1: VNode, n2: VNode): void
+  unmount(vnode: VNode, doRemove?: boolean): void
+  move(vnode: VNode, container: any, anchor: any): void
 }
 
 /**
index 493d08b5a0529f015cbe78afec9006917cc7fcc7..a34a72be05a95ec83fabaf1a4e3df031ace2a9df 100644 (file)
@@ -192,6 +192,10 @@ export interface AllowedComponentProps {
 // Note: can't mark this whole interface internal because some public interfaces
 // extend it.
 export interface ComponentInternalOptions {
+  /**
+   * indicates vapor component
+   */
+  __vapor?: boolean
   /**
    * @internal
    */
index b0fb27207ae8e248cc0ddb11afe35038c1c3fd6f..2ceaaa9e602fe16d348d2fd907f7569fba62d23e 100644 (file)
@@ -90,13 +90,13 @@ const KeepAliveImpl: ComponentOptions = {
   },
 
   setup(props: KeepAliveProps, { slots }: SetupContext) {
-    const instance = getCurrentInstance()!
+    const keepAliveInstance = getCurrentInstance()!
     // KeepAlive communicates with the instantiated renderer via the
     // ctx where the renderer passes in its internals,
     // and the KeepAlive instance exposes activate/deactivate implementations.
     // The whole point of this is to avoid importing KeepAlive directly in the
     // renderer to facilitate tree-shaking.
-    const sharedContext = instance.ctx as KeepAliveContext
+    const sharedContext = keepAliveInstance.ctx as KeepAliveContext
 
     // if the internal renderer is not registered, it indicates that this is server-side rendering,
     // for KeepAlive, we just need to render its children
@@ -112,10 +112,10 @@ const KeepAliveImpl: ComponentOptions = {
     let current: VNode | null = null
 
     if (__DEV__ || __FEATURE_PROD_DEVTOOLS__) {
-      ;(instance as any).__v_cache = cache
+      ;(keepAliveInstance as any).__v_cache = cache
     }
 
-    const parentSuspense = instance.suspense
+    const parentSuspense = keepAliveInstance.suspense
 
     const {
       renderer: {
@@ -135,7 +135,14 @@ const KeepAliveImpl: ComponentOptions = {
       optimized,
     ) => {
       const instance = vnode.component!
-      move(vnode, container, anchor, MoveType.ENTER, parentSuspense)
+      move(
+        vnode,
+        container,
+        anchor,
+        MoveType.ENTER,
+        keepAliveInstance,
+        parentSuspense,
+      )
       // in case props have changed
       patch(
         instance.vnode,
@@ -170,7 +177,14 @@ const KeepAliveImpl: ComponentOptions = {
       invalidateMount(instance.m)
       invalidateMount(instance.a)
 
-      move(vnode, storageContainer, null, MoveType.LEAVE, parentSuspense)
+      move(
+        vnode,
+        storageContainer,
+        null,
+        MoveType.LEAVE,
+        keepAliveInstance,
+        parentSuspense,
+      )
       queuePostRenderEffect(() => {
         if (instance.da) {
           invokeArrayFns(instance.da)
@@ -191,7 +205,7 @@ const KeepAliveImpl: ComponentOptions = {
     function unmount(vnode: VNode) {
       // reset the shapeFlag so it can be properly unmounted
       resetShapeFlag(vnode)
-      _unmount(vnode, instance, parentSuspense, true)
+      _unmount(vnode, keepAliveInstance, parentSuspense, true)
     }
 
     function pruneCache(filter: (name: string) => boolean) {
@@ -234,12 +248,15 @@ const KeepAliveImpl: ComponentOptions = {
       if (pendingCacheKey != null) {
         // if KeepAlive child is a Suspense, it needs to be cached after Suspense resolves
         // avoid caching vnode that not been mounted
-        if (isSuspense(instance.subTree.type)) {
+        if (isSuspense(keepAliveInstance.subTree.type)) {
           queuePostRenderEffect(() => {
-            cache.set(pendingCacheKey!, getInnerChild(instance.subTree))
-          }, instance.subTree.suspense)
+            cache.set(
+              pendingCacheKey!,
+              getInnerChild(keepAliveInstance.subTree),
+            )
+          }, keepAliveInstance.subTree.suspense)
         } else {
-          cache.set(pendingCacheKey, getInnerChild(instance.subTree))
+          cache.set(pendingCacheKey, getInnerChild(keepAliveInstance.subTree))
         }
       }
     }
@@ -248,7 +265,7 @@ const KeepAliveImpl: ComponentOptions = {
 
     onBeforeUnmount(() => {
       cache.forEach(cached => {
-        const { subTree, suspense } = instance
+        const { subTree, suspense } = keepAliveInstance
         const vnode = getInnerChild(subTree)
         if (cached.type === vnode.type && cached.key === vnode.key) {
           // current instance will be unmounted as part of keep-alive's unmount
index 85001f500cff8edc027cae4553dd8aa3eee4bed4..0f6f69c6526d9edd488b5d0586b0b9dd41020585 100644 (file)
@@ -549,6 +549,7 @@ function createSuspenseBoundary(
                 container,
                 anchor === initialAnchor ? next(activeBranch!) : anchor,
                 MoveType.ENTER,
+                parentComponent,
               )
               queuePostFlushCb(effects)
             }
@@ -573,7 +574,13 @@ function createSuspenseBoundary(
         }
         if (!delayEnter) {
           // move content from off-dom container to actual container
-          move(pendingBranch!, container, anchor, MoveType.ENTER)
+          move(
+            pendingBranch!,
+            container,
+            anchor,
+            MoveType.ENTER,
+            parentComponent,
+          )
         }
       }
 
@@ -672,7 +679,7 @@ function createSuspenseBoundary(
 
     move(container, anchor, type) {
       suspense.activeBranch &&
-        move(suspense.activeBranch, container, anchor, type)
+        move(suspense.activeBranch, container, anchor, type, parentComponent)
       suspense.container = container
     },
 
index fe6fa36c1ca4a5730589b10ba8bcf359238c1742..a6445df7b055b2cc868ea634ba6eeeb9837a0515 100644 (file)
@@ -244,6 +244,7 @@ export const TeleportImpl = {
             container,
             mainAnchor,
             internals,
+            parentComponent,
             TeleportMoveTypes.TOGGLE,
           )
         } else {
@@ -267,6 +268,7 @@ export const TeleportImpl = {
               nextTarget,
               null,
               internals,
+              parentComponent,
               TeleportMoveTypes.TARGET_CHANGE,
             )
           } else if (__DEV__) {
@@ -284,6 +286,7 @@ export const TeleportImpl = {
             target,
             targetAnchor,
             internals,
+            parentComponent,
             TeleportMoveTypes.TOGGLE,
           )
         }
@@ -346,6 +349,7 @@ function moveTeleport(
   container: RendererElement,
   parentAnchor: RendererNode | null,
   { o: { insert }, m: move }: RendererInternals,
+  parentComponent: ComponentInternalInstance | null,
   moveType: TeleportMoveTypes = TeleportMoveTypes.REORDER,
 ): void {
   // move target anchor if this is a target change.
@@ -370,6 +374,7 @@ function moveTeleport(
           container,
           parentAnchor,
           MoveType.REORDER,
+          parentComponent,
         )
       }
     }
index a4629dde8de2034a03c0175da940b8406ea63b6e..7679c8b8706acb0d9d91f8aae540a4db4e7dbacf 100644 (file)
@@ -526,6 +526,7 @@ export {
   createAppAPI,
   type AppMountFn,
   type AppUnmountFn,
+  type VaporInVDOMInterface,
 } from './apiCreateApp'
 /**
  * @internal
index e825bef1ce61042871b4e64e53429337b1e0cdee..182d8a99a76f9c2cc1fda3c512a4835d185ddfc0 100644 (file)
@@ -17,6 +17,7 @@ import {
 import {
   type ComponentInternalInstance,
   type ComponentOptions,
+  type ConcreteComponent,
   type Data,
   type LifecycleHook,
   createComponentInstance,
@@ -64,6 +65,7 @@ import {
   type AppMountFn,
   type AppUnmountFn,
   type CreateAppFunction,
+  type VaporInVDOMInterface,
   createAppAPI,
 } from './apiCreateApp'
 import { setRef } from './rendererTemplateRef'
@@ -234,6 +236,7 @@ type MoveFn = (
   container: RendererElement,
   anchor: RendererNode | null,
   type: MoveType,
+  parentComponent: ComponentInternalInstance | null,
   parentSuspense?: SuspenseBoundary | null,
 ) => void
 
@@ -1145,7 +1148,19 @@ function baseCreateRenderer(
     optimized: boolean,
   ) => {
     n2.slotScopeIds = slotScopeIds
-    if (n1 == null) {
+
+    if ((n2.type as ConcreteComponent).__vapor) {
+      if (n1 == null) {
+        getVaporInterface(parentComponent).mount(
+          n2,
+          container,
+          anchor,
+          parentComponent,
+        )
+      } else {
+        getVaporInterface(parentComponent).update(n1, n2)
+      }
+    } else if (n1 == null) {
       if (n2.shapeFlag & ShapeFlags.COMPONENT_KEPT_ALIVE) {
         ;(parentComponent!.ctx as KeepAliveContext).activate(
           n2,
@@ -2000,7 +2015,13 @@ function baseCreateRenderer(
           // There is no stable subsequence (e.g. a reverse)
           // OR current node is not among the stable sequence
           if (j < 0 || i !== increasingNewIndexSequence[j]) {
-            move(nextChild, container, anchor, MoveType.REORDER)
+            move(
+              nextChild,
+              container,
+              anchor,
+              MoveType.REORDER,
+              parentComponent,
+            )
           } else {
             j--
           }
@@ -2014,11 +2035,22 @@ function baseCreateRenderer(
     container,
     anchor,
     moveType,
+    parentComponent,
     parentSuspense = null,
   ) => {
     const { el, type, transition, children, shapeFlag } = vnode
     if (shapeFlag & ShapeFlags.COMPONENT) {
-      move(vnode.component!.subTree, container, anchor, moveType)
+      if ((type as ConcreteComponent).__vapor) {
+        getVaporInterface(parentComponent).move(vnode, container, anchor)
+      } else {
+        move(
+          vnode.component!.subTree,
+          container,
+          anchor,
+          moveType,
+          parentComponent,
+        )
+      }
       return
     }
 
@@ -2028,14 +2060,26 @@ function baseCreateRenderer(
     }
 
     if (shapeFlag & ShapeFlags.TELEPORT) {
-      ;(type as typeof TeleportImpl).move(vnode, container, anchor, internals)
+      ;(type as typeof TeleportImpl).move(
+        vnode,
+        container,
+        anchor,
+        internals,
+        parentComponent,
+      )
       return
     }
 
     if (type === Fragment) {
       hostInsert(el!, container, anchor)
       for (let i = 0; i < (children as VNode[]).length; i++) {
-        move((children as VNode[])[i], container, anchor, moveType)
+        move(
+          (children as VNode[])[i],
+          container,
+          anchor,
+          moveType,
+          parentComponent,
+        )
       }
       hostInsert(vnode.anchor!, container, anchor)
       return
@@ -2126,7 +2170,11 @@ function baseCreateRenderer(
     }
 
     if (shapeFlag & ShapeFlags.COMPONENT) {
-      unmountComponent(vnode.component!, parentSuspense, doRemove)
+      if ((type as ConcreteComponent).__vapor) {
+        getVaporInterface(parentComponent).unmount(vnode, doRemove)
+      } else {
+        unmountComponent(vnode.component!, parentSuspense, doRemove)
+      }
     } else {
       if (__FEATURE_SUSPENSE__ && shapeFlag & ShapeFlags.SUSPENSE) {
         vnode.suspense!.unmount(parentSuspense, doRemove)
@@ -2553,3 +2601,19 @@ export function invalidateMount(hooks: LifecycleHook | undefined): void {
       hooks[i].flags! |= SchedulerJobFlags.DISPOSED
   }
 }
+
+function getVaporInterface(
+  instance: ComponentInternalInstance | null,
+): VaporInVDOMInterface {
+  const res = instance!.appContext.config.vapor
+  if (__DEV__ && !res) {
+    warn(
+      `Vapor component found in vdom tree but vapor-in-vdom interop was not installed. ` +
+        `Make sure to install it:\n` +
+        `\`\`\`\nimport { vaporInteropPlugin } from 'vue'\n` +
+        `app.use(vaporInteropPlugin)\n` +
+        `\`\`\``,
+    )
+  }
+  return res!
+}
index a2f66f7b8e4ef6368ea757a0d837f63936d733e6..c34eb05a05c334a4ebb4b69f3670814e7a9ecc4a 100644 (file)
@@ -41,14 +41,14 @@ export function makeRender<C = VaporComponent>(
     let app: App
 
     function render(
-      props: RawProps = {},
+      props: RawProps | undefined = undefined,
       container: string | ParentNode = host,
     ) {
       create(props)
       return mount(container)
     }
 
-    function create(props: RawProps = {}) {
+    function create(props: RawProps | undefined = undefined) {
       app?.unmount()
       app = createVaporApp(component, props)
       return res()
index 01b83de80a05fb05e458d75a687c24715c9a28f0..068791b8ad27cff01660c41ea5b1b940e83c895d 100644 (file)
@@ -297,7 +297,8 @@ describe('apiWatch', () => {
     }
     define(Comp).render()
     // should not record watcher in detached scope
-    expect(instance!.scope.effects.length).toBe(0)
+    // the 1 is the props validation effect
+    expect(instance!.scope.effects.length).toBe(1)
   })
 
   test('watchEffect should keep running if created in a detached scope', async () => {
index c9f3a5ffd68c1cd9d135c6658790a2ebda8f5ed2..3a945bc0962bcc09ea49c1a32be8914222d24982 100644 (file)
@@ -274,7 +274,8 @@ describe('component', () => {
     }).render()
 
     const i = instance as VaporComponentInstance
-    expect(i.scope.effects.length).toBe(2)
+    // watchEffect + renderEffect + props validation effect
+    expect(i.scope.effects.length).toBe(3)
     expect(host.innerHTML).toBe('<div>0</div>')
 
     app.unmount()
index 58341312fca164725543330b5f7fe9d6a2eb5d44..06f169c3e176be1280c7462a8e173d42bd56a103 100644 (file)
@@ -33,6 +33,7 @@ import {
   remove,
 } from './block'
 import {
+  type ShallowRef,
   markRaw,
   pauseTracking,
   proxyRefs,
@@ -180,6 +181,10 @@ export function createComponent(
   simpleSetCurrentInstance(instance)
   pauseTracking()
 
+  if (__DEV__) {
+    setupPropsValidation(instance)
+  }
+
   const setupFn = isFunction(component) ? component : component.setup
   const setupResult = setupFn
     ? callWithErrorHandling(setupFn, instance, ErrorCodes.SETUP_FUNCTION, [
@@ -295,6 +300,7 @@ export class VaporComponentInstance implements GenericComponentInstance {
   props: Record<string, any>
   attrs: Record<string, any>
   propsDefaults: Record<string, any> | null
+  rawPropsRef?: ShallowRef<any> // to hold vnode props in vdom interop mode
 
   slots: StaticSlots
 
@@ -414,8 +420,6 @@ export class VaporComponentInstance implements GenericComponentInstance {
       : EMPTY_OBJ
 
     if (__DEV__) {
-      // validate props
-      if (rawProps) setupPropsValidation(this)
       // cache normalized options for dev only emit check
       this.propsOptions = normalizePropsOptions(comp)
       this.emitsOptions = normalizeEmitsOptions(comp)
@@ -518,6 +522,7 @@ export function unmountComponent(
       // and also remove it from the parent's children list.
       remove(instance.block, parentNode)
       const parentInstance = instance.parent
+      instance.parent = null
       if (isVaporComponent(parentInstance)) {
         if (parentsWithUnmountedChildren) {
           // for optimize children removal
@@ -525,7 +530,6 @@ export function unmountComponent(
         } else {
           removeItem(parentInstance.children, instance)
         }
-        instance.parent = null
       }
     }
 
index b228217efa621af2c3088239a3b20362ddca9958..5e96f98621e3a5ca2aa21a51811ee978b01fd10b 100644 (file)
@@ -4,7 +4,13 @@ export { defineVaporComponent } from './apiDefineComponent'
 
 // compiler-use only
 export { insert, prepend, remove } from './block'
-export { createComponent, createComponentWithFallback } from './component'
+export {
+  createComponent,
+  createComponentWithFallback,
+  mountComponent,
+  unmountComponent,
+  type VaporComponentInstance,
+} from './component'
 export { renderEffect } from './renderEffect'
 export { createSlot } from './componentSlots'
 export { template, children, next } from './dom/template'
@@ -38,3 +44,4 @@ export {
   applySelectModel,
   applyDynamicModel,
 } from './directives/vModel'
+export { vaporInteropPlugin } from './vdomInterop'
diff --git a/packages/runtime-vapor/src/vdomInterop.ts b/packages/runtime-vapor/src/vdomInterop.ts
new file mode 100644 (file)
index 0000000..af85cfa
--- /dev/null
@@ -0,0 +1,59 @@
+import {
+  type GenericComponentInstance,
+  type Plugin,
+  type VNode,
+  type VaporInVDOMInterface,
+  currentInstance,
+  shallowRef,
+  simpleSetCurrentInstance,
+} from '@vue/runtime-dom'
+import {
+  type VaporComponentInstance,
+  createComponent,
+  mountComponent,
+  unmountComponent,
+} from './component'
+import { insert } from './block'
+
+const vaporInVDOMInterface: VaporInVDOMInterface = {
+  mount(
+    vnode: VNode,
+    container: ParentNode,
+    anchor: Node,
+    parentComponent: GenericComponentInstance | null,
+  ) {
+    const selfAnchor = (vnode.anchor = document.createComment('vapor'))
+    container.insertBefore(selfAnchor, anchor)
+    const prev = currentInstance
+    simpleSetCurrentInstance(parentComponent)
+    const propsRef = shallowRef(vnode.props)
+    // @ts-expect-error
+    const instance = (vnode.component = createComponent(vnode.type, {
+      $: [() => propsRef.value],
+    }))
+    instance.rawPropsRef = propsRef
+    mountComponent(instance, container, selfAnchor)
+    simpleSetCurrentInstance(prev)
+    return instance
+  },
+
+  update(n1: VNode, n2: VNode) {
+    n2.component = n1.component
+    ;(n2.component as any as VaporComponentInstance).rawPropsRef!.value =
+      n2.props
+  },
+
+  unmount(vnode: VNode, doRemove?: boolean) {
+    const container = doRemove ? vnode.anchor!.parentNode : undefined
+    unmountComponent(vnode.component as any, container)
+  },
+
+  move(vnode: VNode, container: ParentNode, anchor: Node) {
+    insert(vnode.component as any, container, anchor)
+    insert(vnode.anchor as any, container, anchor)
+  },
+}
+
+export const vaporInteropPlugin: Plugin = app => {
+  app.config.vapor = vaporInVDOMInterface
+}
index 9d682d9ffb6911b92f5a8a89ce2a655cb7f1383a..39c0d6fbe314d5d49ab351428d6723bc12b0c4fa 100644 (file)
@@ -1 +1,2 @@
-import './_entry'
+// import './_entry'
+import './interop'