From 436eda776395b2ff473057652bc3a6bef2991c1e Mon Sep 17 00:00:00 2001 From: edison Date: Mon, 20 Oct 2025 14:47:49 +0800 Subject: [PATCH] feat(vapor): template ref vdom interop (#13323) --- .../transformTemplateRef.spec.ts.snap | 2 +- .../transforms/transformTemplateRef.spec.ts | 4 +- .../src/generators/templateRef.ts | 8 +- packages/runtime-core/src/index.ts | 14 +- .../runtime-core/src/rendererTemplateRef.ts | 2 +- packages/runtime-core/src/vnode.ts | 11 +- packages/runtime-vapor/__tests__/_utils.ts | 51 ++++ .../__tests__/dom/templateRef.spec.ts | 227 +++++++++++++++++- .../runtime-vapor/__tests__/hydration.spec.ts | 53 +--- packages/runtime-vapor/src/apiTemplateRef.ts | 17 +- packages/runtime-vapor/src/block.ts | 7 + packages/runtime-vapor/src/vdomInterop.ts | 27 ++- 12 files changed, 357 insertions(+), 66 deletions(-) diff --git a/packages/compiler-vapor/__tests__/transforms/__snapshots__/transformTemplateRef.spec.ts.snap b/packages/compiler-vapor/__tests__/transforms/__snapshots__/transformTemplateRef.spec.ts.snap index 7184446fc0..15db96b6ac 100644 --- a/packages/compiler-vapor/__tests__/transforms/__snapshots__/transformTemplateRef.spec.ts.snap +++ b/packages/compiler-vapor/__tests__/transforms/__snapshots__/transformTemplateRef.spec.ts.snap @@ -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 " `; diff --git a/packages/compiler-vapor/__tests__/transforms/transformTemplateRef.spec.ts b/packages/compiler-vapor/__tests__/transforms/transformTemplateRef.spec.ts index 2c883d10cc..4a1d011c17 100644 --- a/packages/compiler-vapor/__tests__/transforms/transformTemplateRef.spec.ts +++ b/packages/compiler-vapor/__tests__/transforms/transformTemplateRef.spec.ts @@ -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', () => { diff --git a/packages/compiler-vapor/src/generators/templateRef.ts b/packages/compiler-vapor/src/generators/templateRef.ts index af8facc57b..3aa037a045 100644 --- a/packages/compiler-vapor/src/generators/templateRef.ts +++ b/packages/compiler-vapor/src/generators/templateRef.ts @@ -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)] } diff --git a/packages/runtime-core/src/index.ts b/packages/runtime-core/src/index.ts index 5c86a4962a..d0d4686af3 100644 --- a/packages/runtime-core/src/index.ts +++ b/packages/runtime-core/src/index.ts @@ -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 */ diff --git a/packages/runtime-core/src/rendererTemplateRef.ts b/packages/runtime-core/src/rendererTemplateRef.ts index 545edced17..97390655dd 100644 --- a/packages/runtime-core/src/rendererTemplateRef.ts +++ b/packages/runtime-core/src/rendererTemplateRef.ts @@ -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__) { diff --git a/packages/runtime-core/src/vnode.ts b/packages/runtime-core/src/vnode.ts index e451bb50c1..db7f6fcfbc 100644 --- a/packages/runtime-core/src/vnode.ts +++ b/packages/runtime-core/src/vnode.ts @@ -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 diff --git a/packages/runtime-vapor/__tests__/_utils.ts b/packages/runtime-vapor/__tests__/_utils.ts index 12efebf7c7..1e05611032 100644 --- a/packages/runtime-vapor/__tests__/_utils.ts +++ b/packages/runtime-vapor/__tests__/_utils.ts @@ -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, + components: Record = {}, + { + vapor = true, + ssr = false, + }: { + vapor?: boolean | undefined + ssr?: boolean | undefined + } = {}, +): any { + if (!sfc.includes(`const data = _data; const components = _components;` + + 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[] { let currentIndex = array.length let temporaryValue diff --git a/packages/runtime-vapor/__tests__/dom/templateRef.spec.ts b/packages/runtime-vapor/__tests__/dom/templateRef.spec.ts index 4e8a1a9253..a448a1be4a 100644 --- a/packages/runtime-vapor/__tests__/dom/templateRef.spec.ts +++ b/packages/runtime-vapor/__tests__/dom/templateRef.spec.ts @@ -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 = {}, + 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( + ` + `, + { + VaporChild: { + code: ` + + + `, + vapor: true, + }, + }, + ) + + expect(container.innerHTML).toBe( + `
foo
`, + ) + + const btn = container.querySelector('.btn') + triggerEvent('click', btn!) + await nextTick() + expect(container.innerHTML).toBe( + `
bar
`, + ) + }) + + test('vdom app: static ref with vapor child', async () => { + const { container } = await testTemplateRefInterop( + ` + `, + { + VaporChild: { + code: ` + + + `, + vapor: true, + }, + }, + ) + + expect(container.innerHTML).toBe( + `
foo
`, + ) + + const btn = container.querySelector('.btn') + triggerEvent('click', btn!) + await nextTick() + expect(container.innerHTML).toBe( + `
bar
`, + ) + }) + + test('vapor app: useTemplateRef with vdom child', async () => { + const { container } = await testTemplateRefInterop( + ` + `, + { + VDOMChild: { + code: ` + + + `, + vapor: false, + }, + }, + undefined, + { vapor: true }, + ) + + expect(container.innerHTML).toBe( + `
foo
`, + ) + + const btn = container.querySelector('.btn') + triggerEvent('click', btn!) + await nextTick() + expect(container.innerHTML).toBe( + `
bar
`, + ) + }) + + test('vapor app: static ref with vdom child', async () => { + const { container } = await testTemplateRefInterop( + ` + `, + { + VDomChild: { + code: ` + + + `, + vapor: false, + }, + }, + undefined, + { vapor: true }, + ) + + expect(container.innerHTML).toBe( + `
foo
`, + ) + + const btn = container.querySelector('.btn') + triggerEvent('click', btn!) + await nextTick() + expect(container.innerHTML).toBe( + `
bar
`, + ) + }) +}) diff --git a/packages/runtime-vapor/__tests__/hydration.spec.ts b/packages/runtime-vapor/__tests__/hydration.spec.ts index 6ba2bf895f..72d3fe27d6 100644 --- a/packages/runtime-vapor/__tests__/hydration.spec.ts +++ b/packages/runtime-vapor/__tests__/hydration.spec.ts @@ -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, - components: Record = {}, - ssr = false, -) { - if (!sfc.includes(`const data = _data; const components = _components;` + - 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), ) diff --git a/packages/runtime-vapor/src/apiTemplateRef.ts b/packages/runtime-vapor/src/apiTemplateRef.ts index 2c7b275c19..8d5d8585a5 100644 --- a/packages/runtime-vapor/src/apiTemplateRef.ts +++ b/packages/runtime-vapor/src/apiTemplateRef.ts @@ -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) => 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 } }) }) diff --git a/packages/runtime-vapor/src/block.ts b/packages/runtime-vapor/src/block.ts index 3aa5db41d7..146c2ec5ee 100644 --- a/packages/runtime-vapor/src/block.ts +++ b/packages/runtime-vapor/src/block.ts @@ -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 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 diff --git a/packages/runtime-vapor/src/vdomInterop.ts b/packages/runtime-vapor/src/vdomInterop.ts index 98470c37ff..dcd234ef57 100644 --- a/packages/runtime-vapor/src/vdomInterop.ts +++ b/packages/runtime-vapor/src/vdomInterop.ts @@ -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 } -- 2.47.3