]> git.ipfire.org Git - thirdparty/vuejs/core.git/commitdiff
feat(vapor): template ref vdom interop (#13323)
authoredison <daiwei521@126.com>
Mon, 20 Oct 2025 06:47:49 +0000 (14:47 +0800)
committerGitHub <noreply@github.com>
Mon, 20 Oct 2025 06:47:49 +0000 (14:47 +0800)
12 files changed:
packages/compiler-vapor/__tests__/transforms/__snapshots__/transformTemplateRef.spec.ts.snap
packages/compiler-vapor/__tests__/transforms/transformTemplateRef.spec.ts
packages/compiler-vapor/src/generators/templateRef.ts
packages/runtime-core/src/index.ts
packages/runtime-core/src/rendererTemplateRef.ts
packages/runtime-core/src/vnode.ts
packages/runtime-vapor/__tests__/_utils.ts
packages/runtime-vapor/__tests__/dom/templateRef.spec.ts
packages/runtime-vapor/__tests__/hydration.spec.ts
packages/runtime-vapor/src/apiTemplateRef.ts
packages/runtime-vapor/src/block.ts
packages/runtime-vapor/src/vdomInterop.ts

index 7184446fc09d0a7bd42ab12550f4892df3fbf955..15db96b6aceff32f51fb33ade523820cd4f2154a 100644 (file)
@@ -67,7 +67,7 @@ exports[`compiler: template ref transform > static ref (inline mode) 1`] = `
 "
   const _setTemplateRef = _createTemplateRefSetter()
   const n0 = t0()
-  _setTemplateRef(n0, foo)
+  _setTemplateRef(n0, foo, null, null, "foo")
   return n0
 "
 `;
index 2c883d10cc6cd16e822b321ac50c6c91feba7320..4a1d011c178f45c1b7bec81f923913cb1e2a8d5f 100644 (file)
@@ -55,8 +55,8 @@ describe('compiler: template ref transform', () => {
       bindingMetadata: { foo: BindingTypes.SETUP_REF },
     })
     expect(code).matchSnapshot()
-    // pass the actual ref
-    expect(code).contains('_setTemplateRef(n0, foo)')
+    // pass the actual ref and ref key
+    expect(code).contains('_setTemplateRef(n0, foo, null, null, "foo")')
   })
 
   test('dynamic ref', () => {
index af8facc57b1f80b1342b16bb31ea8d192ef21e54..3aa037a04581cd8ab699737e7ef8d88151b961d7 100644 (file)
@@ -10,15 +10,17 @@ export function genSetTemplateRef(
   oper: SetTemplateRefIRNode,
   context: CodegenContext,
 ): CodeFragment[] {
+  const [refValue, refKey] = genRefValue(oper.value, context)
   return [
     NEWLINE,
     oper.effect && `r${oper.element} = `,
     ...genCall(
       setTemplateRefIdent, // will be generated in root scope
       `n${oper.element}`,
-      genRefValue(oper.value, context),
+      refValue,
       oper.effect ? `r${oper.element}` : oper.refFor ? 'void 0' : undefined,
       oper.refFor && 'true',
+      refKey,
     ),
   ]
 }
@@ -38,8 +40,8 @@ function genRefValue(value: SimpleExpressionNode, context: CodegenContext) {
       binding === BindingTypes.SETUP_REF ||
       binding === BindingTypes.SETUP_MAYBE_REF
     ) {
-      return [value.content]
+      return [[value.content], JSON.stringify(value.content)]
     }
   }
-  return genExpression(value, context)
+  return [genExpression(value, context)]
 }
index 5c86a4962acf2803ff2889260a169625af37da7f..d0d4686af3b3cba79ab31d4342274487c6c75c7c 100644 (file)
@@ -114,7 +114,11 @@ export { Fragment, Text, Comment, Static, type VNodeRef } from './vnode'
 // Built-in components
 export { Teleport, type TeleportProps } from './components/Teleport'
 export { Suspense, type SuspenseProps } from './components/Suspense'
