fix(compiler-core): prevent cached array children from retaining detached dom nodes

This commit is contained in:
daiwei 2025-07-23 11:13:20 +08:00
parent c486536105
commit 18d2fac858
9 changed files with 61 additions and 92 deletions

View File

@ -170,11 +170,6 @@ describe('compiler: cacheStatic transform', () => {
{ {
/* _ slot flag */ /* _ slot flag */
}, },
{
type: NodeTypes.JS_PROPERTY,
key: { content: '__' },
value: { content: '[0]' },
},
], ],
}) })
}) })
@ -202,11 +197,6 @@ describe('compiler: cacheStatic transform', () => {
{ {
/* _ slot flag */ /* _ slot flag */
}, },
{
type: NodeTypes.JS_PROPERTY,
key: { content: '__' },
value: { content: '[0]' },
},
], ],
}) })
}) })

View File

@ -420,6 +420,7 @@ export interface CacheExpression extends Node {
needPauseTracking: boolean needPauseTracking: boolean
inVOnce: boolean inVOnce: boolean
needArraySpread: boolean needArraySpread: boolean
cachedAsArray: boolean
} }
export interface MemoExpression extends CallExpression { export interface MemoExpression extends CallExpression {
@ -784,6 +785,7 @@ export function createCacheExpression(
needPauseTracking: needPauseTracking, needPauseTracking: needPauseTracking,
inVOnce, inVOnce,
needArraySpread: false, needArraySpread: false,
cachedAsArray: false,
loc: locStub, loc: locStub,
} }
} }

View File

