]> git.ipfire.org Git - thirdparty/vuejs/core.git/commitdiff
fix(compiler): share logic for comments and whitespace (#13550)
authorskirtle <65301168+skirtles-code@users.noreply.github.com>
Mon, 24 Nov 2025 03:18:11 +0000 (03:18 +0000)
committerGitHub <noreply@github.com>
Mon, 24 Nov 2025 03:18:11 +0000 (11:18 +0800)
packages/compiler-core/__tests__/transforms/__snapshots__/vSlot.spec.ts.snap
packages/compiler-core/__tests__/transforms/transformText.spec.ts
packages/compiler-core/__tests__/transforms/vIf.spec.ts
packages/compiler-core/__tests__/transforms/vSlot.spec.ts
packages/compiler-core/src/parser.ts
packages/compiler-core/src/transforms/vIf.ts
packages/compiler-core/src/transforms/vSlot.ts
packages/compiler-core/src/utils.ts
packages/compiler-dom/__tests__/transforms/Transition.spec.ts
packages/compiler-dom/__tests__/transforms/__snapshots__/Transition.spec.ts.snap
packages/compiler-dom/src/transforms/Transition.ts

index 2cd13bab0363749bab8040e75f2971f8d08d5157..0ce40337c0c9cc86ad65d39508b66c2ca1592f44 100644 (file)
@@ -139,6 +139,24 @@ return function render(_ctx, _cache) {
 }"
 `;
 
+exports[`compiler: transform component slots > named slots w/ implicit default slot containing non-breaking space 1`] = `
+"const _Vue = Vue
+
+return function render(_ctx, _cache) {
+  with (_ctx) {
+    const { resolveComponent: _resolveComponent, withCtx: _withCtx, openBlock: _openBlock, createBlock: _createBlock } = _Vue
+
+    const _component_Comp = _resolveComponent("Comp")
+
+    return (_openBlock(), _createBlock(_component_Comp, null, {
+      one: _withCtx(() => ["foo"]),
+      default: _withCtx(() => ["   "]),
+      _: 1 /* STABLE */
+    }))
+  }
+}"
+`;
+
 exports[`compiler: transform component slots > nested slots scoping 1`] = `
 "const { toDisplayString: _toDisplayString, resolveComponent: _resolveComponent, withCtx: _withCtx, createVNode: _createVNode, openBlock: _openBlock, createBlock: _createBlock } = Vue
 
@@ -232,6 +250,20 @@ return function render(_ctx, _cache) {
 }"
 `;
 
+exports[`compiler: transform component slots > with whitespace: 'preserve' > implicit default slot with non-breaking space 1`] = `
+"const { resolveComponent: _resolveComponent, withCtx: _withCtx, openBlock: _openBlock, createBlock: _createBlock } = Vue
+
+return function render(_ctx, _cache) {
+  const _component_Comp = _resolveComponent("Comp")
+
+  return (_openBlock(), _createBlock(_component_Comp, null, {
+    header: _withCtx(() => [" Header "]),
+    default: _withCtx(() => ["\\n         \\n        "]),
+    _: 1 /* STABLE */
+  }))
+}"
+`;
+
 exports[`compiler: transform component slots > with whitespace: 'preserve' > named default slot + implicit whitespace content 1`] = `
 "const { resolveComponent: _resolveComponent, withCtx: _withCtx, openBlock: _openBlock, createBlock: _createBlock } = Vue
 
@@ -268,6 +300,32 @@ return function render(_ctx, _cache) {
 }"
 `;
 
+exports[`compiler: transform component slots > with whitespace: 'preserve' > named slot with v-if + v-else and comments 1`] = `
+"const { createTextVNode: _createTextVNode, createCommentVNode: _createCommentVNode, resolveComponent: _resolveComponent, withCtx: _withCtx, createSlots: _createSlots, openBlock: _openBlock, createBlock: _createBlock } = Vue
+
+return function render(_ctx, _cache) {
+  const _component_Comp = _resolveComponent("Comp")
+
+  return (_openBlock(), _createBlock(_component_Comp, null, _createSlots({ _: 2 /* DYNAMIC */ }, [
+    ok
+      ? {
+          name: "one",
+          fn: _withCtx(() => [
+            _createTextVNode("foo")
+          ]),
+          key: "0"
+        }
+      : {
+          name: "two",
+          fn: _withCtx(() => [
+            _createTextVNode("baz")
+          ]),
+          key: "1"
+        }
+  ]), 1024 /* DYNAMIC_SLOTS */))
+}"
+`;
+
 exports[`compiler: transform component slots > with whitespace: 'preserve' > should not generate whitespace only default slot 1`] = `
 "const { resolveComponent: _resolveComponent, withCtx: _withCtx, openBlock: _openBlock, createBlock: _createBlock } = Vue
 
index 1a6d6916a73cafdf566216256457ca1f31b23f09..057250de1f7bc67ae42a8280d8d428c2c7c5da9b 100644 (file)
@@ -4,6 +4,7 @@ import {
   type ForNode,
   NodeTypes,
   generate,
+  isWhitespaceText,
   baseParse as parse,
   transform,
 } from '../../src'
@@ -109,6 +110,24 @@ describe('compiler: transform text', () => {
     expect(generate(root).code).toMatchSnapshot()
   })
 
+  test('whitespace text', () => {
+    const root = transformWithTextOpt(`<div/>hello<div/>  <div/>`)
+    expect(root.children.length).toBe(5)
+    expect(root.children[0].type).toBe(NodeTypes.ELEMENT)
+    expect(root.children[1].type).toBe(NodeTypes.TEXT_CALL)
+    expect(root.children[2].type).toBe(NodeTypes.ELEMENT)
+    expect(root.children[3].type).toBe(NodeTypes.TEXT_CALL)
+    expect(root.children[4].type).toBe(NodeTypes.ELEMENT)
+
+    expect(root.children.map(isWhitespaceText)).toEqual([
+      false,
+      false,
+      false,
+      true,
+      false,
+    ])
+  })
+
   test('consecutive text mixed with elements', () => {
     const root = transformWithTextOpt(
       `<div/>{{ foo }} bar {{ baz }}<div/>hello<div/>`,
index 1e0067aa32a3aec1db2c459e5f6cee39b1b93319..fe3aef71495f24fc5dc5313f1de1bafc59467825 100644 (file)
@@ -266,6 +266,31 @@ describe('compiler: v-if', () => {
           loc: node3.loc,
         },
       ])
+
+      const { node: node4 } = parseWithIfTransform(
+        `<div v-if="bar"/>foo<div v-else/>`,
+        { onError },
+        2,
+      )
+      expect(onError.mock.calls[3]).toMatchObject([
+        {
+          code: ErrorCodes.X_V_ELSE_NO_ADJACENT_IF,
+          loc: node4.loc,
+        },
+      ])
+
+      // Non-breaking space
+      const { node: node5 } = parseWithIfTransform(
+        `<div v-if="bar"/>\u00a0<div v-else/>`,
+        { onError },
+        2,
+      )
+      expect(onError.mock.calls[4]).toMatchObject([
+        {
+          code: ErrorCodes.X_V_ELSE_NO_ADJACENT_IF,
+          loc: node5.loc,
+        },
+      ])
     })
 
     test('error on v-else-if missing adjacent v-if or v-else-if', () => {
@@ -305,6 +330,31 @@ describe('compiler: v-if', () => {
         },
       ])
 
+      const { node: node4 } = parseWithIfTransform(
+        `<div v-if="bar"/>foo<div v-else-if="foo"/>`,
+        { onError },
+        2,
+      )
+      expect(onError.mock.calls[3]).toMatchObject([
+        {
+          code: ErrorCodes.X_V_ELSE_NO_ADJACENT_IF,
+          loc: node4.loc,
+        },
+      ])
+
+      // Non-breaking space
+      const { node: node5 } = parseWithIfTransform(
+        `<div v-if="bar"/>\u00a0<div v-else-if="foo"/>`,
+        { onError },
+        2,
+      )
+      expect(onError.mock.calls[4]).toMatchObject([
+        {
+          code: ErrorCodes.X_V_ELSE_NO_ADJACENT_IF,
+          loc: node5.loc,
+        },
+      ])
+
       const {
         node: { branches },
       } = parseWithIfTransform(
@@ -313,7 +363,7 @@ describe('compiler: v-if', () => {
         0,
       )
 
-      expect(onError.mock.calls[3]).toMatchObject([
+      expect(onError.mock.calls[5]).toMatchObject([
         {
           code: ErrorCodes.X_V_ELSE_NO_ADJACENT_IF,
           loc: branches[branches.length - 1].loc,
index 1213e218e662bb1285873e999b54da069ed0cae2..97f68101f7481e853a79c3480536039ffcfe39c3 100644 (file)
@@ -28,8 +28,12 @@ import { createObjectMatcher } from '../testUtils'
 import { PatchFlags } from '@vue/shared'
 import { transformFor } from '../../src/transforms/vFor'
 import { transformIf } from '../../src/transforms/vIf'
+import { transformText } from '../../src/transforms/transformText'
 
-function parseWithSlots(template: string, options: CompilerOptions = {}) {
+function parseWithSlots(
+  template: string,
+  options: CompilerOptions & { transformText?: boolean } = {},
+) {
   const ast = parse(template, {
     whitespace: options.whitespace,
   })
@@ -43,6 +47,7 @@ function parseWithSlots(template: string, options: CompilerOptions = {}) {
       transformSlotOutlet,
       transformElement,
       trackSlotScopes,
+      ...(options.transformText ? [transformText] : []),
     ],
     directiveTransforms: {
       on: transformOn,
@@ -307,6 +312,40 @@ describe('compiler: transform component slots', () => {
     expect(generate(root).code).toMatchSnapshot()
   })
 
+  test('named slots w/ implicit default slot containing non-breaking space', () => {
+    const { root, slots } = parseWithSlots(
+      `<Comp>
+        \u00a0
+        <template #one>foo</template>
+      </Comp>`,
+    )
+    expect(slots).toMatchObject(
+      createSlotMatcher({
+        one: {
+          type: NodeTypes.JS_FUNCTION_EXPRESSION,
+          params: undefined,
+          returns: [
+            {
+              type: NodeTypes.TEXT,
+              content: `foo`,
+            },
+          ],
+        },
+        default: {
+          type: NodeTypes.JS_FUNCTION_EXPRESSION,
+          params: undefined,
+          returns: [
+            {
+              type: NodeTypes.TEXT,
+              content: ` \u00a0 `,
+            },
+          ],
+        },
+      }),
+    )
+    expect(generate(root).code).toMatchSnapshot()
+  })
+
   test('dynamically named slots', () => {
     const { root, slots } = parseWithSlots(
       `<Comp>
@@ -1011,6 +1050,27 @@ describe('compiler: transform component slots', () => {
       expect(generate(root, { prefixIdentifiers: true }).code).toMatchSnapshot()
     })
 
+    test('implicit default slot with non-breaking space', () => {
+      const source = `
+      <Comp>
+        &nbsp;
+        <template #header> Header </template>
+      </Comp>
+      `
+      const { root } = parseWithSlots(source, {
+        whitespace: 'preserve',
+      })
+
+      const slots = (root as any).children[0].codegenNode.children
+        .properties as ObjectExpression['properties']
+
+      expect(
+        slots.some(p => (p.key as SimpleExpressionNode).content === 'default'),
+      ).toBe(true)
+
+      expect(generate(root, { prefixIdentifiers: true }).code).toMatchSnapshot()
+    })
+
     test('named slot with v-if + v-else', () => {
       const source = `
         <Comp>
@@ -1024,5 +1084,23 @@ describe('compiler: transform component slots', () => {
 
       expect(generate(root, { prefixIdentifiers: true }).code).toMatchSnapshot()
     })
+
+    test('named slot with v-if + v-else and comments', () => {
+      const source = `
+        <Comp>
+          <template #one v-if="ok">foo</template>
+          <!-- start -->
+
+          <!-- end -->
+          <template #two v-else>baz</template>
+        </Comp>
+      `
+      const { root } = parseWithSlots(source, {
+        transformText: true,
+        whitespace: 'preserve',
+      })
+
+      expect(generate(root, { prefixIdentifiers: true }).code).toMatchSnapshot()
+    })
   })
 })
index 2d85289fc6842afe6c9cfaa97b194e86eb3416a6..debf11facfed69ba668c0be01b3b1587fd354ed9 100644 (file)
@@ -40,6 +40,7 @@ import {
 } from './errors'
 import {
   forAliasRE,
+  isAllWhitespace,
   isCoreComponent,
   isSimpleIdentifier,
   isStaticArgOf,
@@ -881,15 +882,6 @@ function condenseWhitespace(nodes: TemplateChildNode[]): TemplateChildNode[] {
   return removedWhitespace ? nodes.filter(Boolean) : nodes
 }
 
-function isAllWhitespace(str: string) {
-  for (let i = 0; i < str.length; i++) {
-    if (!isWhitespace(str.charCodeAt(i))) {
-      return false
-    }
-  }
-  return true
-}
-
 function hasNewlineChar(str: string) {
   for (let i = 0; i < str.length; i++) {
     const c = str.charCodeAt(i)
index 74575322d46b6fca6e705d94afa399759b9b2412..63a65cb31e14c6813b42c5b74ff7d2b25b0a9ed9 100644 (file)
@@ -32,7 +32,13 @@ import { processExpression } from './transformExpression'
 import { validateBrowserExpression } from '../validateExpression'
 import { cloneLoc } from '../parser'
 import { CREATE_COMMENT, FRAGMENT } from '../runtimeHelpers'
-import { findDir, findProp, getMemoedVNodeCall, injectProp } from '../utils'
+import {
+  findDir,
+  findProp,
+  getMemoedVNodeCall,
+  injectProp,
+  isCommentOrWhitespace,
+} from '../utils'
 import { PatchFlags } from '@vue/shared'
 
 export const transformIf: NodeTransform = createStructuralDirectiveTransform(
@@ -125,18 +131,11 @@ export function processIf(
     let i = siblings.indexOf(node)
     while (i-- >= -1) {
       const sibling = siblings[i]
-      if (sibling && sibling.type === NodeTypes.COMMENT) {
-        context.removeNode(sibling)
-        __DEV__ && comments.unshift(sibling)
-        continue
-      }
-
-      if (
-        sibling &&
-        sibling.type === NodeTypes.TEXT &&
-        !sibling.content.trim().length
-      ) {
+      if (sibling && isCommentOrWhitespace(sibling)) {
         context.removeNode(sibling)
+        if (__DEV__ && sibling.type === NodeTypes.COMMENT) {
+          comments.unshift(sibling)
+        }
         continue
       }
 
index 3493934e39bff10716abd254c84a23be0188b4b1..f9a1b72daad32b0a976e8bc050b9d4c32fe75cf9 100644 (file)
@@ -26,9 +26,11 @@ import {
   assert,
   findDir,
   hasScopeRef,
+  isCommentOrWhitespace,
   isStaticExp,
   isTemplateNode,
   isVSlot,
+  isWhitespaceText,
 } from '../utils'
 import { CREATE_SLOTS, RENDER_LIST, WITH_CTX } from '../runtimeHelpers'
 import { createForLoopParams, finalizeForParseResult } from './vFor'
@@ -230,7 +232,7 @@ export function buildSlots(
       let prev
       while (j--) {
         prev = children[j]
-        if (prev.type !== NodeTypes.COMMENT && isNonWhitespaceContent(prev)) {
+        if (!isCommentOrWhitespace(prev)) {
           break
         }
       }
@@ -327,7 +329,7 @@ export function buildSlots(
       // #3766
       // with whitespace: 'preserve', whitespaces between slots will end up in
       // implicitDefaultChildren. Ignore if all implicit children are whitespaces.
-      implicitDefaultChildren.some(node => isNonWhitespaceContent(node))
+      !implicitDefaultChildren.every(isWhitespaceText)
     ) {
       // implicit default slot (mixed with named slots)
       if (hasNamedDefaultSlot) {
@@ -419,11 +421,3 @@ function hasForwardedSlots(children: TemplateChildNode[]): boolean {
   }
   return false
 }
-
-function isNonWhitespaceContent(node: TemplateChildNode): boolean {
-  if (node.type !== NodeTypes.TEXT && node.type !== NodeTypes.TEXT_CALL)
-    return true
-  return node.type === NodeTypes.TEXT
-    ? !!node.content.trim()
-    : isNonWhitespaceContent(node.content)
-}
index e04247d0fe82f3d796a6109387781c1c161323e8..aa426bbab47137ec6cf52495e8383a480c02c6d9 100644 (file)
@@ -42,6 +42,7 @@ import type { PropsExpression } from './transforms/transformElement'
 import { parseExpression } from '@babel/parser'
 import type { Expression, Node } from '@babel/types'
 import { unwrapTSNode } from './babelUtils'
+import { isWhitespace } from './tokenizer'
 
 export const isStaticExp = (p: JSChildNode): p is SimpleExpressionNode =>
   p.type === NodeTypes.SIMPLE_EXPRESSION && p.isStatic
@@ -568,3 +569,23 @@ export function getMemoedVNodeCall(
 }
 
 export const forAliasRE: RegExp = /([\s\S]*?)\s+(?:in|of)\s+(\S[\s\S]*)/
+
+export function isAllWhitespace(str: string): boolean {
+  for (let i = 0; i < str.length; i++) {
+    if (!isWhitespace(str.charCodeAt(i))) {
+      return false
+    }
+  }
+  return true
+}
+
+export function isWhitespaceText(node: TemplateChildNode): boolean {
+  return (
+    (node.type === NodeTypes.TEXT && isAllWhitespace(node.content)) ||
+    (node.type === NodeTypes.TEXT_CALL && isWhitespaceText(node.content))
+  )
+}
+
+export function isCommentOrWhitespace(node: TemplateChildNode): boolean {
+  return node.type === NodeTypes.COMMENT || isWhitespaceText(node)
+}
index 8f64adb80a3445e1f8e8573c8cca83811f149036..93039d2e42b3d64cfaf2a449d2348c8bdf04a209 100644 (file)
@@ -135,6 +135,18 @@ describe('Transition multi children warnings', () => {
       false,
     )
   })
+
+  test('non-breaking spaces are treated as normal text', () => {
+    checkWarning(
+      `
+      <transition>
+        \u00a0
+        <div>foo</div>
+      </transition>
+      `,
+      true,
+    )
+  })
 })
 
 test('inject persisted when child has v-show', () => {
@@ -164,3 +176,19 @@ test('the v-if/else-if/else branches in Transition should ignore comments', () =
     `).code,
   ).toMatchSnapshot()
 })
+
+test('comments and preserved whitespace are ignored', () => {
+  expect(
+    compile(
+      `
+      <transition>
+        <!-- foo --> <!-- bar -->
+        <div>foo bar</div>
+      </transition>
+      `,
+      {
+        whitespace: 'preserve',
+      },
+    ).code,
+  ).toMatchSnapshot()
+})
index aa08c1366a6237115aa06761c889109914f3083d..404ad8cd2853c4771567444aeaa5509c3aeabd47 100644 (file)
@@ -1,5 +1,22 @@
 // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
 
+exports[`comments and preserved whitespace are ignored 1`] = `
+"const _Vue = Vue
+
+return function render(_ctx, _cache) {
+  with (_ctx) {
+    const { createCommentVNode: _createCommentVNode, createElementVNode: _createElementVNode, Transition: _Transition, withCtx: _withCtx, openBlock: _openBlock, createBlock: _createBlock } = _Vue
+
+    return (_openBlock(), _createBlock(_Transition, null, {
+      default: _withCtx(() => [
+        _createElementVNode("div", null, "foo bar")
+      ]),
+      _: 1 /* STABLE */
+    }))
+  }
+}"
+`;
+
 exports[`inject persisted when child has v-show 1`] = `
 "const _Vue = Vue
 
index f6cf968e37263492a9348f7a9afcca03684a6948..ccf96e8aa6660f67edbd1b0532deca48d8288645 100644 (file)
@@ -4,6 +4,7 @@ import {
   type IfBranchNode,
   type NodeTransform,
   NodeTypes,
+  isCommentOrWhitespace,
 } from '@vue/compiler-core'
 import { TRANSITION } from '../runtimeHelpers'
 import { DOMErrorCodes, createDOMCompilerError } from '../errors'
@@ -56,11 +57,9 @@ export const transformTransition: NodeTransform = (node, context) => {
 }
 
 function hasMultipleChildren(node: ComponentNode | IfBranchNode): boolean {
-  // #1352 filter out potential comment nodes.
+  // filter out potential comment nodes (#1352) and whitespace (#4637)
   const children = (node.children = node.children.filter(
-    c =>
-      c.type !== NodeTypes.COMMENT &&
-      !(c.type === NodeTypes.TEXT && !c.content.trim()),
+    c => !isCommentOrWhitespace(c),
   ))
   const child = children[0]
   return (