-export { KeepAlive, type KeepAliveProps } from './components/KeepAlive'
+export {
+  KeepAlive,
+  type KeepAliveProps,
+  type KeepAliveContext,
+} from './components/KeepAlive'
 export {
   BaseTransition,
   BaseTransitionPropsValidators,
@@ -563,6 +567,14 @@ export { startMeasure, endMeasure } from './profiling'
  * @internal
  */
 export { initFeatureFlags } from './featureFlags'
+/**
+ * @internal
+ */
+export { setRef } from './rendererTemplateRef'
+/**
+ * @internal
+ */
+export { type VNodeNormalizedRef, normalizeRef } from './vnode'
 /**
  * @internal
  */
index 545edced17236049407770d1b466c6b13be236a1..97390655ddfb2d990bfb380ef1636c7aaadc177a 100644 (file)
@@ -175,7 +175,7 @@ export function createCanSetSetupRefChecker(
   setupState: Data,
 ): (key: string) => boolean {
   const rawSetupState = toRaw(setupState)
-  return setupState === EMPTY_OBJ
+  return setupState === undefined || setupState === EMPTY_OBJ
     ? NO
     : (key: string) => {
         if (__DEV__) {
index e451bb50c1b5df513a884e319a6fc8b4443f17c9..db7f6fcfbc6938c18cbee0d7d65226b15295798e 100644 (file)
@@ -455,18 +455,17 @@ const createVNodeWithArgsTransform = (
 const normalizeKey = ({ key }: VNodeProps): VNode['key'] =>
   key != null ? key : null
 
-const normalizeRef = ({
-  ref,
-  ref_key,
-  ref_for,
-}: VNodeProps): VNodeNormalizedRefAtom | null => {
+export const normalizeRef = (
+  { ref, ref_key, ref_for }: VNodeProps,
+  i: ComponentInternalInstance = currentRenderingInstance!,
+): VNodeNormalizedRefAtom | null => {
   if (typeof ref === 'number') {
     ref = '' + ref
   }
   return (
     ref != null
       ? isString(ref) || isRef(ref) || isFunction(ref)
-        ? { i: currentRenderingInstance, r: ref, k: ref_key, f: !!ref_for }
+        ? { i, r: ref, k: ref_key, f: !!ref_for }
         : ref
       : null
   ) as any
index 12efebf7c7a79ae3cb5f4a5a364bd6320904bbd1..1e056110320dcc231974600ffcdad625427540c8 100644 (file)
@@ -2,6 +2,10 @@ import { createVaporApp, vaporInteropPlugin } from '../src'
 import { type App, type Component, createApp } from '@vue/runtime-dom'
 import type { VaporComponent, VaporComponentInstance } from '../src/component'
 import type { RawProps } from '../src/componentProps'
+import { compileScript, parse } from '@vue/compiler-sfc'
+import * as runtimeVapor from '../src'
+import * as runtimeDom from '@vue/runtime-dom'
+import * as VueServerRenderer from '@vue/server-renderer'
 
 export interface RenderContext {
   component: VaporComponent
@@ -136,6 +140,53 @@ export function makeInteropRender(): (comp: Component) => InteropRenderContext {
   return define
 }
 
+export { runtimeDom, runtimeVapor, VueServerRenderer }
+export function compile(
+  sfc: string,
+  data: runtimeDom.Ref<any>,
+  components: Record<string, any> = {},
+  {
+    vapor = true,
+    ssr = false,
+  }: {
+    vapor?: boolean | undefined
+    ssr?: boolean | undefined
+  } = {},
+): any {
+  if (!sfc.includes(`<script`)) {
+    sfc =
+      `<script vapor>const data = _data; const components = _components;</script>` +
+      sfc
+  }
+  const descriptor = parse(sfc).descriptor
+
+  const script = compileScript(descriptor, {
+    id: 'x',
+    isProd: true,
+    inlineTemplate: true,
+    genDefaultAs: '__sfc__',
+    vapor,
+    templateOptions: {
+      ssr,
+    },
+  })
+
+  const code =
+    script.content
+      .replace(/\bimport {/g, 'const {')
+      .replace(/ as _/g, ': _')
+      .replace(/} from ['"]vue['"]/g, `} = Vue`)
+      .replace(/} from "vue\/server-renderer"/g, '} = VueServerRenderer') +
+    '\nreturn __sfc__'
+
+  return new Function('Vue', 'VueServerRenderer', '_data', '_components', code)(
+    { ...runtimeDom, ...runtimeVapor },
+    VueServerRenderer,
+    data,
+    components,
+  )
+}
+
 export function shuffle(array: Array<any>): any[] {
   let currentIndex = array.length
   let temporaryValue
index 4e8a1a9253c2a41d07d673c55eb25b5d5d2d93bc..a448a1be4ab23ab7aaaa3b10d42ff3e695c05db3 100644 (file)
@@ -8,11 +8,12 @@ import {
   createSlot,
   createTemplateRefSetter,
   defineVaporComponent,
+  delegateEvents,
   insert,
   renderEffect,
   template,
 } from '../../src'
-import { makeRender } from '../_utils'
+import { compile, makeRender, runtimeDom, runtimeVapor } from '../_utils'
 import {
   type ShallowRef,
   currentInstance,
@@ -794,3 +795,227 @@ describe('api: template ref', () => {
   //   expect(elRef1.value).toBe(elRef2.value)
   // })
 })
+
+describe('interop: template ref', () => {
+  beforeEach(() => {
+    document.body.innerHTML = ''
+  })
+
+  const triggerEvent = (type: string, el: Element) => {
+    const event = new Event(type, { bubbles: true })
+    el.dispatchEvent(event)
+  }
+
+  delegateEvents('click')
+
+  async function testTemplateRefInterop(
+    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]
+      const 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 }
+  }
+
+  test('vdom app: useTemplateRef with vapor child', async () => {
+    const { container } = await testTemplateRefInterop(
+      `<script setup>
+        import { useTemplateRef } from 'vue'
+        const components = _components;
+        const elRef = useTemplateRef('el')
+        function click() {
+          elRef.value.change()
+        }
+      </script>
+      <template>
+        <button class="btn" @click="click"></button>
+        <components.VaporChild ref="el"/>
+      </template>`,
+      {
+        VaporChild: {
+          code: `
+            <script vapor>
+              import { ref } from 'vue'
+              const msg = ref('foo')
+              function change(){
+                msg.value = 'bar'
+              }
+              defineExpose({ change })
+            </script>
+            <template><div>{{msg}}</div></template>
+          `,
+          vapor: true,
+        },
+      },
+    )
+
+    expect(container.innerHTML).toBe(
+      `<button class="btn"></button><div>foo</div>`,
+    )
+
+    const btn = container.querySelector('.btn')
+    triggerEvent('click', btn!)
+    await nextTick()
+    expect(container.innerHTML).toBe(
+      `<button class="btn"></button><div>bar</div>`,
+    )
+  })
+
+  test('vdom app: static ref with vapor child', async () => {
+    const { container } = await testTemplateRefInterop(
+      `<script setup>
+        import { ref } from 'vue'
+        const components = _components;
+        const elRef = ref(null)
+        function click() {
+          elRef.value.change()
+        }
+      </script>
+      <template>
+        <button class="btn" @click="click"></button>
+        <components.VaporChild ref="elRef"/>
+      </template>`,
+      {
+        VaporChild: {
+          code: `
+            <script vapor>
+              import { ref } from 'vue'
+              const msg = ref('foo')
+              function change(){
+                msg.value = 'bar'
+              }
+              defineExpose({ change })
+            </script>
+            <template><div>{{msg}}</div></template>
+          `,
+          vapor: true,
+        },
+      },
+    )
+
+    expect(container.innerHTML).toBe(
+      `<button class="btn"></button><div>foo</div>`,
+    )
+
+    const btn = container.querySelector('.btn')
+    triggerEvent('click', btn!)
+    await nextTick()
+    expect(container.innerHTML).toBe(
+      `<button class="btn"></button><div>bar</div>`,
+    )
+  })
+
+  test('vapor app: useTemplateRef with vdom child', async () => {
+    const { container } = await testTemplateRefInterop(
+      `<script vapor>
+        import { useTemplateRef } from 'vue'
+        const components = _components;
+        const elRef = useTemplateRef('el')
+        function click() {
+          elRef.value.change()
+        }
+      </script>
+      <template>
+        <button class="btn" @click="click"></button>
+        <components.VDOMChild ref="el"/>
+      </template>`,
+      {
+        VDOMChild: {
+          code: `
+            <script setup>
+              import { ref } from 'vue'
+              const msg = ref('foo')
+              function change(){
+                msg.value = 'bar'
+              }
+              defineExpose({ change })
+            </script>
+            <template><div>{{msg}}</div></template>
+          `,
+          vapor: false,
+        },
+      },
+      undefined,
+      { vapor: true },
+    )
+
+    expect(container.innerHTML).toBe(
+      `<button class="btn"></button><div>foo</div>`,
+    )
+
+    const btn = container.querySelector('.btn')
+    triggerEvent('click', btn!)
+    await nextTick()
+    expect(container.innerHTML).toBe(
+      `<button class="btn"></button><div>bar</div>`,
+    )
+  })
+
+  test('vapor app: static ref with vdom child', async () => {
+    const { container } = await testTemplateRefInterop(
+      `<script vapor>
+        import { ref } from 'vue'
+        const components = _components;
+        const elRef = ref(null)
+        function click() {
+          elRef.value.change()
+        }
+      </script>
+      <template>
+        <button class="btn" @click="click"></button>
+        <components.VDomChild ref="elRef"/>
+      </template>`,
+      {
+        VDomChild: {
+          code: `
+            <script setup>
+              import { ref } from 'vue'
+              const msg = ref('foo')
+              function change(){
+                msg.value = 'bar'
+              }
+              defineExpose({ change })
+            </script>
+            <template><div>{{msg}}</div></template>
+          `,
+          vapor: false,
+        },
+      },
+      undefined,
+      { vapor: true },
+    )
+
+    expect(container.innerHTML).toBe(
+      `<button class="btn"></button><div>foo</div>`,
+    )
+
+    const btn = container.querySelector('.btn')
+    triggerEvent('click', btn!)
+    await nextTick()
+    expect(container.innerHTML).toBe(
+      `<button class="btn"></button><div>bar</div>`,
+    )
+  })
+})
index 6ba2bf895fbb23ed7cfc6ba3da2d36d706330f18..72d3fe27d648686637c7cb134f1eff219a213b39 100644 (file)
@@ -1,51 +1,6 @@
 import { createVaporSSRApp, delegateEvents } from '../src'
 import { nextTick, ref } from '@vue/runtime-dom'
-import { compileScript, parse } from '@vue/compiler-sfc'
-import * as runtimeVapor from '../src'
-import * as runtimeDom from '@vue/runtime-dom'
-import * as VueServerRenderer from '@vue/server-renderer'
-
-const Vue = { ...runtimeDom, ...runtimeVapor }
-
-function compile(
-  sfc: string,
-  data: runtimeDom.Ref<any>,
-  components: Record<string, any> = {},
-  ssr = false,
-) {
-  if (!sfc.includes(`<script`)) {
-    sfc =
-      `<script vapor>const data = _data; const components = _components;</script>` +
-      sfc
-  }
-  const descriptor = parse(sfc).descriptor
-
-  const script = compileScript(descriptor, {
-    id: 'x',
-    isProd: true,
-    inlineTemplate: true,
-    genDefaultAs: '__sfc__',
-    vapor: true,
-    templateOptions: {
-      ssr,
-    },
-  })
-
-  const code =
-    script.content
-      .replace(/\bimport {/g, 'const {')
-      .replace(/ as _/g, ': _')
-      .replace(/} from ['"]vue['"]/g, `} = Vue`)
-      .replace(/} from "vue\/server-renderer"/g, '} = VueServerRenderer') +
-    '\nreturn __sfc__'
-
-  return new Function('Vue', 'VueServerRenderer', '_data', '_components', code)(
-    Vue,
-    VueServerRenderer,
-    data,
-    components,
-  )
-}
+import { VueServerRenderer, compile, runtimeDom } from './_utils'
 
 async function testHydration(
   code: string,
@@ -56,10 +11,12 @@ async function testHydration(
   const clientComponents: any = {}
   for (const key in components) {
     clientComponents[key] = compile(components[key], data, clientComponents)
-    ssrComponents[key] = compile(components[key], data, ssrComponents, true)
+    ssrComponents[key] = compile(components[key], data, ssrComponents, {
+      ssr: true,
+    })
   }
 
-  const serverComp = compile(code, data, ssrComponents, true)
+  const serverComp = compile(code, data, ssrComponents, { ssr: true })
   const html = await VueServerRenderer.renderToString(
     runtimeDom.createSSRApp(serverComp),
   )
index 2c7b275c194277a628e67c1a13df231b43171a08..8d5d8585a56c52c91a4bb7edaed097cef7cd6b72 100644 (file)
@@ -21,9 +21,12 @@ import {
   isString,
   remove,
 } from '@vue/shared'
-import { DynamicFragment } from './block'
+import { DynamicFragment, isFragment } from './block'
 
-export type NodeRef = string | Ref | ((ref: Element) => void)
+export type NodeRef =
+  | string
+  | Ref
+  | ((ref: Element | VaporComponentInstance, refs: Record<string, any>) => void)
 export type RefEl = Element | VaporComponentInstance
 
 export type setRefFn = (
@@ -47,9 +50,16 @@ export function setRef(
   ref: NodeRef,
   oldRef?: NodeRef,
   refFor = false,
+  refKey?: string,
 ): NodeRef | undefined {
   if (!instance || instance.isUnmounted) return
 
+  // vdom interop
+  if (isFragment(el) && el.setRef) {
+    el.setRef(instance, ref, refFor, refKey)
+    return
+  }
+
   const setupState: any = __DEV__ ? instance.setupState || {} : null
   const refValue = getRefValue(el)
   const refs =
@@ -106,6 +116,7 @@ export function setRef(
               }
             } else {
               ref.value = existing
+              if (refKey) refs[refKey] = existing
             }
           } else if (!existing.includes(refValue)) {
             existing.push(refValue)
@@ -117,6 +128,7 @@ export function setRef(
           }
         } else if (_isRef) {
           ref.value = refValue
+          if (refKey) refs[refKey] = refValue
         } else if (__DEV__) {
           warn('Invalid template ref type:', ref, `(${typeof ref})`)
         }
@@ -135,6 +147,7 @@ export function setRef(
             }
           } else if (_isRef) {
             ref.value = null
+            if (refKey) refs[refKey] = null
           }
         })
       })
index 3aa5db41d7ce98f314f85fac8cbd2446112fd2c8..146c2ec5ee9fb275f468e830640a7f8089ee3eb0 100644 (file)
@@ -9,6 +9,7 @@ import {
 import { createComment, createTextNode } from './dom/node'
 import { EffectScope, setActiveSub } from '@vue/reactivity'
 import { isHydrating } from './dom/hydration'
+import type { NodeRef } from './apiTemplateRef'
 import {
   type TransitionHooks,
   type TransitionProps,
@@ -53,6 +54,12 @@ export class VaporFragment<T extends Block = Block>
   nodes: T
   vnode?: VNode | null = null
   anchor?: Node
+  setRef?: (
+    instance: VaporComponentInstance,
+    ref: NodeRef,
+    refFor: boolean,
+    refKey: string | undefined,
+  ) => void
   fallback?: BlockFn
   $key?: any
   $transition?: VaporTransitionHooks | undefined
index 98470c37ffa765aefa1834dd67dcb2343abcaf8f..dcd234ef578d418d1688f03bff5d79872f699954 100644 (file)
@@ -2,6 +2,7 @@ import {
   type App,
   type ComponentInternalInstance,
   type ConcreteComponent,
+  type KeepAliveContext,
   MoveType,
   type Plugin,
   type RendererElement,
@@ -11,6 +12,7 @@ import {
   type Slots,
   type TransitionHooks,
   type VNode,
+  type VNodeNormalizedRef,
   type VaporInteropInterface,
   createInternalObject,
   createVNode,
@@ -20,6 +22,7 @@ import {
   isEmitListener,
   isKeepAlive,
   isVNode,
+  normalizeRef,
   onScopeDispose,
   renderSlot,
   setTransitionHooks as setVNodeTransitionHooks,
@@ -28,6 +31,7 @@ import {
   simpleSetCurrentInstance,
   activate as vdomActivate,
   deactivate as vdomDeactivate,
+  setRef as vdomSetRef,
 } from '@vue/runtime-dom'
 import {
   type LooseRawProps,
@@ -60,13 +64,13 @@ import type { RawSlots, VaporSlot } from './componentSlots'
 import { renderEffect } from './renderEffect'
 import { createTextNode } from './dom/node'
 import { optimizePropertyLookup } from './dom/prop'
+import type { NodeRef } from './apiTemplateRef'
 import { setTransitionHooks as setVaporTransitionHooks } from './components/Transition'
 import {
   type KeepAliveInstance,
   activate,
   deactivate,
 } from './components/KeepAlive'
-import type { KeepAliveContext } from 'packages/runtime-core/src/components/KeepAlive'
 
 export const interopKey: unique symbol = Symbol(`interop`)
 
@@ -260,9 +264,12 @@ function createVDOMComponent(
         : new Proxy(wrapper.slots, vaporSlotsProxyHandler)
   }
 
+  let rawRef: VNodeNormalizedRef | null = null
   let isMounted = false
   const parentInstance = currentInstance as VaporComponentInstance
   const unmount = (parentNode?: ParentNode, transition?: TransitionHooks) => {
+    // unset ref
+    if (rawRef) vdomSetRef(rawRef, null, null, vnode, true)
     if (transition) setVNodeTransitionHooks(vnode, transition)
     if (vnode.shapeFlag & ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE) {
       vdomDeactivate(
@@ -305,6 +312,8 @@ function createVDOMComponent(
         undefined,
         false,
       )
+      // set ref
+      if (rawRef) vdomSetRef(rawRef, null, null, vnode)
       onScopeDispose(unmount, true)
       isMounted = true
     } else {
@@ -324,6 +333,22 @@ function createVDOMComponent(
 
   frag.remove = unmount
 
+  frag.setRef = (
+    instance: VaporComponentInstance,
+    ref: NodeRef,
+    refFor: boolean,
+    refKey: string | undefined,
+  ): void => {
+    rawRef = normalizeRef(
+      {
+        ref: ref as any,
+        ref_for: refFor,
+        ref_key: refKey,
+      },
+      instance as any,
+    )
+  }
+
   return frag
 }