diff --git a/packages/compiler-core/__tests__/transforms/__snapshots__/vSlot.spec.ts.snap b/packages/compiler-core/__tests__/transforms/__snapshots__/vSlot.spec.ts.snap index 2cd13bab0..0ce40337c 100644 --- a/packages/compiler-core/__tests__/transforms/__snapshots__/vSlot.spec.ts.snap +++ b/packages/compiler-core/__tests__/transforms/__snapshots__/vSlot.spec.ts.snap @@ -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 diff --git a/packages/compiler-core/__tests__/transforms/transformText.spec.ts b/packages/compiler-core/__tests__/transforms/transformText.spec.ts index 1a6d6916a..057250de1 100644 --- a/packages/compiler-core/__tests__/transforms/transformText.spec.ts +++ b/packages/compiler-core/__tests__/transforms/transformText.spec.ts @@ -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(`
hello
`) + 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( `
{{ foo }} bar {{ baz }}
hello
`, diff --git a/packages/compiler-core/__tests__/transforms/vIf.spec.ts b/packages/compiler-core/__tests__/transforms/vIf.spec.ts index 2c2fedab0..e2f85f283 100644 --- a/packages/compiler-core/__tests__/transforms/vIf.spec.ts +++ b/packages/compiler-core/__tests__/transforms/vIf.spec.ts @@ -246,6 +246,31 @@ describe('compiler: v-if', () => { loc: node3.loc, }, ]) + + const { node: node4 } = parseWithIfTransform( + `
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( + `
\u00a0
`, + { 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', () => { @@ -285,6 +310,31 @@ describe('compiler: v-if', () => { }, ]) + const { node: node4 } = parseWithIfTransform( + `
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( + `
\u00a0
`, + { onError }, + 2, + ) + expect(onError.mock.calls[4]).toMatchObject([ + { + code: ErrorCodes.X_V_ELSE_NO_ADJACENT_IF, + loc: node5.loc, + }, + ]) + const { node: { branches }, } = parseWithIfTransform( @@ -293,7 +343,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, diff --git a/packages/compiler-core/__tests__/transforms/vSlot.spec.ts b/packages/compiler-core/__tests__/transforms/vSlot.spec.ts index e0f44a064..78bd129a6 100644 --- a/packages/compiler-core/__tests__/transforms/vSlot.spec.ts +++ b/packages/compiler-core/__tests__/transforms/vSlot.spec.ts @@ -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( + ` + \u00a0 + + `, + ) + 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( ` @@ -989,6 +1028,27 @@ describe('compiler: transform component slots', () => { expect(generate(root, { prefixIdentifiers: true }).code).toMatchSnapshot() }) + test('implicit default slot with non-breaking space', () => { + const source = ` + +   + + + ` + 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 = ` @@ -1002,5 +1062,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 = ` + + + + + + + + ` + const { root } = parseWithSlots(source, { + transformText: true, + whitespace: 'preserve', + }) + + expect(generate(root, { prefixIdentifiers: true }).code).toMatchSnapshot() + }) }) }) diff --git a/packages/compiler-core/src/parser.ts b/packages/compiler-core/src/parser.ts index 3eb3a976f..7b85b52fc 100644 --- a/packages/compiler-core/src/parser.ts +++ b/packages/compiler-core/src/parser.ts @@ -40,6 +40,7 @@ import { } from './errors' import { forAliasRE, + isAllWhitespace, isCoreComponent, isSimpleIdentifier, isStaticArgOf, @@ -880,15 +881,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) diff --git a/packages/compiler-core/src/transforms/vIf.ts b/packages/compiler-core/src/transforms/vIf.ts index 54c505407..c4333ccf4 100644 --- a/packages/compiler-core/src/transforms/vIf.ts +++ b/packages/compiler-core/src/transforms/vIf.ts @@ -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 } diff --git a/packages/compiler-core/src/transforms/vSlot.ts b/packages/compiler-core/src/transforms/vSlot.ts index 43296dcc9..d113cc629 100644 --- a/packages/compiler-core/src/transforms/vSlot.ts +++ b/packages/compiler-core/src/transforms/vSlot.ts @@ -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' @@ -222,7 +224,7 @@ export function buildSlots( let prev while (j--) { prev = children[j] - if (prev.type !== NodeTypes.COMMENT && isNonWhitespaceContent(prev)) { + if (!isCommentOrWhitespace(prev)) { break } } @@ -319,7 +321,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) { @@ -411,11 +413,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) -} diff --git a/packages/compiler-core/src/utils.ts b/packages/compiler-core/src/utils.ts index b49d70bb2..d7a701202 100644 --- a/packages/compiler-core/src/utils.ts +++ b/packages/compiler-core/src/utils.ts @@ -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 @@ -564,3 +565,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) +} diff --git a/packages/compiler-dom/__tests__/transforms/Transition.spec.ts b/packages/compiler-dom/__tests__/transforms/Transition.spec.ts index 8f64adb80..93039d2e4 100644 --- a/packages/compiler-dom/__tests__/transforms/Transition.spec.ts +++ b/packages/compiler-dom/__tests__/transforms/Transition.spec.ts @@ -135,6 +135,18 @@ describe('Transition multi children warnings', () => { false, ) }) + + test('non-breaking spaces are treated as normal text', () => { + checkWarning( + ` + + \u00a0 +
foo
+
+ `, + 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( + ` + + +
foo bar
+
+ `, + { + whitespace: 'preserve', + }, + ).code, + ).toMatchSnapshot() +}) diff --git a/packages/compiler-dom/__tests__/transforms/__snapshots__/Transition.spec.ts.snap b/packages/compiler-dom/__tests__/transforms/__snapshots__/Transition.spec.ts.snap index aa08c1366..404ad8cd2 100644 --- a/packages/compiler-dom/__tests__/transforms/__snapshots__/Transition.spec.ts.snap +++ b/packages/compiler-dom/__tests__/transforms/__snapshots__/Transition.spec.ts.snap @@ -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 diff --git a/packages/compiler-dom/src/transforms/Transition.ts b/packages/compiler-dom/src/transforms/Transition.ts index f6cf968e3..ccf96e8aa 100644 --- a/packages/compiler-dom/src/transforms/Transition.ts +++ b/packages/compiler-dom/src/transforms/Transition.ts @@ -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 (