@ -1012,7 +1012,7 @@ function genConditionalExpression(
function genCacheExpression(node: CacheExpression, context: CodegenContext) { function genCacheExpression(node: CacheExpression, context: CodegenContext) {
const { push, helper, indent, deindent, newline } = context const { push, helper, indent, deindent, newline } = context
const { needPauseTracking, needArraySpread } = node const { needPauseTracking, needArraySpread, cachedAsArray } = node
if (needArraySpread) { if (needArraySpread) {
push(`[...(`) push(`[...(`)
} }
@ -1027,6 +1027,9 @@ function genCacheExpression(node: CacheExpression, context: CodegenContext) {
} }
push(`_cache[${node.index}] = `) push(`_cache[${node.index}] = `)
genNode(node.value, context) genNode(node.value, context)
if (cachedAsArray) {
push(`, _cache[${node.index}].patchFlag = -1, _cache[${node.index}]`)
}
if (needPauseTracking) { if (needPauseTracking) {
push(`).cacheIndex = ${node.index},`) push(`).cacheIndex = ${node.index},`)
newline() newline()

View File

@ -12,14 +12,11 @@ import {
type RootNode, type RootNode,
type SimpleExpressionNode, type SimpleExpressionNode,
type SlotFunctionExpression, type SlotFunctionExpression,
type SlotsObjectProperty,
type TemplateChildNode, type TemplateChildNode,
type TemplateNode, type TemplateNode,
type TextCallNode, type TextCallNode,
type VNodeCall, type VNodeCall,
createArrayExpression, createArrayExpression,
createObjectProperty,
createSimpleExpression,
getVNodeBlockHelper, getVNodeBlockHelper,
getVNodeHelper, getVNodeHelper,
} from '../ast' } from '../ast'
@ -70,7 +67,10 @@ function walk(
inFor = false, inFor = false,
) { ) {
const { children } = node const { children } = node
const toCache: (PlainElementNode | TextCallNode)[] = [] const toCacheMap = new Map<
PlainElementNode | TextCallNode,
undefined | (() => void)
>()
for (let i = 0; i < children.length; i++) { for (let i = 0; i < children.length; i++) {
const child = children[i] const child = children[i]
// only plain elements & text calls are eligible for caching. // only plain elements & text calls are eligible for caching.
@ -83,8 +83,11 @@ function walk(
: getConstantType(child, context) : getConstantType(child, context)
if (constantType > ConstantTypes.NOT_CONSTANT) { if (constantType > ConstantTypes.NOT_CONSTANT) {
if (constantType >= ConstantTypes.CAN_CACHE) { if (constantType >= ConstantTypes.CAN_CACHE) {
;(child.codegenNode as VNodeCall).patchFlag = PatchFlags.CACHED toCacheMap.set(
toCache.push(child) child,
() =>
((child.codegenNode as VNodeCall).patchFlag = PatchFlags.CACHED),
)
continue continue
} }
} else { } else {
@ -115,16 +118,17 @@ function walk(
? ConstantTypes.NOT_CONSTANT ? ConstantTypes.NOT_CONSTANT
: getConstantType(child, context) : getConstantType(child, context)
if (constantType >= ConstantTypes.CAN_CACHE) { if (constantType >= ConstantTypes.CAN_CACHE) {
if ( toCacheMap.set(child, () => {
child.codegenNode.type === NodeTypes.JS_CALL_EXPRESSION && if (
child.codegenNode.arguments.length > 0 child.codegenNode.type === NodeTypes.JS_CALL_EXPRESSION &&
) { child.codegenNode.arguments.length > 0
child.codegenNode.arguments.push( ) {
PatchFlags.CACHED + child.codegenNode.arguments.push(
(__DEV__ ? ` /* ${PatchFlagNames[PatchFlags.CACHED]} */` : ``), PatchFlags.CACHED +
) (__DEV__ ? ` /* ${PatchFlagNames[PatchFlags.CACHED]} */` : ``),
} )
toCache.push(child) }
})
continue continue
} }
} }
@ -157,8 +161,7 @@ function walk(
} }
let cachedAsArray = false let cachedAsArray = false
const slotCacheKeys = [] if (toCacheMap.size === children.length && node.type === NodeTypes.ELEMENT) {
if (toCache.length === children.length && node.type === NodeTypes.ELEMENT) {
if ( if (
node.tagType === ElementTypes.ELEMENT && node.tagType === ElementTypes.ELEMENT &&
node.codegenNode && node.codegenNode &&
@ -181,7 +184,6 @@ function walk(
// default slot // default slot
const slot = getSlotNode(node.codegenNode, 'default') const slot = getSlotNode(node.codegenNode, 'default')
if (slot) { if (slot) {
slotCacheKeys.push(context.cached.length)
slot.returns = getCacheExpression( slot.returns = getCacheExpression(
createArrayExpression(slot.returns as TemplateChildNode[]), createArrayExpression(slot.returns as TemplateChildNode[]),
) )
@ -205,7 +207,6 @@ function walk(
slotName.arg && slotName.arg &&
getSlotNode(parent.codegenNode, slotName.arg) getSlotNode(parent.codegenNode, slotName.arg)
if (slot) { if (slot) {
slotCacheKeys.push(context.cached.length)
slot.returns = getCacheExpression( slot.returns = getCacheExpression(
createArrayExpression(slot.returns as TemplateChildNode[]), createArrayExpression(slot.returns as TemplateChildNode[]),
) )
@ -215,33 +216,16 @@ function walk(
} }
if (!cachedAsArray) { if (!cachedAsArray) {
for (const child of toCache) { for (const [child, setupCache] of toCacheMap) {
slotCacheKeys.push(context.cached.length) if (setupCache) setupCache()
child.codegenNode = context.cache(child.codegenNode!) child.codegenNode = context.cache(child.codegenNode!)
} }
} }
// put the slot cached keys on the slot object, so that the cache function getCacheExpression(
// can be removed when component unmounting to prevent memory leaks value: JSChildNode,
if ( cachedAsArray: boolean = true,
slotCacheKeys.length && ): CacheExpression {
node.type === NodeTypes.ELEMENT &&
node.tagType === ElementTypes.COMPONENT &&
node.codegenNode &&
node.codegenNode.type === NodeTypes.VNODE_CALL &&
node.codegenNode.children &&
!isArray(node.codegenNode.children) &&
node.codegenNode.children.type === NodeTypes.JS_OBJECT_EXPRESSION
) {
node.codegenNode.children.properties.push(
createObjectProperty(
`__`,
createSimpleExpression(JSON.stringify(slotCacheKeys), false),
) as SlotsObjectProperty,
)
}
function getCacheExpression(value: JSChildNode): CacheExpression {
const exp = context.cache(value) const exp = context.cache(value)
// #6978, #7138, #7114 // #6978, #7138, #7114
// a cached children array inside v-for can caused HMR errors since // a cached children array inside v-for can caused HMR errors since
@ -249,6 +233,7 @@ function walk(
if (inFor && context.hmr) { if (inFor && context.hmr) {
exp.needArraySpread = true exp.needArraySpread = true
} }
exp.cachedAsArray = cachedAsArray
return exp return exp
} }
@ -268,7 +253,7 @@ function walk(
} }
} }
if (toCache.length && context.transformHoist) { if (toCacheMap.size && context.transformHoist) {
context.transformHoist(children, context, node) context.transformHoist(children, context, node)
} }
} }

View File

@ -56,14 +56,10 @@ describe('component: slots', () => {
expect(Object.getOwnPropertyDescriptor(slots, '_')!.enumerable).toBe( expect(Object.getOwnPropertyDescriptor(slots, '_')!.enumerable).toBe(
false, false,
) )
expect(slots).toHaveProperty('__')
expect(Object.getOwnPropertyDescriptor(slots, '__')!.enumerable).toBe(
false,
)
return h('div') return h('div')
}, },
} }
const slots = { foo: () => {}, _: 1, __: [1] } const slots = { foo: () => {}, _: 1 }
render(createBlock(Comp, null, slots), nodeOps.createElement('div')) render(createBlock(Comp, null, slots), nodeOps.createElement('div'))
}) })

View File

@ -79,15 +79,10 @@ export type RawSlots = {
* @internal * @internal
*/ */
_?: SlotFlags _?: SlotFlags
/**
* cache indexes for slot content
* @internal
*/
__?: number[]
} }
const isInternalKey = (key: string) => const isInternalKey = (key: string) =>
key === '_' || key === '__' || key === '_ctx' || key === '$stable' key === '_' || key === '_ctx' || key === '$stable'
const normalizeSlotValue = (value: unknown): VNode[] => const normalizeSlotValue = (value: unknown): VNode[] =>
isArray(value) isArray(value)
@ -194,10 +189,6 @@ export const initSlots = (
): void => { ): void => {
const slots = (instance.slots = createInternalObject()) const slots = (instance.slots = createInternalObject())
if (instance.vnode.shapeFlag & ShapeFlags.SLOTS_CHILDREN) { if (instance.vnode.shapeFlag & ShapeFlags.SLOTS_CHILDREN) {
const cacheIndexes = (children as RawSlots).__
// make cache indexes marker non-enumerable
if (cacheIndexes) def(slots, '__', cacheIndexes, true)
const type = (children as RawSlots)._ const type = (children as RawSlots)._
if (type) { if (type) {
assignSlots(slots, children as Slots, optimized) assignSlots(slots, children as Slots, optimized)

View File

@ -9,6 +9,7 @@ import {
Fragment, Fragment,
type VNode, type VNode,
type VNodeArrayChildren, type VNodeArrayChildren,
cloneVNode,
createBlock, createBlock,
createVNode, createVNode,
isVNode, isVNode,
@ -71,12 +72,24 @@ export function renderSlot(
;(slot as ContextualRenderFn)._d = false ;(slot as ContextualRenderFn)._d = false
} }
openBlock() openBlock()
const validSlotContent = slot && ensureValidVNode(slot(props)) let validSlotContent = slot && ensureValidVNode(slot(props))
const slotKey = const slotKey =
props.key || props.key ||
// slot content array of a dynamic conditional slot may have a branch // slot content array of a dynamic conditional slot may have a branch
// key attached in the `createSlots` helper, respect that // key attached in the `createSlots` helper, respect that
(validSlotContent && (validSlotContent as any).key) (validSlotContent && (validSlotContent as any).key)
// if slot content is an cached array, deep clone it to prevent
// cached vnodes from retaining detached DOM nodes
if (
validSlotContent &&
(validSlotContent as any).patchFlag === PatchFlags.CACHED
) {
validSlotContent = (validSlotContent as VNode[]).map(child =>
cloneVNode(child),
)
}
const rendered = createBlock( const rendered = createBlock(
Fragment, Fragment,
{ {

View File

@ -2277,17 +2277,7 @@ function baseCreateRenderer(
unregisterHMR(instance) unregisterHMR(instance)
} }
const { const { bum, scope, job, subTree, um, m, a } = instance
bum,
scope,
job,
subTree,
um,
m,
a,
parent,
slots: { __: slotCacheKeys },
} = instance
invalidateMount(m) invalidateMount(m)
invalidateMount(a) invalidateMount(a)
@ -2296,13 +2286,6 @@ function baseCreateRenderer(
invokeArrayFns(bum) invokeArrayFns(bum)
} }
// remove slots content from parent renderCache
if (parent && isArray(slotCacheKeys)) {
slotCacheKeys.forEach(v => {
parent.renderCache[v] = undefined
})
}
if ( if (
__COMPAT__ && __COMPAT__ &&
isCompatEnabled(DeprecationTypes.INSTANCE_EVENT_HOOKS, instance) isCompatEnabled(DeprecationTypes.INSTANCE_EVENT_HOOKS, instance)

View File

@ -471,7 +471,11 @@ function createBaseVNode(
ref: props && normalizeRef(props), ref: props && normalizeRef(props),
scopeId: currentScopeId, scopeId: currentScopeId,
slotScopeIds: null, slotScopeIds: null,
children, children:
// children is a cached array
isArray(children) && (children as any).patchFlag === PatchFlags.CACHED
? (children as VNode[]).map(child => cloneVNode(child))
: children,
component: null, component: null,
suspense: null, suspense: null,
ssContent: null, ssContent: null,
@ -680,7 +684,9 @@ export function cloneVNode<T, U>(
scopeId: vnode.scopeId, scopeId: vnode.scopeId,
slotScopeIds: vnode.slotScopeIds, slotScopeIds: vnode.slotScopeIds,
children: children:
__DEV__ && patchFlag === PatchFlags.CACHED && isArray(children) // if vnode is cached, deep clone it's children to prevent cached children
// from retaining detached DOM nodes
patchFlag === PatchFlags.CACHED && isArray(children)
? (children as VNode[]).map(deepCloneVNode) ? (children as VNode[]).map(deepCloneVNode)
: children, : children,
target: vnode.target, target: vnode.target,
@ -738,7 +744,7 @@ export function cloneVNode<T, U>(
} }
/** /**
* Dev only, for HMR of hoisted vnodes reused in v-for * for HMR of hoisted vnodes reused in v-for
* https://github.com/vitejs/vite/issues/2022 * https://github.com/vitejs/vite/issues/2022
*/ */
function deepCloneVNode(vnode: VNode): VNode { function deepCloneVNode(vnode: VNode): VNode {