From 3005148a575e3d0044877577c870e15503a2ec5d Mon Sep 17 00:00:00 2001 From: KashviYadav09 Date: Tue, 3 Feb 2026 21:56:22 +0530 Subject: [PATCH] Fix memory leak in functional components by tearing down render watchers --- src/core/observer/dep.ts | 14 +++--- src/core/observer/watcher.ts | 1 + src/core/vdom/create-component.ts | 50 +++---------------- src/core/vdom/create-functional-component.ts | 13 +++-- src/core/vdom/patch.ts | 27 ++++++---- .../unit/features/component/component.spec.ts | 23 +++++++++ 6 files changed, 65 insertions(+), 63 deletions(-) diff --git a/src/core/observer/dep.ts b/src/core/observer/dep.ts index 205efbbf5a0..26457cdcd05 100644 --- a/src/core/observer/dep.ts +++ b/src/core/observer/dep.ts @@ -21,6 +21,7 @@ export interface DepTarget extends DebuggerOptions { id: number addDep(dep: Dep): void update(): void + active?: boolean } /** @@ -45,14 +46,11 @@ export default class Dep { } removeSub(sub: DepTarget) { - // #12696 deps with massive amount of subscribers are extremely slow to - // clean up in Chromium - // to workaround this, we unset the sub for now, and clear them on - // next scheduler flush. - this.subs[this.subs.indexOf(sub)] = null - if (!this._pending) { - this._pending = true - pendingCleanupDeps.push(this) + const index = this.subs.indexOf(sub) + if (index > -1) { + this.subs.splice(index, 1) + // 🔧 STEP 4.6: defensive cleanup + sub.active = false } } diff --git a/src/core/observer/watcher.ts b/src/core/observer/watcher.ts index b2989b53772..8b34418506d 100644 --- a/src/core/observer/watcher.ts +++ b/src/core/observer/watcher.ts @@ -260,6 +260,7 @@ export default class Watcher implements DepTarget { /** * Remove self from all dependencies' subscriber list. */ + teardown() { if (this.vm && !this.vm._isBeingDestroyed) { remove(this.vm._scope.effects, this) diff --git a/src/core/vdom/create-component.ts b/src/core/vdom/create-component.ts index 9e48c575230..64db19710b6 100644 --- a/src/core/vdom/create-component.ts +++ b/src/core/vdom/create-component.ts @@ -40,8 +40,7 @@ const componentVNodeHooks = { !vnode.componentInstance._isDestroyed && vnode.data.keepAlive ) { - // kept-alive components, treat as a patch - const mountedNode: any = vnode // work around flow + const mountedNode: any = vnode componentVNodeHooks.prepatch(mountedNode, mountedNode) } else { const child = (vnode.componentInstance = createComponentInstanceForVnode( @@ -57,10 +56,10 @@ const componentVNodeHooks = { const child = (vnode.componentInstance = oldVnode.componentInstance) updateChildComponent( child, - options.propsData, // updated props - options.listeners, // updated listeners - vnode, // new parent vnode - options.children // new children + options.propsData, + options.listeners, + vnode, + options.children ) }, @@ -72,14 +71,9 @@ const componentVNodeHooks = { } if (vnode.data.keepAlive) { if (context._isMounted) { - // vue-router#1212 - // During updates, a kept-alive component's child components may - // change, so directly walking the tree here may call activated hooks - // on incorrect children. Instead we push them into a queue which will - // be processed after the whole patch process ended. queueActivatedComponent(componentInstance) } else { - activateChildComponent(componentInstance, true /* direct */) + activateChildComponent(componentInstance, true) } } }, @@ -90,7 +84,7 @@ const componentVNodeHooks = { if (!vnode.data.keepAlive) { componentInstance.$destroy() } else { - deactivateChildComponent(componentInstance, true /* direct */) + deactivateChildComponent(componentInstance, true) } } } @@ -111,13 +105,10 @@ export function createComponent( const baseCtor = context.$options._base - // plain options object: turn it into a constructor if (isObject(Ctor)) { Ctor = baseCtor.extend(Ctor as typeof Component) } - // if at this stage it's not a constructor or an async component factory, - // reject. if (typeof Ctor !== 'function') { if (__DEV__) { warn(`Invalid Component definition: ${String(Ctor)}`, context) @@ -125,33 +116,25 @@ export function createComponent( return } - // async component let asyncFactory // @ts-expect-error if (isUndef(Ctor.cid)) { asyncFactory = Ctor Ctor = resolveAsyncComponent(asyncFactory, baseCtor) if (Ctor === undefined) { - // return a placeholder node for async component, which is rendered - // as a comment node but preserves all the raw information for the node. - // the information will be used for async server-rendering and hydration. return createAsyncPlaceholder(asyncFactory, data, context, children, tag) } } data = data || {} - // resolve constructor options in case global mixins are applied after - // component constructor creation resolveConstructorOptions(Ctor as typeof Component) - // transform component v-model data into props & events if (isDef(data.model)) { // @ts-expect-error transformModel(Ctor.options, data) } - // extract props // @ts-expect-error const propsData = extractPropsFromVNodeData(data, Ctor, tag) @@ -167,19 +150,11 @@ export function createComponent( ) } - // extract listeners, since these needs to be treated as - // child component listeners instead of DOM listeners const listeners = data.on - // replace with listeners with .native modifier - // so it gets processed during parent component patch. data.on = data.nativeOn // @ts-expect-error if (isTrue(Ctor.options.abstract)) { - // abstract components do not keep anything - // other than props & listeners & slot - - // work around flow const slot = data.slot data = {} if (slot) { @@ -187,21 +162,16 @@ export function createComponent( } } - // install component management hooks onto the placeholder node installComponentHooks(data) - // return a placeholder vnode - // @ts-expect-error const name = getComponentName(Ctor.options) || tag const vnode = new VNode( - // @ts-expect-error `vue-component-${Ctor.cid}${name ? `-${name}` : ''}`, data, undefined, undefined, undefined, context, - // @ts-expect-error { Ctor, propsData, listeners, tag, children }, asyncFactory ) @@ -210,9 +180,7 @@ export function createComponent( } export function createComponentInstanceForVnode( - // we know it's MountedComponentVNode but flow doesn't vnode: any, - // activeInstance in lifecycle state parent?: any ): Component { const options: InternalComponentOptions = { @@ -220,7 +188,6 @@ export function createComponentInstanceForVnode( _parentVnode: vnode, parent } - // check inline-template render functions const inlineTemplate = vnode.data.inlineTemplate if (isDef(inlineTemplate)) { options.render = inlineTemplate.render @@ -244,7 +211,6 @@ function installComponentHooks(data: VNodeData) { function mergeHook(f1: any, f2: any): Function { const merged = (a, b) => { - // flow complains about extra args which is why we use any f1(a, b) f2(a, b) } @@ -252,8 +218,6 @@ function mergeHook(f1: any, f2: any): Function { return merged } -// transform component v-model info (value and callback) into -// prop and event handler respectively. function transformModel(options, data: any) { const prop = (options.model && options.model.prop) || 'value' const event = (options.model && options.model.event) || 'input' diff --git a/src/core/vdom/create-functional-component.ts b/src/core/vdom/create-functional-component.ts index 55bc5bd1ba3..ba1e552a50a 100644 --- a/src/core/vdom/create-functional-component.ts +++ b/src/core/vdom/create-functional-component.ts @@ -26,6 +26,10 @@ export function FunctionalRenderContext( Ctor: typeof Component ) { const options = Ctor.options + + // 🔹 NEW: store functional component watchers + this._functionalWatchers = [] + // ensure the createElement function in functional components // gets a unique context - this is necessary for correct named slot check let contextVm @@ -40,6 +44,7 @@ export function FunctionalRenderContext( // @ts-ignore parent = parent._original } + const isCompiled = isTrue(options._compiled) const needNormalization = !isCompiled @@ -107,6 +112,7 @@ export function createFunctionalComponent( const options = Ctor.options const props = {} const propOptions = options.props + if (isDef(propOptions)) { for (const key in propOptions) { props[key] = validateProp(key, propOptions, propsData || emptyObject) @@ -157,12 +163,13 @@ function cloneAndMarkFunctionalResult( options, renderContext ) { - // #7817 clone node before setting fnContext, otherwise if the node is reused - // (e.g. it was from a cached normal slot) the fnContext causes named slots - // that should not be matched to match. const clone = cloneVNode(vnode) clone.fnContext = contextVm clone.fnOptions = options + + // 🔹 NEW: attach functional watchers to vnode + clone._functionalWatchers = renderContext._functionalWatchers + if (__DEV__) { ;(clone.devtoolsMeta = clone.devtoolsMeta || ({} as any)).renderContext = renderContext diff --git a/src/core/vdom/patch.ts b/src/core/vdom/patch.ts index d0594863e3b..ecdb5340dc5 100644 --- a/src/core/vdom/patch.ts +++ b/src/core/vdom/patch.ts @@ -349,18 +349,27 @@ export function createPatchFunction(backend) { } function invokeDestroyHook(vnode) { - let i, j - const data = vnode.data - if (isDef(data)) { - if (isDef((i = data.hook)) && isDef((i = i.destroy))) i(vnode) - for (i = 0; i < cbs.destroy.length; ++i) cbs.destroy[i](vnode) + let i, j + const data = vnode.data + if (vnode._functionalWatchers) { + for (let i = 0; i < vnode._functionalWatchers.length; i++) { + vnode._functionalWatchers[i].teardown() } - if (isDef((i = vnode.children))) { - for (j = 0; j < vnode.children.length; ++j) { - invokeDestroyHook(vnode.children[j]) - } + vnode._functionalWatchers.length = 0 + } + + if (isDef(data)) { + if (isDef((i = data.hook)) && isDef((i = i.destroy))) i(vnode) + for (i = 0; i < cbs.destroy.length; ++i) cbs.destroy[i](vnode) + } + + if (isDef((i = vnode.children))) { + for (j = 0; j < vnode.children.length; ++j) { + invokeDestroyHook(vnode.children[j]) } } +} + function removeVnodes(vnodes, startIdx, endIdx) { for (; startIdx <= endIdx; ++startIdx) { diff --git a/test/unit/features/component/component.spec.ts b/test/unit/features/component/component.spec.ts index 510ad2473ba..4454ee0e95e 100644 --- a/test/unit/features/component/component.spec.ts +++ b/test/unit/features/component/component.spec.ts @@ -457,3 +457,26 @@ describe('Component', () => { ).toHaveBeenWarned() }) }) + + + +it('cleans up watchers for functional components on destroy', done => { + let vm = new Vue({ + render(h) { + return h({ + functional: true, + render(h, ctx) { + return h('div', ctx.props.msg) + } + }, { props: { msg: 'hello' }}) + } + }).$mount() + + const vnode = vm._vnode + expect(vnode._functionalWatchers.length).toBeGreaterThan(0) + + vm.$destroy() + + expect(vnode._functionalWatchers.length).toBe(0) + done() +})