diff --git a/packages/compiler-core/__tests__/patterns.spec.ts b/packages/compiler-core/__tests__/patterns.spec.ts new file mode 100644 index 00000000000..3165f3ef81f --- /dev/null +++ b/packages/compiler-core/__tests__/patterns.spec.ts @@ -0,0 +1,271 @@ +import { generateMatchSelector, parseMatchPattern } from '../src/patterns' + +function select( + patterns: string[], + subject: unknown, + values: Record = {}, +) { + const expression = generateMatchSelector( + patterns.map(parseMatchPattern), + 'subject', + '__test', + ) + return new Function( + 'subject', + ...Object.keys(values), + `return ${expression.code}`, + )(subject, ...Object.values(values)) +} + +describe('RFC 823 pattern grammar', () => { + test.each([ + "'ok'", + '42', + '-42', + '0xff', + '1.5e2', + '123n', + 'true', + 'null', + 'undefined', + 'NaN', + 'Status.Active', + "Status['active']", + 'fallback', + '_', + 'const value', + "{ status: 'ok', const data }", + '{ nested: { const value }, ...const other }', + '[]', + '[const head, ...const tail]', + '[...]', + '{ ... }', + "('idle' | 'pending') as pending", + '([1] | [2]) as pair', + '{ "a-b": _, 0: const zero }', + 'const 日本語', + '{ const value } if (value && fn("if ("))', + ])('accepts %s', pattern => { + const arm = parseMatchPattern(pattern) + expect(arm.pattern.end).toBeGreaterThan(arm.pattern.start) + expect( + () => + new Function( + 'subject', + `return ${generateMatchSelector([arm], 'subject', '__test').code}`, + ), + ).not.toThrow() + }) + + test.each([ + '', + ' ', + 'let value', + 'var value', + 'const for', + 'const', + 'foo()', + 'a + b', + 'new Foo', + '{ foo }', + '{ const x, x: const y }', + '{ a: const x, b: const x }', + '[const x, const x]', + '{ const x, nested: { x: const x } }', + '{ const x, ...const x }', + '[const x, ...const x]', + '{ const x } as x', + 'const x as x', + '_ as x as y', + 'const x | 1', + '1 | const x', + '({ const x }) | {}', + '[...rest]', + '[...let rest]', + '{ ...var rest }', + '[...const x,]', + '[..., 1]', + '{ ..., a: _ }', + '{ ...const x, ...const y }', + '[...{ const x }]', + '...', + '[,]', + '[1,,2]', + '[1', + '{ x: }', + '1 || 2', + '_ if ()', + '_ if true', + '_ if (true) extra', + '0x', + '1e', + '1.2n', + '+1n', + "'unterminated", + "'\\uZZZZ'", + "'\\01'", + 'a?.b', + ])('rejects %s', pattern => { + expect(() => parseMatchPattern(pattern)).toThrow(SyntaxError) + }) +}) + +describe('RFC 823 selection semantics', () => { + test.each([ + ["'ok'", 'ok', true], + ["'ok'", 'other', false], + ['42', '42', false], + ['-0', 0, true], + ['NaN', NaN, true], + ['NaN', 0, false], + ['null', undefined, false], + ['undefined', undefined, true], + ['{ x: _ }', {}, false], + ['{ x: _ }', { x: undefined }, true], + ['{}', null, false], + ['{ x: 1 }', { x: 1, y: 2 }, true], + ['[]', [], true], + ['[]', [1], false], + ['[1]', { 0: 1, length: 1 }, false], + ['[1]', new Set([1]), false], + ['[1, ...]', [1], true], + ['[1, ...]', [1, 2], true], + ['[1, ...]', [], false], + ['[1 | 2, true]', [2, true], true], + ['[1 | 2, true]', [3, true], false], + ['{ x: { y: 2 } }', { x: null }, false], + ] as const)('%s against %j selects correctly', (pattern, value, matches) => { + expect(select([pattern], value)[0]).toBe(matches ? 0 : -1) + }) + + test('evaluates subject once, patterns lazily and guards after bindings', () => { + const events: string[] = [] + const value = { kind: 'ok', data: 42 } + const get = () => { + events.push('subject') + return value + } + const values = { + get first() { + events.push('value') + return 'other' + }, + get never() { + throw new Error('unreachable') + }, + } + const guard = (data: number) => { + events.push(`guard:${data}`) + return false + } + const arms = [ + 'values.first', + "{ kind: 'ok', const data } if (guard(data))", + '{ const data }', + 'values.never', + ].map(parseMatchPattern) + const result = new Function( + 'get', + 'values', + 'guard', + `return ${generateMatchSelector(arms, 'get()', '__test').code}`, + )(get, values, guard) + expect(result).toEqual([2, 42]) + expect(events).toEqual(['subject', 'value', 'guard:42']) + }) + + test('object rest copies enumerable own strings and symbols, excludes every listed key', () => { + const symbol = Symbol('metadata') + const nested = { value: 1 } + const subject = Object.assign(Object.create({ inherited: 1 }), { + kind: 'ok', + value: 2, + nested, + [symbol]: 3, + }) + Object.defineProperty(subject, 'hidden', { value: 4 }) + const result = select( + ["{ kind: 'ok', value: _, ...const rest } as whole"], + subject, + ) + expect(result).toEqual([0, { nested, [symbol]: 3 }, subject]) + expect(result[1]).not.toBe(subject) + expect(result[1].nested).toBe(nested) + expect(Object.getPrototypeOf(result[1])).toBe(Object.prototype) + expect(subject.value).toBe(2) + }) + + test('unbound rest does not read omitted values', () => { + const subject = { + kind: 'ok', + get unread() { + throw new Error('unexpected read') + }, + } + expect(select(["{ kind: 'ok', ... }"], subject)).toEqual([0]) + }) + + test('array rest is a fresh ordinary array including a zero-length remainder', () => { + const subject = Object.freeze([1, 2, 3]) + expect(select(['[const head, ...const tail]'], subject)).toEqual([ + 0, + 1, + [2, 3], + ]) + expect(select(['[const head, ...const tail]'], [1])).toEqual([0, 1, []]) + expect(select(['[...const tail]'], [])).toEqual([0, []]) + const copy = select(['[...const tail]'], subject)[1] + expect(copy).not.toBe(subject) + expect(Object.getPrototypeOf(copy)).toBe(Array.prototype) + }) + + test('rest is available to guards and failed guards continue', () => { + expect( + select( + ['[_, ...const tail] if (tail.length > 1)', '[const first, ...]', '_'], + [1, 2], + ), + ).toEqual([1, 1]) + expect( + select(['{ x: _, ...const rest } if (rest.y === 2)', '_'], { + x: 1, + y: 2, + }), + ).toEqual([0, { y: 2 }]) + }) + + test('inherited property presence follows the reference in semantics', () => { + expect(select(['{ x: const value }'], Object.create({ x: 1 }))).toEqual([ + 0, 1, + ]) + }) + + test('value patterns use the enclosing scope before branch bindings shadow it', () => { + expect( + select(['{ a: value, b: const value }'], { a: 1, b: 2 }, { value: 1 }), + ).toEqual([0, 2]) + }) + + test('copies rest after matching, without re-reading excluded getters', () => { + const read = vi.fn(() => 'ok') + const subject = { + get kind() { + return read() + }, + value: 42, + } + expect(select(["{ kind: 'ok', ...const rest }"], subject)).toEqual([ + 0, + { value: 42 }, + ]) + expect(read).toHaveBeenCalledTimes(1) + }) + + test('copying a __proto__ key defines an own property without changing the prototype', () => { + const subject = JSON.parse('{"__proto__":{"changed":true},"value":1}') + const rest = select(['{ value: _, ...const rest }'], subject)[1] + expect(Object.getPrototypeOf(rest)).toBe(Object.prototype) + expect(Object.prototype.hasOwnProperty.call(rest, '__proto__')).toBe(true) + expect(rest.changed).toBeUndefined() + }) +}) diff --git a/packages/compiler-core/__tests__/transforms/cacheStatic.spec.ts b/packages/compiler-core/__tests__/transforms/cacheStatic.spec.ts index 7f3cffea2a0..94f08249ed1 100644 --- a/packages/compiler-core/__tests__/transforms/cacheStatic.spec.ts +++ b/packages/compiler-core/__tests__/transforms/cacheStatic.spec.ts @@ -2,6 +2,7 @@ import { type CompilerOptions, ConstantTypes, type ElementNode, + type ForCodegenNode, type ForNode, type IfNode, NodeTypes, @@ -510,7 +511,8 @@ describe('compiler: cacheStatic transform', () => { }, patchFlag: PatchFlags.UNKEYED_FRAGMENT, }) - const innerBlockCodegen = forBlockCodegen!.children.arguments[1] + const innerBlockCodegen = (forBlockCodegen! as ForCodegenNode).children + .arguments[1] expect(innerBlockCodegen.returns).toMatchObject({ type: NodeTypes.VNODE_CALL, tag: `"div"`, @@ -766,7 +768,8 @@ describe('compiler: cacheStatic transform', () => { }, patchFlag: PatchFlags.UNKEYED_FRAGMENT, }) - const innerBlockCodegen = forBlockCodegen!.children.arguments[1] + const innerBlockCodegen = (forBlockCodegen! as ForCodegenNode).children + .arguments[1] expect(innerBlockCodegen.returns).toMatchObject({ type: NodeTypes.VNODE_CALL, tag: `"div"`, diff --git a/packages/compiler-core/src/ast.ts b/packages/compiler-core/src/ast.ts index db3e5af36b8..e14d74a2a35 100644 --- a/packages/compiler-core/src/ast.ts +++ b/packages/compiler-core/src/ast.ts @@ -48,6 +48,7 @@ export enum NodeTypes { JS_ASSIGNMENT_EXPRESSION, JS_SEQUENCE_EXPRESSION, JS_RETURN_STATEMENT, + JS_SCOPE_EXPRESSION, } export enum ElementTypes { @@ -230,6 +231,8 @@ export interface SimpleExpressionNode extends Node { * - `false` means there was a parsing error */ ast?: BabelNode | null | false + /** Original source ranges within a compiler-generated expression. */ + sourceRanges?: { start: number; end: number; loc: SourceLocation }[] /** * Indicates this is an identifier for a hoist vnode call and points to the * hoisted node. @@ -295,10 +298,12 @@ export interface ForNode extends Node { objectIndexAlias: ExpressionNode | undefined parseResult: ForParseResult children: TemplateChildNode[] - codegenNode?: ForCodegenNode + codegenNode?: ForCodegenNode | ScopeExpression } export interface ForParseResult { + /** Internal lexical scope introduced by patterned-template lowering. */ + matchScope?: boolean source: ExpressionNode value: ExpressionNode | undefined key: ExpressionNode | undefined @@ -348,6 +353,7 @@ export interface VNodeCall extends Node { // Vue render function generation. export type JSChildNode = + | ScopeExpression | VNodeCall | CallExpression | ObjectExpression @@ -570,7 +576,14 @@ export interface DynamicSlotFnProperty extends Property { value: SlotFunctionExpression } -export type BlockCodegenNode = VNodeCall | RenderSlotCall +export interface ScopeExpression extends Node { + type: NodeTypes.JS_SCOPE_EXPRESSION + value: ExpressionNode + source: ExpressionNode + body: JSChildNode | TemplateChildNode +} + +export type BlockCodegenNode = VNodeCall | RenderSlotCall | ScopeExpression export interface IfConditionalExpression extends ConditionalExpression { consequent: BlockCodegenNode | MemoExpression diff --git a/packages/compiler-core/src/codegen.ts b/packages/compiler-core/src/codegen.ts index 86ce518605d..17d384b0b29 100644 --- a/packages/compiler-core/src/codegen.ts +++ b/packages/compiler-core/src/codegen.ts @@ -700,6 +700,15 @@ function genNode(node: CodegenNode | symbol | string, context: CodegenContext) { genVNodeCall(node, context) break + case NodeTypes.JS_SCOPE_EXPRESSION: + context.push(`(() => { const `) + genNode(node.value, context) + context.push(` = (`) + genNode(node.source, context) + context.push(`)[0]; return `) + genNode(node.body, context) + context.push(` })()`) + break case NodeTypes.JS_CALL_EXPRESSION: genCallExpression(node, context) break diff --git a/packages/compiler-core/src/compile.ts b/packages/compiler-core/src/compile.ts index eba00427289..f6d90ab6ce6 100644 --- a/packages/compiler-core/src/compile.ts +++ b/packages/compiler-core/src/compile.ts @@ -8,6 +8,7 @@ import { import { type CodegenResult, generate } from './codegen' import type { RootNode } from './ast' import { extend, isString } from '@vue/shared' +import { lowerMatchDirectives } from './transforms/vMatch' import { transformIf } from './transforms/vIf' import { transformFor } from './transforms/vFor' import { transformExpression } from './transforms/transformExpression' @@ -103,6 +104,8 @@ export function baseCompile( } } + lowerMatchDirectives(ast, resolvedOptions) + transform( ast, extend({}, resolvedOptions, { diff --git a/packages/compiler-core/src/index.ts b/packages/compiler-core/src/index.ts index 06e963acfd6..87906fd52fb 100644 --- a/packages/compiler-core/src/index.ts +++ b/packages/compiler-core/src/index.ts @@ -85,3 +85,6 @@ export { CompilerDeprecationTypes, type CompilerCompatOptions, } from './compat/compatConfig' + +export * from './patterns' +export { lowerMatchDirectives } from './transforms/vMatch' diff --git a/packages/compiler-core/src/parser.ts b/packages/compiler-core/src/parser.ts index 53f7c0c1ed2..f0d90ac6298 100644 --- a/packages/compiler-core/src/parser.ts +++ b/packages/compiler-core/src/parser.ts @@ -357,7 +357,7 @@ const tokenizer = new Tokenizer(stack, { // directive let expParseMode = ExpParseMode.Normal if (!__BROWSER__) { - if (currentProp.name === 'for') { + if (currentProp.name === 'for' || currentProp.name === 'when') { expParseMode = ExpParseMode.Skip } else if (currentProp.name === 'slot') { expParseMode = ExpParseMode.Params @@ -764,7 +764,15 @@ function backTrack(index: number, c: number) { return i } -const specialTemplateDir = new Set(['if', 'else', 'else-if', 'for', 'slot']) +const specialTemplateDir = new Set([ + 'if', + 'else', + 'else-if', + 'for', + 'slot', + 'match', + 'when', +]) function isFragmentTemplate({ tag, props }: ElementNode): boolean { if (tag === 'template') { for (let i = 0; i < props.length; i++) { diff --git a/packages/compiler-core/src/patterns.ts b/packages/compiler-core/src/patterns.ts new file mode 100644 index 00000000000..3dc7a7885ce --- /dev/null +++ b/packages/compiler-core/src/patterns.ts @@ -0,0 +1,441 @@ +/** Pattern grammar for the RFC 823 reference implementation. No JS evaluation. */ +export interface PatternRange { + start: number + end: number +} + +export type MatchPattern = PatternRange & + ( + | { kind: 'wildcard' } + | { kind: 'literal' | 'value'; text: string } + | { kind: 'binding'; name: string } + | { kind: 'as'; pattern: MatchPattern; binding: PatternBinding } + | { kind: 'or'; patterns: MatchPattern[] } + | { kind: 'object'; properties: PatternProperty[]; rest?: PatternRest } + | { kind: 'array'; elements: MatchPattern[]; rest?: PatternRest } + ) + +export interface PatternBinding extends PatternRange { + name: string +} +export interface PatternProperty extends PatternRange { + key: string + keyText: string + pattern: MatchPattern +} +export interface PatternRest extends PatternRange { + binding?: PatternBinding +} +export interface MatchArm { + pattern: MatchPattern + bindings: PatternBinding[] + guard?: PatternRange & { text: string } +} + +export class PatternSyntaxError extends SyntaxError { + constructor( + message: string, + public offset: number, + ) { + super(message) + } +} + +const identifier = /^[$_\p{ID_Start}][$_\u200c\u200d\p{ID_Continue}]*/u +const reserved = new Set( + 'await break case catch class const continue debugger default delete do else enum export extends false finally for function if import in instanceof interface implements let new null package private protected public return static super switch this throw true try typeof var void while with yield eval arguments'.split( + ' ', + ), +) +const numeric = + /^(?:0[xX][\da-fA-F]+n?|0[bB][01]+n?|0[oO][0-7]+n?|(?:0|[1-9]\d*)n|(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?)/ + +export function parseMatchPattern(source: string): MatchArm { + let pos = 0 + const bindings: PatternBinding[] = [] + const names = new Set() + const fail = (message: string): never => { + throw new PatternSyntaxError(message, pos) + } + const space = () => { + while (/\s/.test(source[pos] || '') && pos < source.length) pos++ + } + const eat = (text: string) => { + space() + if (!source.startsWith(text, pos)) return false + pos += text.length + return true + } + const word = (text: string) => { + space() + if ( + !source.startsWith(text, pos) || + /^[$_\u200c\u200d\p{ID_Continue}]/u.test(source.slice(pos + text.length)) + ) + return false + pos += text.length + return true + } + const expect = (text: string) => { + if (!eat(text)) fail(`Expected ${text} in pattern.`) + } + const name = () => { + space() + const start = pos + const value = identifier.exec(source.slice(pos))?.[0] + if (!value) return fail('Expected an identifier.') + pos += value.length + return { name: value, start, end: pos } + } + const bind = (): PatternBinding => { + const result = name() + if (reserved.has(result.name)) fail(`Invalid binding name ${result.name}.`) + if (names.has(result.name)) + fail(`Duplicate pattern binding ${result.name}.`) + names.add(result.name) + bindings.push(result) + return result + } + const string = () => { + const start = pos + const quote = source[pos++] + let value = '' + while (pos < source.length) { + let char = source[pos++] + if (char === quote) return { value, text: source.slice(start, pos) } + if (char === '\n' || char === '\r') + fail('Unterminated string in pattern.') + if (char === '\\') { + char = source[pos++] + const escapes: Record = { + n: '\n', + r: '\r', + t: '\t', + b: '\b', + f: '\f', + v: '\v', + '0': '\0', + } + if (char === 'x' || char === 'u') { + let hex: string + if (char === 'u' && source[pos] === '{') { + const end = source.indexOf('}', ++pos) + if (end < 0) fail('Invalid Unicode escape.') + hex = source.slice(pos, end) + pos = end + 1 + } else { + const size = char === 'x' ? 2 : 4 + hex = source.slice(pos, pos + size) + if (hex.length !== size) fail('Invalid string escape.') + pos += size + } + if (!/^[\da-f]+$/i.test(hex) || parseInt(hex, 16) > 0x10ffff) + fail('Invalid string escape.') + value += String.fromCodePoint(parseInt(hex, 16)) + } else if ( + /[1-9]/.test(char || '') || + (char === '0' && /\d/.test(source[pos] || '')) + ) { + fail('Legacy octal escapes are not supported.') + } else if (char === '\n' || char === '\r' || !char) { + fail('Invalid string escape.') + } else value += escapes[char] ?? char + } else value += char + } + return fail('Unterminated string in pattern.') + } + const rest = (close: string): PatternRest => { + const start = pos - 3 + const binding = word('const') ? bind() : undefined + space() + if (source[pos] !== close) + fail( + 'Rest must be last, without a trailing comma; use ... or ...const name.', + ) + return { start, end: pos, binding } + } + const parse = (): MatchPattern => { + let pattern = atom() + const alternatives = [pattern] + while (eat('|')) alternatives.push(atom()) + if (alternatives.length > 1) { + if (alternatives.some(p => getPatternBindings(p).length)) + fail('Bindings inside or-patterns are not supported.') + pattern = { + kind: 'or', + patterns: alternatives, + start: pattern.start, + end: pos, + } + } + if (word('as')) { + const binding = bind() + pattern = { kind: 'as', pattern, binding, start: pattern.start, end: pos } + } + return pattern + } + const atom = (): MatchPattern => { + space() + const start = pos + if (eat('(')) { + const pattern = parse() + expect(')') + return { ...pattern, start, end: pos } + } + if (eat('{')) { + const properties: PatternProperty[] = [] + const keys = new Set() + let remainder: PatternRest | undefined + while (!eat('}')) { + if (eat('...')) { + remainder = rest('}') + expect('}') + break + } + space() + const keyStart = pos + let key: string + let keyText: string + let pattern: MatchPattern + if (word('const')) { + const binding = bind() + key = binding.name + keyText = JSON.stringify(key) + pattern = { kind: 'binding', ...binding } + } else { + if (source[pos] === "'" || source[pos] === '"') { + const token = string() + key = token.value + keyText = token.text + } else { + const number = numeric.exec(source.slice(pos))?.[0] + if (number) { + pos += number.length + if (number.endsWith('n')) + fail('Bigint object keys are not supported.') + key = String(Number(number)) + keyText = JSON.stringify(key) + } else { + key = name().name + keyText = JSON.stringify(key) + } + } + expect(':') + pattern = parse() + } + if (keys.has(key)) fail(`Duplicate pattern property ${key}.`) + keys.add(key) + properties.push({ key, keyText, pattern, start: keyStart, end: pos }) + if (eat('}')) break + expect(',') + } + return { kind: 'object', properties, rest: remainder, start, end: pos } + } + if (eat('[')) { + const elements: MatchPattern[] = [] + let remainder: PatternRest | undefined + while (!eat(']')) { + if (eat('...')) { + remainder = rest(']') + expect(']') + break + } + elements.push(parse()) + if (eat(']')) break + expect(',') + } + return { kind: 'array', elements, rest: remainder, start, end: pos } + } + if (word('const')) return { kind: 'binding', ...bind() } + if (word('let') || word('var')) + fail('Only const pattern bindings are supported.') + if (source[pos] === "'" || source[pos] === '"') { + const token = string() + return { kind: 'literal', text: token.text, start, end: pos } + } + const sign = source[pos] === '-' || source[pos] === '+' ? source[pos++] : '' + const number = numeric.exec(source.slice(pos))?.[0] + if (number) { + pos += number.length + if (sign === '+' && number.endsWith('n')) + fail('Unary plus cannot be used with bigint.') + return { kind: 'literal', text: sign + number, start, end: pos } + } + if (sign) fail('Expected a numeric literal after sign.') + const value = name() + if (value.name === '_') return { kind: 'wildcard', start, end: pos } + if (['true', 'false', 'null'].includes(value.name)) + return { kind: 'literal', text: value.name, start, end: pos } + if (reserved.has(value.name)) + fail('Expected a literal, value or structural pattern.') + while (true) { + if (eat('.')) name() + else if (eat('[')) { + space() + if (source[pos] === "'" || source[pos] === '"') string() + else { + const number = numeric.exec(source.slice(pos))?.[0] + if (number) pos += number.length + else name() + } + expect(']') + } else break + } + return { + kind: 'value', + text: source.slice(start, pos).trim(), + start, + end: pos, + } + } + const pattern = parse() + let guard: MatchArm['guard'] + if (word('if')) { + expect('(') + const start = pos + const end = source.trimEnd().length - 1 + if (source[end] !== ')' || !source.slice(start, end).trim()) + fail('Expected if (guard).') + guard = { text: source.slice(start, end), start, end } + pos = end + 1 + } + space() + if (pos !== source.length) fail('Unexpected token in pattern.') + return { pattern, bindings, guard } +} + +export function getPatternBindings(pattern: MatchPattern): PatternBinding[] { + switch (pattern.kind) { + case 'binding': + return [pattern] + case 'as': + return [...getPatternBindings(pattern.pattern), pattern.binding] + case 'or': + return pattern.patterns.flatMap(getPatternBindings) + case 'array': + return [ + ...pattern.elements.flatMap(getPatternBindings), + ...(pattern.rest?.binding ? [pattern.rest.binding] : []), + ] + case 'object': + return [ + ...pattern.properties.flatMap(p => getPatternBindings(p.pattern)), + ...(pattern.rest?.binding ? [pattern.rest.binding] : []), + ] + default: + return [] + } +} + +/** Emit a lazy selector. Values/bindings are local to each attempted arm. */ +export function generateMatchSelector( + arms: MatchArm[], + subject: string, + prefix: string, +): { code: string; subjectOffset: number } { + let nextId = 0 + const temp = () => `${prefix}_${nextId++}` + const root = temp() + const branches = arms.map((arm, index) => { + // RFC evaluation order: test the shape, copy bound rest, introduce bindings, + // then evaluate the guard. A failed test must not read a discarded rest. + const tests: string[] = [] + const declarations: string[] = [] + const copies: string[] = [] + const emit = (pattern: MatchPattern, value: string): void => { + switch (pattern.kind) { + case 'wildcard': + return + case 'binding': + declarations.push(`const ${pattern.name} = ${value};`) + return + case 'as': + emit(pattern.pattern, value) + declarations.push(`const ${pattern.binding.name} = ${value};`) + return + case 'literal': + tests.push(`if (!(${value} === ${pattern.text})) return null;`) + return + case 'value': { + const expected = temp() + tests.push( + `const ${expected} = ${pattern.text}; if (!(${value} === ${expected} || (${value} !== ${value} && ${expected} !== ${expected}))) return null;`, + ) + return + } + case 'or': { + const alternatives = pattern.patterns.map(p => { + const start = tests.length + emit(p, value) + return `(() => { ${tests.splice(start).join(' ')} return true; })()` + }) + tests.push(`if (!(${alternatives.join(' || ')})) return null;`) + return + } + case 'object': { + tests.push(`if (${value} == null) return null;`) + for (const property of pattern.properties) { + tests.push( + `if (!(${property.keyText} in Object(${value}))) return null;`, + ) + if (property.pattern.kind !== 'wildcard') { + const child = temp() + tests.push(`const ${child} = ${value}[${property.keyText}];`) + emit(property.pattern, child) + } + } + if (pattern.rest?.binding) { + const copy = temp() + const key = temp() + const keys = pattern.properties + .map(p => JSON.stringify(p.key)) + .join(', ') + copies.push( + `const ${copy} = {}; ` + + `for (const ${key} of Object.getOwnPropertyNames(Object(${value}))` + + `.concat(Object.getOwnPropertySymbols(Object(${value})))) { ` + + `if (![${keys}].includes(${key}) && ` + + `Object.prototype.propertyIsEnumerable.call(${value}, ${key})) ` + + `Object.defineProperty(${copy}, ${key}, { value: ${value}[${key}], ` + + `enumerable: true, configurable: true, writable: true }); }`, + ) + declarations.push(`const ${pattern.rest.binding.name} = ${copy};`) + } + return + } + case 'array': + tests.push( + `if (!Array.isArray(${value}) || !(${value}.length ${pattern.rest ? '>=' : '==='} ${pattern.elements.length})) return null;`, + ) + pattern.elements.forEach((p, i) => { + if (p.kind === 'wildcard') return + const child = temp() + tests.push(`const ${child} = ${value}[${i}];`) + emit(p, child) + }) + if (pattern.rest?.binding) { + // Array.from avoids custom slice/species and copies sparse values. + const copy = temp() + copies.push( + `const ${copy} = Array.from({ length: ${value}.length - ${pattern.elements.length} }, (_, i) => ${value}[i + ${pattern.elements.length}]);`, + ) + declarations.push(`const ${pattern.rest.binding.name} = ${copy};`) + } + } + } + emit(arm.pattern, root) + const result = temp() + const guard = arm.guard ? `if (!(${arm.guard.text})) return null;` : '' + const values = arm.bindings.map(binding => `, ${binding.name}`).join('') + // This inner block keeps pattern value lookups outside a binding's TDZ. + const body = + `${tests.join(' ')} ${copies.join(' ')} ` + + `{ ${declarations.join(' ')} ${guard} return [${index}${values}]; }` + return ( + `{ const ${result} = (() => { ${body} })(); ` + + `if (${result} !== null) return ${result}; }` + ) + }) + const selector = `((${root}) => { ${branches.join(' ')} return [-1]; })(` + return { code: `${selector}${subject})`, subjectOffset: selector.length } +} diff --git a/packages/compiler-core/src/transforms/transformExpression.ts b/packages/compiler-core/src/transforms/transformExpression.ts index 40575156b6e..e58e2e71186 100644 --- a/packages/compiler-core/src/transforms/transformExpression.ts +++ b/packages/compiler-core/src/transforms/transformExpression.ts @@ -24,7 +24,7 @@ import { isStaticPropertyKey, walkIdentifiers, } from '../babelUtils' -import { advancePositionWithClone, findDir, isSimpleIdentifier } from '../utils' +import { findDir, getExpressionRange, isSimpleIdentifier } from '../utils' import { genPropsAccessExp, hasOwn, @@ -359,16 +359,11 @@ export function processExpression( if (leadingText.length || id.prefix) { children.push(leadingText + (id.prefix || ``)) } - const source = rawExp.slice(start, end) children.push( createSimpleExpression( id.name, false, - { - start: advancePositionWithClone(node.loc.start, source, start), - end: advancePositionWithClone(node.loc.start, source, end), - source, - }, + getExpressionRange(node, start, end), id.isConstant ? ConstantTypes.CAN_STRINGIFY : ConstantTypes.NOT_CONSTANT, diff --git a/packages/compiler-core/src/transforms/vFor.ts b/packages/compiler-core/src/transforms/vFor.ts index ebf67d3d7d1..909c5b935ad 100644 --- a/packages/compiler-core/src/transforms/vFor.ts +++ b/packages/compiler-core/src/transforms/vFor.ts @@ -20,6 +20,7 @@ import { type SimpleExpressionNode, type SlotOutletNode, type VNodeCall, + convertToBlock, createBlockStatement, createCallExpression, createCompoundExpression, @@ -54,6 +55,51 @@ export const transformFor: NodeTransform = createStructuralDirectiveTransform( (node, dir, context) => { const { helper, removeHelper } = context return processFor(node, dir, context, forNode => { + if (forNode.parseResult.matchScope) { + const key = findProp(node, 'key', false, true) + const keyValue = + key?.type === NodeTypes.DIRECTIVE + ? key.exp + : key?.value && createSimpleExpression(key.value.content, true) + const keyExpression = + keyValue && !__BROWSER__ && context.prefixIdentifiers + ? processExpression(keyValue as SimpleExpressionNode, context) + : keyValue + return () => { + const children = forNode.children + const child = children.length === 1 ? children[0] : undefined + const body = + child && + (child.type === NodeTypes.ELEMENT || + child.type === NodeTypes.IF || + child.type === NodeTypes.FOR) + ? child.codegenNode! + : createVNodeCall( + context, + helper(FRAGMENT), + undefined, + children, + PatchFlags.STABLE_FRAGMENT, + undefined, + undefined, + true, + ) + if (body.type === NodeTypes.VNODE_CALL) convertToBlock(body, context) + forNode.codegenNode = { + type: NodeTypes.JS_SCOPE_EXPRESSION, + value: forNode.valueAlias!, + source: forNode.source, + body, + loc: node.loc, + } + if (keyExpression) + injectProp( + forNode.codegenNode, + createObjectProperty('key', keyExpression), + context, + ) + } + } // create the loop render function expression now, and add the // iterator on exit after all children have been traversed const renderExp = createCallExpression(helper(RENDER_LIST), [ diff --git a/packages/compiler-core/src/transforms/vMatch.ts b/packages/compiler-core/src/transforms/vMatch.ts new file mode 100644 index 00000000000..8d294dd6b78 --- /dev/null +++ b/packages/compiler-core/src/transforms/vMatch.ts @@ -0,0 +1,259 @@ +import { parseExpression } from '@babel/parser' +import { type CompilerError, defaultOnWarn } from '../errors' +import type { CompilerOptions } from '../options' +import { + type DirectiveNode, + type ElementNode, + ElementTypes, + NodeTypes, + type RootNode, + type SimpleExpressionNode, + type SourceLocation, + type TemplateChildNode, + createSimpleExpression, +} from '../ast' +import { findDir, findProp, isCommentOrWhitespace } from '../utils' +import { + type MatchArm, + PatternSyntaxError, + generateMatchSelector, + parseMatchPattern, +} from '../patterns' + +/** + * RFC 823 reference lowering, shared by VDOM, SSR and Vapor. The selector is + * evaluated once in a lexical scope. Existing structural traversal tracks + * binding/slot scopes; code generation returns the selected conditional branch + * directly, without introducing list fragments or runtime directives. + * Keeping selection out of the render bodies also preserves empty arms. + */ +export function lowerMatchDirectives( + ast: RootNode, + options: Pick, +): void { + let id = 0 + let prefix = '__vue_match' + while (ast.source.includes(prefix)) prefix += '_' + + function report(message: string, loc: SourceLocation, warning = false) { + const error = new SyntaxError(message) as CompilerError + error.code = warning ? 'V_MATCH_WARNING' : 'V_MATCH_SYNTAX' + error.loc = loc + if (warning) (options.onWarn || defaultOnWarn)(error) + else if (options.onError) options.onError(error) + else throw error + } + + function template( + children: TemplateChildNode[], + loc: SourceLocation, + ): ElementNode { + return { + type: NodeTypes.ELEMENT, + tag: 'template', + tagType: ElementTypes.TEMPLATE, + ns: 0, + props: [], + children, + loc, + codegenNode: undefined, + } + } + function expression(content: string, loc: SourceLocation, params = false) { + const exp = createSimpleExpression(content, false, loc) + if (!__BROWSER__) + exp.ast = parseExpression( + params ? `(${content}) => {}` : `(${content})`, + { plugins: ['typescript'] }, + ) + return exp + } + function directive( + name: string, + content: string, + loc: SourceLocation, + ): DirectiveNode { + return { + type: NodeTypes.DIRECTIVE, + name, + rawName: `v-${name}`, + exp: expression(content, loc), + arg: undefined, + modifiers: [], + loc, + } + } + function scope( + children: TemplateChildNode[], + value: string, + source: string, + loc: SourceLocation, + sourceRanges?: SimpleExpressionNode['sourceRanges'], + ): ElementNode { + const node = template(children, loc) + const dir = directive('for', `${value} in [${source}]`, loc) + const sourceExpression = expression(`[${source}]`, loc) + sourceExpression.sourceRanges = sourceRanges?.map(range => ({ + ...range, + start: range.start + 1, + end: range.end + 1, + })) + dir.forParseResult = { + source: sourceExpression, + value: expression(value, loc, true), + key: undefined, + index: undefined, + finalized: false, + matchScope: true, + } + node.props.push(dir) + return node + } + function visit(node: RootNode | TemplateChildNode) { + if (node.type !== NodeTypes.ELEMENT && node.type !== NodeTypes.ROOT) return + if (node.type === NodeTypes.ELEMENT) { + const orphan = findDir(node, 'when', true) + if (orphan) { + report('v-when must be a direct child of v-match.', orphan.loc) + node.props.splice(node.props.indexOf(orphan), 1) + } + const match = findDir(node, 'match', true) + if (match) { + node.props.splice(node.props.indexOf(match), 1) + if ( + !match.exp || + match.exp.type !== NodeTypes.SIMPLE_EXPRESSION || + !match.exp.content.trim() || + match.arg || + match.modifiers.length + ) { + report( + 'v-match requires a subject expression and accepts no arguments or modifiers.', + match.loc, + ) + match.exp = expression('undefined', match.loc) + } + const arms: MatchArm[] = [] + const elements: ElementNode[] = [] + let fallback = false + for (const child of node.children) { + if (isCommentOrWhitespace(child)) continue + const when = + child.type === NodeTypes.ELEMENT && findDir(child, 'when', true) + if (!when || child.type !== NodeTypes.ELEMENT) { + report( + 'Every direct child of v-match must declare v-when; this child is ignored.', + child.loc, + true, + ) + continue + } + if ( + when.arg || + when.modifiers.length || + !when.exp || + when.exp.type !== NodeTypes.SIMPLE_EXPRESSION + ) { + report( + 'v-when requires a pattern and accepts no arguments or modifiers.', + when.loc, + ) + continue + } + if (findDir(child, /^(if|else-if|else|for|match)$/, true)) { + report( + 'v-when cannot share an element with v-if, v-else-if, v-else, v-for or v-match.', + when.loc, + ) + continue + } + try { + const arm = parseMatchPattern(when.exp.content) + if (!__BROWSER__ && arm.guard) { + try { + parseExpression(`(${arm.guard.text})`, { + plugins: ['typescript'], + }) + } catch (error) { + throw new PatternSyntaxError( + `Invalid pattern guard: ${(error as Error).message}`, + arm.guard.start, + ) + } + } + if (fallback) + report( + 'An unguarded wildcard arm must be last and unique.', + when.loc, + ) + if (arm.pattern.kind === 'wildcard' && !arm.guard) fallback = true + child.props.splice(child.props.indexOf(when), 1) + // Binding scopes below surround the complete arm, including props. + elements.push(child) + arms.push(arm) + } catch (error) { + if (!(error instanceof PatternSyntaxError)) throw error + report(error.message, when.exp.loc) + } + } + if (!arms.length) report('v-match has no v-when arms.', match.loc, true) + const local = `${prefix}_${id++}` + const branches = elements.map((child, index) => { + const arm = arms[index] + const content = + child.tagType === ElementTypes.TEMPLATE ? child.children : [child] + const once = + child.tagType === ElementTypes.TEMPLATE && + findDir(child, 'once', true) + const bindings = + arm.bindings.length || once + ? scope( + content, + `[, ${arm.bindings.map(b => b.name).join(', ')}]`, + local, + child.loc, + ) + : undefined + if (once) bindings!.props.push(once) + const branch = template(bindings ? [bindings] : content, child.loc) + const key = + child.tagType === ElementTypes.TEMPLATE && + findProp(child, 'key', false, true) + if (key) (bindings || branch).props.push(key) + branch.props.push( + directive( + index ? 'else-if' : 'if', + `${local}[0] === ${index}`, + child.loc, + ), + ) + return branch + }) + const selection = generateMatchSelector( + arms, + match.exp.content, + `${local}_select`, + ) + const matchScope = scope(branches, local, selection.code, match.loc, [ + { + start: selection.subjectOffset, + end: selection.subjectOffset + match.exp.content.length, + loc: match.exp.loc, + }, + ]) + if ( + node.tag === 'template' && + !findDir(node, /^(if|else-if|else|for|slot)$/, true) + ) { + node.tagType = ElementTypes.TEMPLATE + node.props = [...matchScope.props, ...node.props] + node.children = branches + } else { + node.children = [matchScope] + } + } + } + for (const child of node.children) visit(child) + } + visit(ast) +} diff --git a/packages/compiler-core/src/utils.ts b/packages/compiler-core/src/utils.ts index a636670875f..dc5779ed87e 100644 --- a/packages/compiler-core/src/utils.ts +++ b/packages/compiler-core/src/utils.ts @@ -19,12 +19,14 @@ import { type RootNode, type SimpleExpressionNode, type SlotOutletNode, + type SourceLocation, type TemplateChildNode, type TemplateNode, type TextNode, type VNodeCall, createCallExpression, createObjectExpression, + locStub, } from './ast' import type { TransformContext } from './transform' import { @@ -33,6 +35,7 @@ import { KEEP_ALIVE, MERGE_PROPS, NORMALIZE_PROPS, + RENDER_SLOT, SUSPENSE, TELEPORT, TO_HANDLERS, @@ -232,6 +235,35 @@ export const isFnExpression: ( context: Pick, ) => boolean = __BROWSER__ ? isFnExpressionBrowser : isFnExpressionNode +export function getExpressionRange( + node: SimpleExpressionNode, + start: number, + end: number, +): SourceLocation { + if (node.sourceRanges) { + const range = node.sourceRanges.find( + range => start >= range.start && end <= range.end, + ) + if (!range) return locStub + const { loc } = range + return { + start: advancePositionWithClone( + loc.start, + loc.source, + start - range.start, + ), + end: advancePositionWithClone(loc.start, loc.source, end - range.start), + source: loc.source.slice(start - range.start, end - range.start), + } + } + const source = node.content.slice(start, end) + return { + start: advancePositionWithClone(node.loc.start, source, start), + end: advancePositionWithClone(node.loc.start, source, end), + source, + } +} + export function advancePositionWithClone( pos: Position, source: string, @@ -390,10 +422,44 @@ function getUnnormalizedProps( return [props, callPath] } export function injectProp( - node: VNodeCall | RenderSlotCall, + node: BlockCodegenNode | CacheExpression, prop: Property, context: TransformContext, ): void { + if (node.type === NodeTypes.JS_CACHE_EXPRESSION) { + injectScopeBody(node.value) + return + } + if (node.type === NodeTypes.JS_SCOPE_EXPRESSION) { + injectScopeBody(node.body) + return + } + function injectScopeBody(body: JSChildNode | TemplateChildNode): void { + if (body.type === NodeTypes.JS_CACHE_EXPRESSION) { + injectScopeBody(body.value) + } else if ( + body.type === NodeTypes.JS_CALL_EXPRESSION && + body.callee === WITH_MEMO + ) { + injectScopeBody(getMemoedVNodeCall(body as MemoExpression)) + } else if (body.type === NodeTypes.JS_CONDITIONAL_EXPRESSION) { + injectScopeBody(body.consequent) + injectScopeBody(body.alternate) + } else if ( + body.type === NodeTypes.ELEMENT || + body.type === NodeTypes.IF || + body.type === NodeTypes.FOR + ) { + if (body.codegenNode) injectScopeBody(body.codegenNode) + } else if ( + body.type === NodeTypes.VNODE_CALL || + body.type === NodeTypes.JS_SCOPE_EXPRESSION || + (body.type === NodeTypes.JS_CALL_EXPRESSION && + body.callee === RENDER_SLOT) + ) { + injectProp(body as BlockCodegenNode, prop, context) + } + } if (node.type !== NodeTypes.VNODE_CALL && injectSlotKey(node, prop)) { return } @@ -596,7 +662,7 @@ export function hasScopeRef( export function getMemoedVNodeCall( node: BlockCodegenNode | MemoExpression, -): VNodeCall | RenderSlotCall { +): BlockCodegenNode { if (node.type === NodeTypes.JS_CALL_EXPRESSION && node.callee === WITH_MEMO) { return node.arguments[1].returns as VNodeCall } else { diff --git a/packages/compiler-dom/__tests__/__snapshots__/vMatch.spec.ts.snap b/packages/compiler-dom/__tests__/__snapshots__/vMatch.spec.ts.snap new file mode 100644 index 00000000000..d53e5b3786a --- /dev/null +++ b/packages/compiler-dom/__tests__/__snapshots__/vMatch.spec.ts.snap @@ -0,0 +1,68 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`SSR v-match > lowers patterns and branch bindings without runtime directives 1`] = ` +"import { mergeProps as _mergeProps } from "vue" +import { ssrRenderAttrs as _ssrRenderAttrs, ssrInterpolate as _ssrInterpolate } from "vue/server-renderer" + +export function ssrRender(_ctx, _push, _parent, _attrs) { + { const __vue_match_0 = ([((__vue_match_0_select_0) => { { const __vue_match_0_select_3 = (() => { if (__vue_match_0_select_0 == null) return null; if (!("kind" in Object(__vue_match_0_select_0))) return null; const __vue_match_0_select_1 = __vue_match_0_select_0["kind"]; if (!(__vue_match_0_select_1 === 'ok')) return null; if (!("data" in Object(__vue_match_0_select_0))) return null; const __vue_match_0_select_2 = __vue_match_0_select_0["data"]; { const data = __vue_match_0_select_2; if (!(data > 0)) return null; return [0, data]; } })(); if (__vue_match_0_select_3 !== null) return __vue_match_0_select_3; } { const __vue_match_0_select_4 = (() => { { return [1]; } })(); if (__vue_match_0_select_4 !== null) return __vue_match_0_select_4; } return [-1]; })(_ctx.result)])[0]; + if (__vue_match_0[0] === 0) { + { const [, data] = ([__vue_match_0])[0]; + _push(\`\${ + _ssrInterpolate(data) + }

\`) + } + } else if (__vue_match_0[0] === 1) { + _push(\`empty

\`) + } else { + _push(\`\`) + } + } +}" +`; + +exports[`Vapor v-match > lowers patterns and branch bindings without runtime directives 1`] = ` +"import { txt as _txt, setProp as _setProp, toDisplayString as _toDisplayString, setText as _setText, renderEffect as _renderEffect, computed as _computed, createIf as _createIf, template as _template } from 'vue'; +const t0 = _template("

") +const t1 = _template("

empty") + +export function render(_ctx) { + const n0 = ((_for_item0) => { + const n2 = _createIf(() => (_for_item0.value[0] === 0), () => { + const n4 = ((_for_item1) => { + const n6 = t0() + const x6 = _txt(n6) + _renderEffect(() => { + const _data = _for_item1.value[1] + _setProp(n6, "title", _data) + _setText(x6, _toDisplayString(_data)) + }) + return n6 + })(_computed(() => ([_for_item0.value])[0])) + return n4 + }, () => _createIf(() => (_for_item0.value[0] === 1), () => { + const n8 = t1() + return n8 + }), 261 /* TRUE_SINGLE_ROOT, FALSE_SINGLE_ROOT, KEYED_INDEX_0 */) + return n2 + })(_computed(() => ([((__vue_match_0_select_0) => { { const __vue_match_0_select_3 = (() => { if (__vue_match_0_select_0 == null) return null; if (!("kind" in Object(__vue_match_0_select_0))) return null; const __vue_match_0_select_1 = __vue_match_0_select_0["kind"]; if (!(__vue_match_0_select_1 === 'ok')) return null; if (!("data" in Object(__vue_match_0_select_0))) return null; const __vue_match_0_select_2 = __vue_match_0_select_0["data"]; { const data = __vue_match_0_select_2; if (!(data > 0)) return null; return [0, data]; } })(); if (__vue_match_0_select_3 !== null) return __vue_match_0_select_3; } { const __vue_match_0_select_4 = (() => { { return [1]; } })(); if (__vue_match_0_select_4 !== null) return __vue_match_0_select_4; } return [-1]; })(_ctx.result)])[0])) + return n0 +}" +`; + +exports[`client v-match > lowers patterns and branch bindings without runtime directives 1`] = ` +"import { toDisplayString as _toDisplayString, openBlock as _openBlock, createElementBlock as _createElementBlock, createCommentVNode as _createCommentVNode } from "vue" + +export function render(_ctx, _cache) { + return (() => { const __vue_match_0 = ([((__vue_match_0_select_0) => { { const __vue_match_0_select_3 = (() => { if (__vue_match_0_select_0 == null) return null; if (!("kind" in Object(__vue_match_0_select_0))) return null; const __vue_match_0_select_1 = __vue_match_0_select_0["kind"]; if (!(__vue_match_0_select_1 === 'ok')) return null; if (!("data" in Object(__vue_match_0_select_0))) return null; const __vue_match_0_select_2 = __vue_match_0_select_0["data"]; { const data = __vue_match_0_select_2; if (!(data > 0)) return null; return [0, data]; } })(); if (__vue_match_0_select_3 !== null) return __vue_match_0_select_3; } { const __vue_match_0_select_4 = (() => { { return [1]; } })(); if (__vue_match_0_select_4 !== null) return __vue_match_0_select_4; } return [-1]; })(_ctx.result)])[0]; return (__vue_match_0[0] === 0) + ? (() => { const [, data] = ([__vue_match_0])[0]; return (_openBlock(), _createElementBlock("p", { + key: 0, + title: data + }, _toDisplayString(data), 9 /* TEXT, PROPS */, ["title"])) })() + : (__vue_match_0[0] === 1) + ? (_openBlock(), _createElementBlock("p", { key: 1 }, "empty")) + : _createCommentVNode("v-if", true) })() +}" +`; diff --git a/packages/compiler-dom/__tests__/vMatch.spec.ts b/packages/compiler-dom/__tests__/vMatch.spec.ts new file mode 100644 index 00000000000..7fc314c21b5 --- /dev/null +++ b/packages/compiler-dom/__tests__/vMatch.spec.ts @@ -0,0 +1,66 @@ +import { compile } from '../src' +import { compile as compileSSR } from '@vue/compiler-ssr' +import { compile as compileVapor } from '@vue/compiler-vapor' + +const source = `` + +describe.each([ + ['client', compile], + ['SSR', compileSSR], + ['Vapor', compileVapor], +] as const)('%s v-match', (_, compiler) => { + test('lowers patterns and branch bindings without runtime directives', () => { + const { code } = compiler(source, { + mode: 'module', + prefixIdentifiers: true, + }) + expect(code).not.toContain('resolveDirective') + expect(code).not.toContain('v-match') + expect(code).not.toContain('v-when') + expect(code).not.toContain('_ctx.data') + expect(code).toContain('_ctx.result') + expect(code).toMatchSnapshot() + }) + test.each([ + '

', + '', + '', + '', + '', + '', + ...['if="ok"', 'else', 'else-if="ok"', 'for="x in xs"', 'match="x"'].map( + dir => ``, + ), + '', + ])('rejects invalid structure: %s', template => { + expect(() => compiler(template)).toThrow() + }) + test('reports an invalid guard through the compiler error callback', () => { + const onError = vi.fn() + expect(() => + compiler( + '', + { onError }, + ), + ).not.toThrow() + expect(onError).toHaveBeenCalledOnce() + expect(onError.mock.calls[0][0].code).toBe('V_MATCH_SYNTAX') + }) + test('warns for ignored children and empty match', () => { + const onWarn = vi.fn() + const { code } = compiler( + '', + { onWarn }, + ) + expect(onWarn).toHaveBeenCalledTimes(3) + expect(code).not.toContain('ignored') + }) + test('supports nested matches, slots, loops and ordinary hosts', () => { + expect(() => + compiler( + `

`, + { mode: 'module', prefixIdentifiers: true }, + ), + ).not.toThrow() + }) +}) diff --git a/packages/compiler-dom/src/transforms/Transition.ts b/packages/compiler-dom/src/transforms/Transition.ts index c53a0edf687..6cc66d74b14 100644 --- a/packages/compiler-dom/src/transforms/Transition.ts +++ b/packages/compiler-dom/src/transforms/Transition.ts @@ -2,6 +2,7 @@ import { type CompilerError, type ComponentNode, ElementTypes, + type ForNode, type IfBranchNode, type NodeTransform, NodeTypes, @@ -65,7 +66,7 @@ export function postTransformTransition( } function defaultHasMultipleChildren( - node: ComponentNode | IfBranchNode, + node: ComponentNode | IfBranchNode | ForNode, ): boolean { // filter out potential comment nodes (#1352) and whitespace (#4637) const children = (node.children = node.children.filter( @@ -74,7 +75,8 @@ function defaultHasMultipleChildren( const child = children[0] return ( children.length !== 1 || - child.type === NodeTypes.FOR || + (child.type === NodeTypes.FOR && + (!child.parseResult.matchScope || defaultHasMultipleChildren(child))) || (child.type === NodeTypes.IF && child.branches.some(defaultHasMultipleChildren)) ) diff --git a/packages/compiler-sfc/__tests__/vMatch.spec.ts b/packages/compiler-sfc/__tests__/vMatch.spec.ts new file mode 100644 index 00000000000..6be05e61927 --- /dev/null +++ b/packages/compiler-sfc/__tests__/vMatch.spec.ts @@ -0,0 +1,109 @@ +import { compileScript, compileTemplate, parse } from '../src' +import { SourceMapConsumer } from 'source-map-js' + +const arms = `

{{ data }}

error` + +describe('SFC root v-match', () => { + test.each([ + { ssr: false, vapor: false }, + { ssr: true, vapor: false }, + { ssr: false, vapor: true }, + ])('matches an inner block with %j, including AST reuse', options => { + const root = parse(``) + .descriptor.template! + const nested = parse( + ``, + ).descriptor.template! + const compile = (template: typeof root) => + compileTemplate({ + id: 'test', + filename: 'example.vue', + source: template.content, + ast: template.ast, + ssrCssVars: [], + ...options, + }) + const expected = compile(nested) + expect(expected.errors).toEqual([]) + for (let i = 0; i < 2; i++) { + const result = compile(root) + expect(result.errors).toEqual([]) + expect(result.code).toBe(expected.code) + if (options.vapor) expect(result.multiRoot).toBe(false) + } + }) + + test('retains the header expression for import usage and HMR', () => { + const source = `` + const { descriptor } = parse(source) + expect(descriptor.template!.attrs['v-match']).toBe('result') + expect(descriptor.template!.content).toBe(arms) + const script = compileScript(descriptor, { id: 'test' }) + expect(script.imports!.result.isUsedInTemplate).toBe(true) + expect(() => + compileScript(descriptor, { id: 'test', inlineTemplate: true }), + ).not.toThrow() + }) + + test.each(['v-match', 'v-match=""', 'v-match:arg="x"', 'v-match.foo="x"'])( + 'rejects invalid root syntax: %s', + directive => { + const { descriptor } = parse( + ``, + ) + const template = descriptor.template! + const result = compileTemplate({ + id: 'test', + filename: 'example.vue', + source: template.content, + ast: template.ast, + }) + expect(result.errors).toHaveLength(1) + expect(result.errors[0]).toMatchObject({ + code: 'V_MATCH_SYNTAX', + message: expect.stringContaining( + 'v-match requires a subject expression', + ), + }) + expect(result.errors[0]).toHaveProperty( + 'loc.start.offset', + '