From 3932cb6054ab280239408003b400f03632495754 Mon Sep 17 00:00:00 2001 From: daiwei Date: Wed, 3 Dec 2025 11:48:01 +0800 Subject: [PATCH 1/4] fix(runtime-vapor): conditionally enable `canSetSetupRef` check in dev mode --- packages/runtime-vapor/src/apiTemplateRef.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/runtime-vapor/src/apiTemplateRef.ts b/packages/runtime-vapor/src/apiTemplateRef.ts index 328748feb78..3b1ee9016e7 100644 --- a/packages/runtime-vapor/src/apiTemplateRef.ts +++ b/packages/runtime-vapor/src/apiTemplateRef.ts @@ -16,7 +16,7 @@ import { } from '@vue/runtime-dom' import { EMPTY_OBJ, - hasOwn, + NO, isArray, isFunction, isString, @@ -80,12 +80,12 @@ export function setRef( const refs = instance.refs === EMPTY_OBJ ? (instance.refs = {}) : instance.refs - const canSetSetupRef = createCanSetSetupRefChecker(setupState) + const canSetSetupRef = __DEV__ ? createCanSetSetupRefChecker(setupState) : NO // dynamic ref changed. unset old ref if (oldRef != null && oldRef !== ref) { if (isString(oldRef)) { refs[oldRef] = null - if (__DEV__ && hasOwn(setupState, oldRef)) { + if (__DEV__ && canSetSetupRef(oldRef)) { setupState[oldRef] = null } } else if (isRef(oldRef)) { From bb0e2424ef2065a90bfc5bd3f8ef5f4cd18901f1 Mon Sep 17 00:00:00 2001 From: daiwei Date: Wed, 3 Dec 2025 14:42:22 +0800 Subject: [PATCH 2/4] fix(templateRef): prevent duplicate `onScopeDispose` registrations for dynamic template refs --- .../__tests__/dom/templateRef.spec.ts | 79 +++++++++++++++++++ packages/runtime-vapor/src/apiTemplateRef.ts | 22 ++++-- 2 files changed, 96 insertions(+), 5 deletions(-) diff --git a/packages/runtime-vapor/__tests__/dom/templateRef.spec.ts b/packages/runtime-vapor/__tests__/dom/templateRef.spec.ts index d253d8822dd..b4f721b42a7 100644 --- a/packages/runtime-vapor/__tests__/dom/templateRef.spec.ts +++ b/packages/runtime-vapor/__tests__/dom/templateRef.spec.ts @@ -756,6 +756,85 @@ describe('api: template ref', () => { } }) + it('should not register duplicate onScopeDispose callbacks for dynamic function refs', async () => { + const fn1 = vi.fn() + const fn2 = vi.fn() + const toggle = ref(true) + const t0 = template('
') + + const { app } = define({ + render() { + const n0 = t0() + let r0: any + renderEffect(() => { + r0 = createTemplateRefSetter()( + n0 as Element, + toggle.value ? fn1 : fn2, + r0, + ) + }) + return n0 + }, + }).render() + + expect(fn1).toHaveBeenCalledTimes(1) + expect(fn2).toHaveBeenCalledTimes(0) + + toggle.value = false + await nextTick() + expect(fn1).toHaveBeenCalledTimes(1) + expect(fn2).toHaveBeenCalledTimes(1) + + toggle.value = true + await nextTick() + expect(fn1).toHaveBeenCalledTimes(2) + expect(fn2).toHaveBeenCalledTimes(1) + + app.unmount() + await nextTick() + // expected fn1 to be called again during scope dispose + expect(fn1).toHaveBeenCalledTimes(3) + expect(fn2).toHaveBeenCalledTimes(1) + }) + + it('should not register duplicate onScopeDispose callbacks for dynamic string refs', async () => { + const el1 = ref(null) + const el2 = ref(null) + const toggle = ref(true) + const t0 = template('
') + + const { app, host } = define({ + setup() { + return { ref1: el1, ref2: el2 } + }, + render() { + const n0 = t0() + let r0: any + renderEffect(() => { + r0 = createTemplateRefSetter()( + n0 as Element, + toggle.value ? 'ref1' : 'ref2', + r0, + ) + }) + return n0 + }, + }).render() + + expect(el1.value).toBe(host.children[0]) + expect(el2.value).toBe(null) + + toggle.value = false + await nextTick() + expect(el1.value).toBe(null) + expect(el2.value).toBe(host.children[0]) + + app.unmount() + await nextTick() + expect(el1.value).toBe(null) + expect(el2.value).toBe(null) + }) + // TODO: can not reproduce in Vapor // // #2078 // test('handling multiple merged refs', async () => { diff --git a/packages/runtime-vapor/src/apiTemplateRef.ts b/packages/runtime-vapor/src/apiTemplateRef.ts index 3b1ee9016e7..99bb9b1f61a 100644 --- a/packages/runtime-vapor/src/apiTemplateRef.ts +++ b/packages/runtime-vapor/src/apiTemplateRef.ts @@ -17,6 +17,7 @@ import { import { EMPTY_OBJ, NO, + NOOP, isArray, isFunction, isString, @@ -24,6 +25,19 @@ import { } from '@vue/shared' import { DynamicFragment, isFragment } from './fragment' +// track cleanup functions to prevent duplicate onScopeDispose registrations +const refCleanups = new WeakMap void }>() + +// ensure only register onScopeDispose once per element +function ensureCleanup(el: RefEl): { fn: () => void } { + let cleanupRef = refCleanups.get(el) + if (!cleanupRef) { + refCleanups.set(el, (cleanupRef = { fn: NOOP })) + onScopeDispose(() => cleanupRef!.fn()) + } + return cleanupRef +} + export type NodeRef = | string | Ref @@ -102,8 +116,7 @@ export function setRef( } invokeRefSetter(refValue) - // TODO this gets called repeatedly in renderEffect when it's dynamic ref? - onScopeDispose(() => invokeRefSetter()) + ensureCleanup(el).fn = () => invokeRefSetter() } else { const _isString = isString(ref) const _isRef = isRef(ref) @@ -150,8 +163,7 @@ export function setRef( } queuePostFlushCb(doSet, -1) - // TODO this gets called repeatedly in renderEffect when it's dynamic ref? - onScopeDispose(() => { + ensureCleanup(el).fn = () => { queuePostFlushCb(() => { if (isArray(existing)) { remove(existing, refValue) @@ -165,7 +177,7 @@ export function setRef( if (refKey) refs[refKey] = null } }) - }) + } } else if (__DEV__) { warn('Invalid template ref type:', ref, `(${typeof ref})`) } From 3e68a1b710478374370606368f39b88680d2522d Mon Sep 17 00:00:00 2001 From: daiwei Date: Wed, 3 Dec 2025 14:50:27 +0800 Subject: [PATCH 3/4] fix: ensure template ref cleanup passes null and deletes WeakMap entry --- .../__tests__/dom/templateRef.spec.ts | 2 +- packages/runtime-vapor/src/apiTemplateRef.ts | 31 ++++++++++--------- 2 files changed, 17 insertions(+), 16 deletions(-) diff --git a/packages/runtime-vapor/__tests__/dom/templateRef.spec.ts b/packages/runtime-vapor/__tests__/dom/templateRef.spec.ts index b4f721b42a7..221598a6024 100644 --- a/packages/runtime-vapor/__tests__/dom/templateRef.spec.ts +++ b/packages/runtime-vapor/__tests__/dom/templateRef.spec.ts @@ -182,7 +182,7 @@ describe('api: template ref', () => { expect(fn.mock.calls[0][0]).toBe(host.children[0]) toggle.value = false await nextTick() - expect(fn.mock.calls[1][0]).toBe(undefined) + expect(fn.mock.calls[1][0]).toBe(null) }) test('useTemplateRef mount', () => { diff --git a/packages/runtime-vapor/src/apiTemplateRef.ts b/packages/runtime-vapor/src/apiTemplateRef.ts index 99bb9b1f61a..5e50618bde3 100644 --- a/packages/runtime-vapor/src/apiTemplateRef.ts +++ b/packages/runtime-vapor/src/apiTemplateRef.ts @@ -25,19 +25,6 @@ import { } from '@vue/shared' import { DynamicFragment, isFragment } from './fragment' -// track cleanup functions to prevent duplicate onScopeDispose registrations -const refCleanups = new WeakMap void }>() - -// ensure only register onScopeDispose once per element -function ensureCleanup(el: RefEl): { fn: () => void } { - let cleanupRef = refCleanups.get(el) - if (!cleanupRef) { - refCleanups.set(el, (cleanupRef = { fn: NOOP })) - onScopeDispose(() => cleanupRef!.fn()) - } - return cleanupRef -} - export type NodeRef = | string | Ref @@ -52,6 +39,20 @@ export type setRefFn = ( refKey?: string, ) => NodeRef | undefined +const refCleanups = new WeakMap void }>() + +function ensureCleanup(el: RefEl): { fn: () => void } { + let cleanupRef = refCleanups.get(el) + if (!cleanupRef) { + refCleanups.set(el, (cleanupRef = { fn: NOOP })) + onScopeDispose(() => { + cleanupRef!.fn() + refCleanups.delete(el) + }) + } + return cleanupRef +} + export function createTemplateRefSetter(): setRefFn { const instance = currentInstance as VaporComponentInstance return (...args) => setRef(instance, ...args) @@ -108,7 +109,7 @@ export function setRef( } if (isFunction(ref)) { - const invokeRefSetter = (value?: Element | Record) => { + const invokeRefSetter = (value?: Element | Record | null) => { callWithErrorHandling(ref, currentInstance, ErrorCodes.FUNCTION_REF, [ value, refs, @@ -116,7 +117,7 @@ export function setRef( } invokeRefSetter(refValue) - ensureCleanup(el).fn = () => invokeRefSetter() + ensureCleanup(el).fn = () => invokeRefSetter(null) } else { const _isString = isString(ref) const _isRef = isRef(ref) From 4eaff718bb7ce3a255189135bb23eedece4ffeaa Mon Sep 17 00:00:00 2001 From: daiwei Date: Wed, 3 Dec 2025 15:01:48 +0800 Subject: [PATCH 4/4] test: add assertions for cleanup scope length in templateRef tests --- packages/runtime-vapor/__tests__/dom/templateRef.spec.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/runtime-vapor/__tests__/dom/templateRef.spec.ts b/packages/runtime-vapor/__tests__/dom/templateRef.spec.ts index 221598a6024..536ae965eb3 100644 --- a/packages/runtime-vapor/__tests__/dom/templateRef.spec.ts +++ b/packages/runtime-vapor/__tests__/dom/templateRef.spec.ts @@ -779,16 +779,19 @@ describe('api: template ref', () => { expect(fn1).toHaveBeenCalledTimes(1) expect(fn2).toHaveBeenCalledTimes(0) + expect(app._instance!.scope.cleanups.length).toBe(1) toggle.value = false await nextTick() expect(fn1).toHaveBeenCalledTimes(1) expect(fn2).toHaveBeenCalledTimes(1) + expect(app._instance!.scope.cleanups.length).toBe(1) toggle.value = true await nextTick() expect(fn1).toHaveBeenCalledTimes(2) expect(fn2).toHaveBeenCalledTimes(1) + expect(app._instance!.scope.cleanups.length).toBe(1) app.unmount() await nextTick() @@ -823,11 +826,13 @@ describe('api: template ref', () => { expect(el1.value).toBe(host.children[0]) expect(el2.value).toBe(null) + expect(app._instance!.scope.cleanups.length).toBe(1) toggle.value = false await nextTick() expect(el1.value).toBe(null) expect(el2.value).toBe(host.children[0]) + expect(app._instance!.scope.cleanups.length).toBe(1) app.unmount() await nextTick()