fix(compiler-core): prevent comments from blocking static node hoisting (#13345)

close #13344
This commit is contained in:
Alex Snezhko 2025-06-04 22:23:00 -04:00 committed by GitHub
parent 47ddf98602
commit 55dad625ac
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 92 additions and 15 deletions

View File

@ -410,6 +410,32 @@ return function render(_ctx, _cache) {
}" }"
`; `;
exports[`compiler: cacheStatic transform > should hoist props for root with single element excluding comments 1`] = `
"const _Vue = Vue
const { createElementVNode: _createElementVNode, createCommentVNode: _createCommentVNode } = _Vue
const _hoisted_1 = { id: "a" }
return function render(_ctx, _cache) {
with (_ctx) {
const { createCommentVNode: _createCommentVNode, createElementVNode: _createElementVNode, Fragment: _Fragment, openBlock: _openBlock, createElementBlock: _createElementBlock } = _Vue
return (_openBlock(), _createElementBlock(_Fragment, null, [
_createCommentVNode("comment"),
_createElementVNode("div", _hoisted_1, _cache[0] || (_cache[0] = [
_createElementVNode("div", { id: "b" }, [
_createElementVNode("div", { id: "c" }, [
_createElementVNode("div", { id: "d" }, [
_createElementVNode("div", { id: "e" }, "hello")
])
])
], -1 /* HOISTED */)
]))
], 2112 /* STABLE_FRAGMENT, DEV_ROOT_FRAGMENT */))
}
}"
`;
exports[`compiler: cacheStatic transform > should hoist v-for children if static 1`] = ` exports[`compiler: cacheStatic transform > should hoist v-for children if static 1`] = `
"const _Vue = Vue "const _Vue = Vue
const { createElementVNode: _createElementVNode } = _Vue const { createElementVNode: _createElementVNode } = _Vue

View File

@ -543,6 +543,32 @@ describe('compiler: cacheStatic transform', () => {
expect(generate(root).code).toMatchSnapshot() expect(generate(root).code).toMatchSnapshot()
}) })
test('should hoist props for root with single element excluding comments', () => {
// deeply nested div to trigger stringification condition
const root = transformWithCache(
`<!--comment--><div id="a"><div id="b"><div id="c"><div id="d"><div id="e">hello</div></div></div></div></div>`,
)
expect(root.cached.length).toBe(1)
expect(root.hoists).toMatchObject([createObjectMatcher({ id: 'a' })])
expect((root.codegenNode as VNodeCall).children).toMatchObject([
{
type: NodeTypes.COMMENT,
content: 'comment',
},
{
type: NodeTypes.ELEMENT,
codegenNode: {
type: NodeTypes.VNODE_CALL,
tag: `"div"`,
props: { content: `_hoisted_1` },
children: { type: NodeTypes.JS_CACHE_EXPRESSION },
},
},
])
expect(generate(root).code).toMatchSnapshot()
})
describe('prefixIdentifiers', () => { describe('prefixIdentifiers', () => {
test('cache nested static tree with static interpolation', () => { test('cache nested static tree with static interpolation', () => {
const root = transformWithCache( const root = transformWithCache(

View File

@ -37,7 +37,7 @@ import {
helperNameMap, helperNameMap,
} from './runtimeHelpers' } from './runtimeHelpers'
import { isVSlot } from './utils' import { isVSlot } from './utils'
import { cacheStatic, isSingleElementRoot } from './transforms/cacheStatic' import { cacheStatic, getSingleElementRoot } from './transforms/cacheStatic'
import type { CompilerCompatOptions } from './compat/compatConfig' import type { CompilerCompatOptions } from './compat/compatConfig'
// There are two types of transforms: // There are two types of transforms:
@ -356,12 +356,12 @@ function createRootCodegen(root: RootNode, context: TransformContext) {
const { helper } = context const { helper } = context
const { children } = root const { children } = root
if (children.length === 1) { if (children.length === 1) {
const child = children[0] const singleElementRootChild = getSingleElementRoot(root)
// if the single child is an element, turn it into a block. // if the single child is an element, turn it into a block.
if (isSingleElementRoot(root, child) && child.codegenNode) { if (singleElementRootChild && singleElementRootChild.codegenNode) {
// single element root is never hoisted so codegenNode will never be // single element root is never hoisted so codegenNode will never be
// SimpleExpressionNode // SimpleExpressionNode
const codegenNode = child.codegenNode const codegenNode = singleElementRootChild.codegenNode
if (codegenNode.type === NodeTypes.VNODE_CALL) { if (codegenNode.type === NodeTypes.VNODE_CALL) {
convertToBlock(codegenNode, context) convertToBlock(codegenNode, context)
} }
@ -370,7 +370,7 @@ function createRootCodegen(root: RootNode, context: TransformContext) {
// - single <slot/>, IfNode, ForNode: already blocks. // - single <slot/>, IfNode, ForNode: already blocks.
// - single text node: always patched. // - single text node: always patched.
// root codegen falls through via genNode() // root codegen falls through via genNode()
root.codegenNode = child root.codegenNode = children[0]
} }
} else if (children.length > 1) { } else if (children.length > 1) {
// root has multiple nodes - return a fragment block. // root has multiple nodes - return a fragment block.

View File

@ -41,20 +41,19 @@ export function cacheStatic(root: RootNode, context: TransformContext): void {
context, context,
// Root node is unfortunately non-hoistable due to potential parent // Root node is unfortunately non-hoistable due to potential parent
// fallthrough attributes. // fallthrough attributes.
isSingleElementRoot(root, root.children[0]), !!getSingleElementRoot(root),
) )
} }
export function isSingleElementRoot( export function getSingleElementRoot(
root: RootNode, root: RootNode,
child: TemplateChildNode, ): PlainElementNode | ComponentNode | TemplateNode | null {
): child is PlainElementNode | ComponentNode | TemplateNode { const children = root.children.filter(x => x.type !== NodeTypes.COMMENT)
const { children } = root return children.length === 1 &&
return ( children[0].type === NodeTypes.ELEMENT &&
children.length === 1 && !isSlotOutlet(children[0])
child.type === NodeTypes.ELEMENT && ? children[0]
!isSlotOutlet(child) : null
)
} }
function walk( function walk(

View File

@ -75,6 +75,22 @@ return function render(_ctx, _cache) {
}" }"
`; `;
exports[`stringify static html > should bail for comments 1`] = `
"const { createCommentVNode: _createCommentVNode, createElementVNode: _createElementVNode, createStaticVNode: _createStaticVNode, Fragment: _Fragment, openBlock: _openBlock, createElementBlock: _createElementBlock } = Vue
const _hoisted_1 = { class: "a" }
return function render(_ctx, _cache) {
return (_openBlock(), _createElementBlock(_Fragment, null, [
_createCommentVNode(" Comment 1 "),
_createElementVNode("div", _hoisted_1, [
_createCommentVNode(" Comment 2 "),
_cache[0] || (_cache[0] = _createStaticVNode("<span class=\\"b\\"></span><span class=\\"b\\"></span><span class=\\"b\\"></span><span class=\\"b\\"></span><span class=\\"b\\"></span>", 5))
])
], 2112 /* STABLE_FRAGMENT, DEV_ROOT_FRAGMENT */))
}"
`;
exports[`stringify static html > should bail on bindings that are cached but not stringifiable 1`] = ` exports[`stringify static html > should bail on bindings that are cached but not stringifiable 1`] = `
"const { createElementVNode: _createElementVNode, openBlock: _openBlock, createElementBlock: _createElementBlock } = Vue "const { createElementVNode: _createElementVNode, openBlock: _openBlock, createElementBlock: _createElementBlock } = Vue

View File

@ -491,6 +491,16 @@ describe('stringify static html', () => {
expect(code).toMatchSnapshot() expect(code).toMatchSnapshot()
}) })
test('should bail for comments', () => {
const { code } = compileWithStringify(
`<!-- Comment 1 --><div class="a"><!-- Comment 2 -->${repeat(
`<span class="b"/>`,
StringifyThresholds.ELEMENT_WITH_BINDING_COUNT,
)}</div>`,
)
expect(code).toMatchSnapshot()
})
test('should bail for <option> elements with null values', () => { test('should bail for <option> elements with null values', () => {
const { ast, code } = compileWithStringify( const { ast, code } = compileWithStringify(
`<div><select><option :value="null" />${repeat( `<div><select><option :value="null" />${repeat(