diff --git a/packages/compiler-core/__tests__/transforms/__snapshots__/vIf.spec.ts.snap b/packages/compiler-core/__tests__/transforms/__snapshots__/vIf.spec.ts.snap index 65ee73c46f4..648d61cec12 100644 --- a/packages/compiler-core/__tests__/transforms/__snapshots__/vIf.spec.ts.snap +++ b/packages/compiler-core/__tests__/transforms/__snapshots__/vIf.spec.ts.snap @@ -86,6 +86,129 @@ return function render(_ctx, _cache) { }" `; +exports[`compiler: v-if > codegen > user-defined keys > avoid duplicate keys 1`] = ` +"const _Vue = Vue + +return function render(_ctx, _cache) { + with (_ctx) { + const { mergeProps: _mergeProps, openBlock: _openBlock, createElementBlock: _createElementBlock, createCommentVNode: _createCommentVNode } = _Vue + + return ok + ? (_openBlock(), _createElementBlock("div", _mergeProps({ key: "custom_key" }, obj), null, 16 /* FULL_PROPS */)) + : _createCommentVNode("v-if", true) + } +}" +`; + +exports[`compiler: v-if > codegen > user-defined keys > correct key matching 1`] = ` +"const _Vue = Vue +const { createCommentVNode: _createCommentVNode } = _Vue + +const _hoisted_1 = Symbol() + +return function render(_ctx, _cache) { + with (_ctx) { + const { normalizeProps: _normalizeProps, guardReactiveProps: _guardReactiveProps, openBlock: _openBlock, createElementBlock: _createElementBlock, mergeProps: _mergeProps, createCommentVNode: _createCommentVNode, Fragment: _Fragment } = _Vue + + return (_openBlock(), _createElementBlock(_Fragment, null, [ + ok + ? (_openBlock(), _createElementBlock("div", _normalizeProps(_mergeProps({ key: 0 }, separate)), null, 16 /* FULL_PROPS */)) + : _createCommentVNode("v-if", true), + (_openBlock(), _createElementBlock("div", _mergeProps({ key: 123 }, other1), null, 16 /* FULL_PROPS */)), + ok1 + ? (_openBlock(), _createElementBlock("div", _normalizeProps(_mergeProps({ key: _hoisted_1 }, obj1)), null, 16 /* FULL_PROPS */)) + : (_openBlock(), _createElementBlock("div", _mergeProps({ key: 0 }, obj2), null, 16 /* FULL_PROPS */)) + ], 64 /* STABLE_FRAGMENT */)) + } +}" +`; + +exports[`compiler: v-if > codegen > user-defined keys > key on v-else 1`] = ` +"const _Vue = Vue +const { createCommentVNode: _createCommentVNode } = _Vue + +const _hoisted_1 = Symbol() +const _hoisted_2 = Symbol() + +return function render(_ctx, _cache) { + with (_ctx) { + const { normalizeProps: _normalizeProps, guardReactiveProps: _guardReactiveProps, openBlock: _openBlock, createElementBlock: _createElementBlock, mergeProps: _mergeProps, createCommentVNode: _createCommentVNode } = _Vue + + return ok1 + ? (_openBlock(), _createElementBlock("div", _normalizeProps(_mergeProps({ key: _hoisted_1 }, obj1)), null, 16 /* FULL_PROPS */)) + : ok2 + ? (_openBlock(), _createElementBlock("div", _normalizeProps(_mergeProps({ key: _hoisted_2 }, obj2)), null, 16 /* FULL_PROPS */)) + : (_openBlock(), _createElementBlock("div", _mergeProps({ key: 0 }, obj3), null, 16 /* FULL_PROPS */)) + } +}" +`; + +exports[`compiler: v-if > codegen > user-defined keys > key on v-else with misc irrelevant nodes between 1`] = ` +"const _Vue = Vue +const { createElementVNode: _createElementVNode, createCommentVNode: _createCommentVNode } = _Vue + +const _hoisted_1 = Symbol() +const _hoisted_2 = Symbol() + +return function render(_ctx, _cache) { + with (_ctx) { + const { normalizeProps: _normalizeProps, guardReactiveProps: _guardReactiveProps, openBlock: _openBlock, createElementBlock: _createElementBlock, mergeProps: _mergeProps, createCommentVNode: _createCommentVNode, createElementVNode: _createElementVNode, Fragment: _Fragment } = _Vue + + return ok1 + ? (_openBlock(), _createElementBlock("div", _normalizeProps(_mergeProps({ key: _hoisted_1 }, obj1)), null, 16 /* FULL_PROPS */)) + : ok2 + ? (_openBlock(), _createElementBlock(_Fragment, { key: _hoisted_2 }, [ + _createCommentVNode("comment1"), + _createElementVNode("div", _normalizeProps(_guardReactiveProps(obj2)), null, 16 /* FULL_PROPS */) + ], 2112 /* STABLE_FRAGMENT, DEV_ROOT_FRAGMENT */)) + : (_openBlock(), _createElementBlock(_Fragment, { key: 0 }, [ + _createCommentVNode("comment2"), + (_openBlock(), _createElementBlock("div", _mergeProps({ key: 0 }, obj3), null, 16 /* FULL_PROPS */)) + ], 2112 /* STABLE_FRAGMENT, DEV_ROOT_FRAGMENT */)) + } +}" +`; + +exports[`compiler: v-if > codegen > user-defined keys > key on v-else-if 1`] = ` +"const _Vue = Vue +const { createCommentVNode: _createCommentVNode } = _Vue + +const _hoisted_1 = Symbol() +const _hoisted_2 = Symbol() + +return function render(_ctx, _cache) { + with (_ctx) { + const { normalizeProps: _normalizeProps, guardReactiveProps: _guardReactiveProps, openBlock: _openBlock, createElementBlock: _createElementBlock, mergeProps: _mergeProps, createCommentVNode: _createCommentVNode } = _Vue + + return ok1 + ? (_openBlock(), _createElementBlock("div", _normalizeProps(_mergeProps({ key: _hoisted_1 }, obj1)), null, 16 /* FULL_PROPS */)) + : ok2 + ? (_openBlock(), _createElementBlock("div", _mergeProps({ key: 0 }, obj2), null, 16 /* FULL_PROPS */)) + : (_openBlock(), _createElementBlock("div", _normalizeProps(_mergeProps({ key: _hoisted_2 }, obj3)), null, 16 /* FULL_PROPS */)) + } +}" +`; + +exports[`compiler: v-if > codegen > user-defined keys > key on v-if 1`] = ` +"const _Vue = Vue +const { createCommentVNode: _createCommentVNode } = _Vue + +const _hoisted_1 = Symbol() +const _hoisted_2 = Symbol() + +return function render(_ctx, _cache) { + with (_ctx) { + const { mergeProps: _mergeProps, openBlock: _openBlock, createElementBlock: _createElementBlock, createCommentVNode: _createCommentVNode, normalizeProps: _normalizeProps, guardReactiveProps: _guardReactiveProps } = _Vue + + return ok1 + ? (_openBlock(), _createElementBlock("div", _mergeProps({ key: 1 }, obj1), null, 16 /* FULL_PROPS */)) + : ok2 + ? (_openBlock(), _createElementBlock("div", _normalizeProps(_mergeProps({ key: _hoisted_1 }, obj2)), null, 16 /* FULL_PROPS */)) + : (_openBlock(), _createElementBlock("div", _normalizeProps(_mergeProps({ key: _hoisted_2 }, obj3)), null, 16 /* FULL_PROPS */)) + } +}" +`; + exports[`compiler: v-if > codegen > v-if + v-else 1`] = ` "const _Vue = Vue diff --git a/packages/compiler-core/__tests__/transforms/vIf.spec.ts b/packages/compiler-core/__tests__/transforms/vIf.spec.ts index 1e0067aa32a..2e030ae040a 100644 --- a/packages/compiler-core/__tests__/transforms/vIf.spec.ts +++ b/packages/compiler-core/__tests__/transforms/vIf.spec.ts @@ -21,6 +21,7 @@ import { type CompilerOptions, TO_HANDLERS, generate, + transformBind, transformVBindShorthand, } from '../../src' import { @@ -37,6 +38,7 @@ function parseWithIfTransform( options: CompilerOptions = {}, returnIndex: number = 0, childrenLen: number = 1, + expectAllChildrenIfNodes: boolean = true, ) { const ast = parse(template, options) transform(ast, { @@ -50,8 +52,10 @@ function parseWithIfTransform( }) if (!options.onError) { expect(ast.children.length).toBe(childrenLen) - for (let i = 0; i < childrenLen; i++) { - expect(ast.children[i].type).toBe(NodeTypes.IF) + if (expectAllChildrenIfNodes) { + for (let i = 0; i < childrenLen; i++) { + expect(ast.children[i].type).toBe(NodeTypes.IF) + } } } return { @@ -670,21 +674,120 @@ describe('compiler: v-if', () => { expect(branch1.props).toMatchObject(createObjectMatcher({ key: `[0]` })) }) - // #6631 - test('avoid duplicate keys', () => { - const { - node: { codegenNode }, - } = parseWithIfTransform(`
`) - const branch1 = codegenNode.consequent as VNodeCall - expect(branch1.props).toMatchObject({ - type: NodeTypes.JS_CALL_EXPRESSION, - callee: MERGE_PROPS, - arguments: [ - createObjectMatcher({ - key: 'custom_key', - }), - { content: `obj` }, - ], + describe('user-defined keys', () => { + // #6631 + test('avoid duplicate keys', () => { + const { + root, + node: { codegenNode }, + } = parseWithIfTransform( + `
`, + ) + const branch1 = codegenNode.consequent as VNodeCall + expect(branch1.props).toMatchObject({ + type: NodeTypes.JS_CALL_EXPRESSION, + callee: MERGE_PROPS, + arguments: [ + createObjectMatcher({ + key: 'custom_key', + }), + { content: `obj` }, + ], + }) + expect(generate(root).code).toMatchSnapshot() + }) + + test('key on v-if', () => { + const { root } = parseWithIfTransform( + `
`, + { directiveTransforms: { bind: transformBind } }, + ) + expect(root.hoists).toMatchObject([ + { content: 'Symbol()' }, + { content: 'Symbol()' }, + ]) + + const code = generate(root).code + expect(code).toContain('_mergeProps({ key: 1 }, obj1)') + expect(code).toContain('_mergeProps({ key: _hoisted_1 }, obj2)') + expect(code).toContain('_mergeProps({ key: _hoisted_2 }, obj3)') + expect(code).toMatchSnapshot() + }) + + test('key on v-else-if', () => { + const { root } = parseWithIfTransform( + `
`, + { directiveTransforms: { bind: transformBind } }, + ) + expect(root.hoists).toMatchObject([ + { content: 'Symbol()' }, + { content: 'Symbol()' }, + ]) + + const code = generate(root).code + expect(code).toContain('_mergeProps({ key: _hoisted_1 }, obj1)') + expect(code).toContain('_mergeProps({ key: 0 }, obj2)') + expect(code).toContain('_mergeProps({ key: _hoisted_2 }, obj3)') + expect(code).toMatchSnapshot() + }) + + test('key on v-else', () => { + const { root } = parseWithIfTransform( + `
`, + { directiveTransforms: { bind: transformBind } }, + ) + expect(root.hoists).toMatchObject([ + { content: 'Symbol()' }, + { content: 'Symbol()' }, + ]) + + const code = generate(root).code + expect(code).toContain('_mergeProps({ key: _hoisted_1 }, obj1)') + expect(code).toContain('_mergeProps({ key: _hoisted_2 }, obj2)') + expect(code).toContain('_mergeProps({ key: 0 }, obj3)') + expect(code).toMatchSnapshot() + }) + + test('key on v-else with misc irrelevant nodes between', () => { + const { root } = parseWithIfTransform( + `
`, + { directiveTransforms: { bind: transformBind } }, + 0, + 1, + false, + ) + expect(root.hoists).toMatchObject([ + { content: 'Symbol()' }, + { content: 'Symbol()' }, + ]) + + const code = generate(root).code + expect(code).toContain('_mergeProps({ key: _hoisted_1 }, obj1)') + expect(code).toContain( + '_createElementBlock(_Fragment, { key: _hoisted_2 }', + ) + expect(code).toContain('_normalizeProps(_guardReactiveProps(obj2))') + expect(code).toContain('_mergeProps({ key: 0 }, obj3)') + expect(code).toMatchSnapshot() + }) + + test('correct key matching', () => { + const { root } = parseWithIfTransform( + `
`, + { directiveTransforms: { bind: transformBind } }, + 0, + 3, + false, + ) + expect(root.hoists).toMatchObject([{ content: 'Symbol()' }]) + + const code = generate(root).code + // not part of if-block with a key anywhere so expect + expect(code).toContain('_mergeProps({ key: 0 }, separate)') + expect(code).toContain('_mergeProps({ key: 123 }, other1)') + expect(code).toContain('_mergeProps({ key: _hoisted_1 }, obj1)') + expect(code).toContain('_mergeProps({ key: 0 }, obj2)') + expect(code).toMatchSnapshot() }) }) diff --git a/packages/compiler-core/src/ast.ts b/packages/compiler-core/src/ast.ts index 2d6df9d9010..5a436138822 100644 --- a/packages/compiler-core/src/ast.ts +++ b/packages/compiler-core/src/ast.ts @@ -280,6 +280,7 @@ export interface IfNode extends Node { type: NodeTypes.IF branches: IfBranchNode[] codegenNode?: IfConditionalExpression | CacheExpression //
+ anyBranchesHaveUserDefinedKey?: boolean } export interface IfBranchNode extends Node { diff --git a/packages/compiler-core/src/transforms/vIf.ts b/packages/compiler-core/src/transforms/vIf.ts index 74575322d46..f120ef3da0b 100644 --- a/packages/compiler-core/src/transforms/vIf.ts +++ b/packages/compiler-core/src/transforms/vIf.ts @@ -18,6 +18,7 @@ import { type MemoExpression, NodeTypes, type SimpleExpressionNode, + type TemplateChildNode, convertToBlock, createCallExpression, createConditionalExpression, @@ -59,6 +60,7 @@ export const transformIf: NodeTransform = createStructuralDirectiveTransform( ifNode.codegenNode = createCodegenNodeForBranch( branch, key, + !!ifNode.anyBranchesHaveUserDefinedKey, context, ) as IfConditionalExpression } else { @@ -67,6 +69,7 @@ export const transformIf: NodeTransform = createStructuralDirectiveTransform( parentCondition.alternate = createCodegenNodeForBranch( branch, key + ifNode.branches.length - 1, + !!ifNode.anyBranchesHaveUserDefinedKey, context, ) } @@ -109,10 +112,37 @@ export function processIf( if (dir.name === 'if') { const branch = createIfBranch(node, dir) + const siblings = context.parent!.children + let anyBranchesHaveUserDefinedKey = !!branch.userKey + for ( + let i = siblings.indexOf(node) + 1; + !anyBranchesHaveUserDefinedKey && i < siblings.length; + i++ + ) { + const sibling = siblings[i] + if ( + !sibling || + sibling.type === NodeTypes.COMMENT || + isEmptyTextNode(sibling) + ) { + continue + } + + if ( + sibling.type !== NodeTypes.ELEMENT || + !(findDir(sibling, 'else-if') || findDir(sibling, 'else', true)) + ) { + break + } + + anyBranchesHaveUserDefinedKey = !!findProp(sibling, 'key') + } + const ifNode: IfNode = { type: NodeTypes.IF, loc: cloneLoc(node.loc), branches: [branch], + anyBranchesHaveUserDefinedKey, } context.replaceNode(ifNode) if (processCodegen) { @@ -131,11 +161,7 @@ export function processIf( continue } - if ( - sibling && - sibling.type === NodeTypes.TEXT && - !sibling.content.trim().length - ) { + if (sibling && isEmptyTextNode(sibling)) { context.removeNode(sibling) continue } @@ -220,12 +246,18 @@ function createIfBranch(node: ElementNode, dir: DirectiveNode): IfBranchNode { function createCodegenNodeForBranch( branch: IfBranchNode, keyIndex: number, + hasAnyUserDefinedKeys: boolean, context: TransformContext, ): IfConditionalExpression | BlockCodegenNode | MemoExpression { if (branch.condition) { return createConditionalExpression( branch.condition, - createChildrenCodegenNode(branch, keyIndex, context), + createChildrenCodegenNode( + branch, + keyIndex, + hasAnyUserDefinedKeys, + context, + ), // make sure to pass in asBlock: true so that the comment node call // closes the current block. createCallExpression(context.helper(CREATE_COMMENT), [ @@ -234,25 +266,35 @@ function createCodegenNodeForBranch( ]), ) as IfConditionalExpression } else { - return createChildrenCodegenNode(branch, keyIndex, context) + return createChildrenCodegenNode( + branch, + keyIndex, + hasAnyUserDefinedKeys, + context, + ) } } function createChildrenCodegenNode( branch: IfBranchNode, keyIndex: number, + hasAnyUserDefinedKeys: boolean, context: TransformContext, ): BlockCodegenNode | MemoExpression { const { helper } = context - const keyProperty = createObjectProperty( - `key`, - createSimpleExpression( - `${keyIndex}`, - false, - locStub, - ConstantTypes.CAN_CACHE, - ), - ) + const keyExp = branch.userKey + ? branch.userKey.type === NodeTypes.ATTRIBUTE + ? createSimpleExpression(branch.userKey.value!.content, true) + : branch.userKey.exp! + : hasAnyUserDefinedKeys + ? context.hoist('Symbol()') + : createSimpleExpression( + `${keyIndex}`, + false, + locStub, + ConstantTypes.CAN_CACHE, + ) + const keyProperty = createObjectProperty(`key`, keyExp) const { children } = branch const firstChild = children[0] const needFragmentWrapper = @@ -348,3 +390,7 @@ function getParentCondition( } } } + +function isEmptyTextNode(node: TemplateChildNode): boolean { + return node.type === NodeTypes.TEXT && !node.content.trim().length +}