From 08016360f57dc8e5477598fbcf0f118c2d2732b0 Mon Sep 17 00:00:00 2001 From: ubugeeei Date: Wed, 16 Sep 2026 14:35:24 +0900 Subject: [PATCH 1/8] feat(compiler): add RFC 823 patterned template reference implementation --- .../compiler-core/__tests__/patterns.spec.ts | 266 +++++++++++ .../__tests__/transforms/cacheStatic.spec.ts | 7 +- packages/compiler-core/src/ast.ts | 15 +- packages/compiler-core/src/codegen.ts | 9 + packages/compiler-core/src/compile.ts | 3 + packages/compiler-core/src/index.ts | 3 + packages/compiler-core/src/parser.ts | 12 +- packages/compiler-core/src/patterns.ts | 423 ++++++++++++++++++ packages/compiler-core/src/transforms/vFor.ts | 46 ++ .../compiler-core/src/transforms/vMatch.ts | 240 ++++++++++ packages/compiler-core/src/utils.ts | 28 +- .../__snapshots__/vMatch.spec.ts.snap | 68 +++ .../compiler-dom/__tests__/vMatch.spec.ts | 66 +++ .../compiler-dom/src/transforms/Transition.ts | 6 +- packages/compiler-ssr/src/index.ts | 3 + .../compiler-ssr/src/ssrCodegenTransform.ts | 3 +- .../transforms/ssrInjectFallthroughAttrs.ts | 33 ++ .../compiler-ssr/src/transforms/ssrVFor.ts | 22 + packages/compiler-vapor/src/compile.ts | 3 + packages/compiler-vapor/src/generators/for.ts | 36 +- packages/compiler-vapor/src/ir/index.ts | 1 + .../src/transforms/transformTransition.ts | 7 +- .../compiler-vapor/src/transforms/vFor.ts | 1 + packages/compiler-vapor/src/transforms/vIf.ts | 5 +- .../__tests__/hydration/vMatch.spec.ts | 45 ++ .../runtime-vapor/__tests__/vMatch.spec.ts | 72 +++ packages/vue/__tests__/vMatch.spec.ts | 151 +++++++ 27 files changed, 1557 insertions(+), 17 deletions(-) create mode 100644 packages/compiler-core/__tests__/patterns.spec.ts create mode 100644 packages/compiler-core/src/patterns.ts create mode 100644 packages/compiler-core/src/transforms/vMatch.ts create mode 100644 packages/compiler-dom/__tests__/__snapshots__/vMatch.spec.ts.snap create mode 100644 packages/compiler-dom/__tests__/vMatch.spec.ts create mode 100644 packages/runtime-vapor/__tests__/hydration/vMatch.spec.ts create mode 100644 packages/runtime-vapor/__tests__/vMatch.spec.ts create mode 100644 packages/vue/__tests__/vMatch.spec.ts diff --git a/packages/compiler-core/__tests__/patterns.spec.ts b/packages/compiler-core/__tests__/patterns.spec.ts new file mode 100644 index 00000000000..6d011b9b138 --- /dev/null +++ b/packages/compiler-core/__tests__/patterns.spec.ts @@ -0,0 +1,266 @@ +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}`, + )(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')}`, + ), + ).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]', + '_ 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')}`, + )(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..b0ca9c573c8 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 { @@ -295,10 +296,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 +351,7 @@ export interface VNodeCall extends Node { // Vue render function generation. export type JSChildNode = + | ScopeExpression | VNodeCall | CallExpression | ObjectExpression @@ -570,7 +574,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..9d1963bbcd5 --- /dev/null +++ b/packages/compiler-core/src/patterns.ts @@ -0,0 +1,423 @@ +/** 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, +): string { + let nextId = 0 + const temp = () => `${prefix}_${nextId++}` + const root = temp() + const branches = arms.map((arm, index) => { + 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() + return `{ const ${result} = (() => { ${tests.join(' ')} ${copies.join(' ')} { ${declarations.join(' ')} ${arm.guard ? `if (!(${arm.guard.text})) return null;` : ''} return [${index}${arm.bindings.map(b => `, ${b.name}`).join('')}]; } })(); if (${result} !== null) return ${result}; }` + }) + return `((${root}) => { ${branches.join(' ')} return [-1]; })(${subject})` +} 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..c124a97765c --- /dev/null +++ b/packages/compiler-core/src/transforms/vMatch.ts @@ -0,0 +1,240 @@ +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 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, + ): ElementNode { + const node = template(children, loc) + const dir = directive('for', `${value} in [${source}]`, loc) + dir.forParseResult = { + source: expression(`[${source}]`, loc), + 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, + ) + return + } + 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 bindings = arm.bindings.length + ? scope( + content, + `[, ${arm.bindings.map(b => b.name).join(', ')}]`, + local, + child.loc, + ) + : undefined + 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, match.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..ca2bc62c372 100644 --- a/packages/compiler-core/src/utils.ts +++ b/packages/compiler-core/src/utils.ts @@ -33,6 +33,7 @@ import { KEEP_ALIVE, MERGE_PROPS, NORMALIZE_PROPS, + RENDER_SLOT, SUSPENSE, TELEPORT, TO_HANDLERS, @@ -390,10 +391,33 @@ function getUnnormalizedProps( return [props, callPath] } export function injectProp( - node: VNodeCall | RenderSlotCall, + node: BlockCodegenNode, prop: Property, context: TransformContext, ): void { + if (node.type === NodeTypes.JS_SCOPE_EXPRESSION) { + injectScopeBody(node.body) + return + } + function injectScopeBody(body: JSChildNode | TemplateChildNode): void { + 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 +620,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-ssr/src/index.ts b/packages/compiler-ssr/src/index.ts index cc1658d783c..8cdfb5cf9f5 100644 --- a/packages/compiler-ssr/src/index.ts +++ b/packages/compiler-ssr/src/index.ts @@ -4,6 +4,7 @@ import { type RootNode, baseParse, generate, + lowerMatchDirectives, noopDirectiveTransform, parserOptions, trackSlotScopes, @@ -52,6 +53,8 @@ export function compile( // on slot vnode branches. rawOptionsMap.set(ast, options) + lowerMatchDirectives(ast, options) + transform(ast, { ...options, hoistStatic: false, diff --git a/packages/compiler-ssr/src/ssrCodegenTransform.ts b/packages/compiler-ssr/src/ssrCodegenTransform.ts index 536cbb5c1e9..f2cf274521e 100644 --- a/packages/compiler-ssr/src/ssrCodegenTransform.ts +++ b/packages/compiler-ssr/src/ssrCodegenTransform.ts @@ -1,6 +1,5 @@ import { type BlockStatement, - type CallExpression, type CompilerError, type CompilerOptions, ElementTypes, @@ -84,7 +83,7 @@ export interface SSRTransformContext { onError: (error: CompilerError) => void helper(name: T): T pushStringPart(part: TemplateLiteral['elements'][0]): void - pushStatement(statement: IfStatement | CallExpression): void + pushStatement(statement: JSChildNode | IfStatement): void } function createSSRTransformContext( diff --git a/packages/compiler-ssr/src/transforms/ssrInjectFallthroughAttrs.ts b/packages/compiler-ssr/src/transforms/ssrInjectFallthroughAttrs.ts index 8622421242e..bbfbdddd15c 100644 --- a/packages/compiler-ssr/src/transforms/ssrInjectFallthroughAttrs.ts +++ b/packages/compiler-ssr/src/transforms/ssrInjectFallthroughAttrs.ts @@ -18,6 +18,9 @@ export const ssrInjectFallthroughAttrs: NodeTransform = (node, context) => { // transformExpression. if (node.type === NodeTypes.ROOT) { context.identifiers._attrs = 1 + const children = filterNonCommentChildren(node) + if (children.length === 1 && isMatchScope(children[0])) + injectMatchAttrs(children[0]) } if ( @@ -52,6 +55,10 @@ export const ssrInjectFallthroughAttrs: NodeTransform = (node, context) => { } function injectFallthroughAttrs(node: RootNode | TemplateChildNode) { + if (isMatchScope(node)) { + injectMatchAttrs(node) + return + } if ( node.type === NodeTypes.ELEMENT && (node.tagType === ElementTypes.ELEMENT || @@ -68,3 +75,29 @@ function injectFallthroughAttrs(node: RootNode | TemplateChildNode) { }) } } + +function isMatchScope(node: RootNode | TemplateChildNode) { + return ( + node.type === NodeTypes.ELEMENT && + findDir(node, 'for')?.forParseResult?.matchScope + ) +} + +function injectMatchAttrs(node: RootNode | TemplateChildNode) { + if (node.type !== NodeTypes.ELEMENT) return + if (isMatchScope(node)) { + const children = filterNonCommentChildren(node) + if (children.length === 1) injectMatchAttrs(children[0]) + else if ( + children.every( + c => + c.type === NodeTypes.ELEMENT && + findDir(c, /^(if|else-if|else)$/, true), + ) + ) { + for (const child of children) injectMatchAttrs(child) + } + } else if (node.tagType === ElementTypes.TEMPLATE && hasSingleChild(node)) { + injectMatchAttrs(filterNonCommentChildren(node)[0]) + } else injectFallthroughAttrs(node) +} diff --git a/packages/compiler-ssr/src/transforms/ssrVFor.ts b/packages/compiler-ssr/src/transforms/ssrVFor.ts index 6537eee8287..be174df49c4 100644 --- a/packages/compiler-ssr/src/transforms/ssrVFor.ts +++ b/packages/compiler-ssr/src/transforms/ssrVFor.ts @@ -3,6 +3,7 @@ import { type NodeTransform, NodeTypes, createCallExpression, + createCompoundExpression, createForLoopParams, createFunctionExpression, createStructuralDirectiveTransform, @@ -25,6 +26,27 @@ export function ssrProcessFor( context: SSRTransformContext, disableNestedFragments = false, ): void { + if (node.parseResult.matchScope) { + context.pushStatement( + createCompoundExpression([ + '{ const ', + node.valueAlias!, + ' = (', + node.source, + ')[0];', + ]), + ) + const fragment = + node.children.length !== 1 || + ![NodeTypes.ELEMENT, NodeTypes.IF, NodeTypes.FOR].includes( + node.children[0].type, + ) + for (const statement of processChildrenAsStatement(node, context, fragment) + .body) + context.pushStatement(statement) + context.pushStatement(createCompoundExpression(['}'])) + return + } const needFragmentWrapper = !disableNestedFragments && (node.children.length !== 1 || node.children[0].type !== NodeTypes.ELEMENT) diff --git a/packages/compiler-vapor/src/compile.ts b/packages/compiler-vapor/src/compile.ts index 22111aae117..6131d510de2 100644 --- a/packages/compiler-vapor/src/compile.ts +++ b/packages/compiler-vapor/src/compile.ts @@ -1,6 +1,7 @@ import { type CompilerOptions as BaseCompilerOptions, type RootNode, + lowerMatchDirectives, parse, } from '@vue/compiler-dom' import { extend, isString } from '@vue/shared' @@ -51,6 +52,8 @@ export function compile( } } + lowerMatchDirectives(ast, resolvedOptions) + const ir = transform( ast, extend({}, resolvedOptions, { diff --git a/packages/compiler-vapor/src/generators/for.ts b/packages/compiler-vapor/src/generators/for.ts index 333387f963e..d3a16418c8f 100644 --- a/packages/compiler-vapor/src/generators/for.ts +++ b/packages/compiler-vapor/src/generators/for.ts @@ -80,7 +80,7 @@ export function genFor( } const { selectorPatterns, keyOnlyBindingPatterns, skippedEffectIndexes } = - matchPatterns(render, keyProp, idMap, context) + matchPatterns(render, oper.matchScope ? undefined : keyProp, idMap, context) const selectorDeclarations: CodeFragment[] = [] const selectorName = (i: number) => selectorPatterns.length > 1 ? `_selector${id}_${i}` : `_selector${id}` @@ -100,7 +100,24 @@ export function genFor( const blockFn = context.withId(() => { const frag: CodeFragment[] = [] frag.push('(', ...args, ') => {', INDENT_START) - if (selectorPatterns.length || keyOnlyBindingPatterns.length) { + if (oper.matchScope && keyProp) { + frag.push( + NEWLINE, + 'return ', + ...genCall( + helper('createKeyedFragment'), + ['() => (', ...genExpression(keyProp, context), ')'], + [ + '() => {', + INDENT_START, + ...genBlockContent(render, context), + INDENT_END, + NEWLINE, + '}', + ], + ), + ) + } else if (selectorPatterns.length || keyOnlyBindingPatterns.length) { frag.push( ...genBlockContent( render, @@ -143,6 +160,21 @@ export function genFor( }, idMap) exitScope() + if (oper.matchScope) { + return [ + NEWLINE, + `const n${id} = (`, + ...blockFn, + `)(`, + ...genCall(helper('computed'), [ + '() => (', + ...genExpression(source, context), + ')[0]', + ]), + ')', + ] + } + const flags = genForFlags( onlyChild, component, diff --git a/packages/compiler-vapor/src/ir/index.ts b/packages/compiler-vapor/src/ir/index.ts index 16bb2cd0938..748dda6adc3 100644 --- a/packages/compiler-vapor/src/ir/index.ts +++ b/packages/compiler-vapor/src/ir/index.ts @@ -110,6 +110,7 @@ export interface IfIRNode extends BaseIRNode, EffectBoundary, InsertionState { } export interface IRFor { + matchScope?: boolean source: SimpleExpressionNode value?: SimpleExpressionNode key?: SimpleExpressionNode diff --git a/packages/compiler-vapor/src/transforms/transformTransition.ts b/packages/compiler-vapor/src/transforms/transformTransition.ts index ea3ffb25370..f9a8e7538c9 100644 --- a/packages/compiler-vapor/src/transforms/transformTransition.ts +++ b/packages/compiler-vapor/src/transforms/transformTransition.ts @@ -34,7 +34,10 @@ function hasMultipleChildren(node: ElementNode): boolean { if (children.length === 1 && first.type === NodeTypes.ELEMENT) { // has v-for - if (findDir(first, 'for')) { + if ( + findDir(first, 'for') && + !findDir(first, 'for')?.forParseResult?.matchScope + ) { return true } @@ -55,7 +58,7 @@ function hasMultipleChildren(node: ElementNode): boolean { c.type === NodeTypes.ELEMENT && (!isTemplateNode(c) || !hasMultipleChildren(c)) && // not has v-for - !findDir(c, 'for') && + (!findDir(c, 'for') || findDir(c, 'for')?.forParseResult?.matchScope) && // if the first child has v-if, the rest should also have v-else-if/v-else (index === 0 ? findDir(c, 'if') : hasElse(c)), ) diff --git a/packages/compiler-vapor/src/transforms/vFor.ts b/packages/compiler-vapor/src/transforms/vFor.ts index 29211f58833..32ce4eef582 100644 --- a/packages/compiler-vapor/src/transforms/vFor.ts +++ b/packages/compiler-vapor/src/transforms/vFor.ts @@ -90,6 +90,7 @@ export function processFor( context.dynamic.operation = { type: IRNodeTypes.FOR, + matchScope: parseResult.matchScope, id, ...context.effectBoundary(), source: source as SimpleExpressionNode, diff --git a/packages/compiler-vapor/src/transforms/vIf.ts b/packages/compiler-vapor/src/transforms/vIf.ts index 6e9e5cc949a..5799aab31bf 100644 --- a/packages/compiler-vapor/src/transforms/vIf.ts +++ b/packages/compiler-vapor/src/transforms/vIf.ts @@ -286,7 +286,10 @@ function shouldForceMultiRoot(context: TransformContext): boolean { parent.type === NodeTypes.ELEMENT && parent.tagType === ElementTypes.TEMPLATE && parent.props.some( - prop => prop.type === NodeTypes.DIRECTIVE && prop.name === 'for', + prop => + prop.type === NodeTypes.DIRECTIVE && + prop.name === 'for' && + !prop.forParseResult?.matchScope, ) ) } diff --git a/packages/runtime-vapor/__tests__/hydration/vMatch.spec.ts b/packages/runtime-vapor/__tests__/hydration/vMatch.spec.ts new file mode 100644 index 00000000000..42e7a30e3f7 --- /dev/null +++ b/packages/runtime-vapor/__tests__/hydration/vMatch.spec.ts @@ -0,0 +1,45 @@ +import { nextTick, ref } from '@vue/runtime-dom' +import { setupHydrationTest, testHydration } from './_helpers' + +setupHydrationTest() + +describe('patterned templates hydration', () => { + test('hydrates nested multi-root binding arms and retains sibling nodes', async () => { + const data = ref({ items: [1, 2, 3] }) + const { container, app } = await testHydration( + ``, + {}, + data, + ) + const span = container.querySelector('span') + expect(container.textContent).toBe('12,3after') + data.value.items = [] + await nextTick() + expect(container.textContent).toBe('after') + expect(container.querySelector('span')).toBe(span) + app.unmount() + }) + + test.each([false, true])( + 'hydrates and updates the first matching arm (bound=%s)', + async bindings => { + const data = ref({ kind: 'ok', text: 'first' }) + const { container, app } = await testHydration( + ``, + {}, + data, + ) + const p = container.querySelector('p') + expect(p).not.toBeNull() + data.value = { kind: 'ok', text: 'second' } + await nextTick() + expect(container.querySelector('p')).toBe(p) + expect(container.textContent).toBe(bindings ? 'second' : 'ok') + data.value = null + await nextTick() + expect(container.querySelector('p')).toBeNull() + expect(container.textContent).toBe('empty') + app.unmount() + }, + ) +}) diff --git a/packages/runtime-vapor/__tests__/vMatch.spec.ts b/packages/runtime-vapor/__tests__/vMatch.spec.ts new file mode 100644 index 00000000000..2a35154ad05 --- /dev/null +++ b/packages/runtime-vapor/__tests__/vMatch.spec.ts @@ -0,0 +1,72 @@ +import { nextTick, ref } from '@vue/runtime-dom' +import { renderParity } from './_utils' + +describe('v-match VDOM / Vapor parity', () => { + test('template arm keys remount while empty arms stop fallthrough', async () => { + await renderParity( + { + App: ``, + }, + () => ref({ id: 'a' }), + async (data, root) => { + const first = root.querySelector('input') + data.value = { id: 'b' } + await nextTick() + expect(root.querySelector('input')).not.toBe(first) + data.value = null + await nextTick() + expect(root.textContent).toBe('') + data.value = 42 + await nextTick() + expect(root.textContent).toBe('fallback') + }, + ) + }) + + test('reactive selection, guards, rest and event binding lifetime', async () => { + const results = await renderParity( + { + App: ``, + }, + () => + ref({ + value: { kind: 'ok', value: 1, label: 'first' }, + clicked: [] as number[], + }), + async (data, root) => { + expect(root.textContent).toBe('1:first') + const button = root.querySelector('button')! + button.click() + data.value.value = { kind: 'ok', value: 2, label: 'second' } + await nextTick() + expect(root.querySelector('button')).toBe(button) + button.click() + expect(data.value.clicked).toEqual([1, 2]) + expect(root.textContent).toBe('2:second') + data.value.value.value = 0 + await nextTick() + expect(root.querySelector('button')).toBeNull() + }, + ) + expect(results.vdom.text).toBe('empty') + expect(results.vapor.text).toBe(results.vdom.text) + }) + + test('nested match and array rest react to in-place updates', async () => { + const results = await renderParity( + { + App: ``, + }, + () => ref([1, 2]), + async (data, root) => { + expect(root.textContent).toBe('one:2') + data.value.push(3) + await nextTick() + expect(root.textContent).toBe('one:2,3') + data.value = [] + }, + ) + expect(results.vdom.text).toBe('empty') + expect(results.vapor.text).toBe('empty') + }) +}) diff --git a/packages/vue/__tests__/vMatch.spec.ts b/packages/vue/__tests__/vMatch.spec.ts new file mode 100644 index 00000000000..aee7e731ddc --- /dev/null +++ b/packages/vue/__tests__/vMatch.spec.ts @@ -0,0 +1,151 @@ +import * as Vue from '../src' +import * as SSR from '@vue/server-renderer' +import { compile } from '@vue/compiler-dom' +import { compile as compileSSR } from '@vue/compiler-ssr' +import { + type ComponentOptions, + createApp, + createSSRApp, + nextTick, + ref, +} from '../src' + +function component(template: string, setup: ComponentOptions['setup']) { + const { code } = compile(template, { + mode: 'function', + prefixIdentifiers: true, + cacheHandlers: true, + hoistStatic: true, + }) + const ssr = compileSSR(template, { mode: 'function' }).code + return { + setup, + render: new Function('Vue', code)(Vue), + ssrRender: new Function('require', ssr)((name: string) => + name === 'vue' ? Vue : SSR, + ), + } +} + +describe('v-match rendering', () => { + test.each(['', 'Transition', 'KeepAlive'])( + 'single-root match preserves fallthrough through %s', + async wrapper => { + const state = ref({ text: 'a' }) + let template = `` + if (wrapper) template = `<${wrapper}>${template}` + const App = component(template, () => ({ state })) + const server = await SSR.renderToString( + createSSRApp(App, { id: 'fallthrough' }), + ) + expect(server).toContain('

a

') + const root = document.createElement('div') + root.innerHTML = server + const first = root.querySelector('p') + const app = createSSRApp(App, { id: 'fallthrough' }) + app.mount(root) + expect(root.querySelector('p')).toBe(first) + expect(first!.id).toBe('fallthrough') + app.unmount() + }, + ) + + test('explicit arm keys react within the binding scope', async () => { + const state = ref({ id: 'a' }) + const App = component( + ``, + () => ({ state }), + ) + const root = document.createElement('div') + const app = createApp(App) + app.mount(root) + const first = root.querySelector('input') + state.value.id = 'b' + await nextTick() + expect(root.querySelector('input')).not.toBe(first) + app.unmount() + }) + + test('client / SSR parity and hydration across reactive arm changes', async () => { + const result = ref({ kind: 'ok', data: 1 }) + const read = vi.fn(() => result.value) + const received: unknown[] = [] + const App = component( + `

{{ rest.message }}

`, + () => ({ result, read, received }), + ) + const root = document.createElement('div') + root.innerHTML = await SSR.renderToString(createSSRApp(App)) + expect(read).toHaveBeenCalledTimes(1) + const button = root.querySelector('button')! + const app = createSSRApp(App) + app.mount(root) + expect(root.querySelector('button')).toBe(button) + expect(read).toHaveBeenCalledTimes(2) + button.click() + expect(received).toEqual([1]) + result.value = { kind: 'ok', data: 2 } + await nextTick() + expect(root.querySelector('button')).toBe(button) + button.click() + expect(received).toEqual([1, 2]) + expect(read).toHaveBeenCalledTimes(3) + result.value = { kind: 'error', message: '' } + await nextTick() + expect(root.textContent).toBe('') + expect(root.querySelector('button')).toBeNull() + const server = document.createElement('div') + server.innerHTML = await SSR.renderToString(createSSRApp(App)) + expect(server.textContent).toBe(root.textContent) + expect(server.querySelector('p')!.outerHTML).toBe( + root.querySelector('p')!.outerHTML, + ) + const serverP = server.querySelector('p') + const hydrated = createSSRApp(App) + hydrated.mount(server) + expect(server.querySelector('p')).toBe(serverP) + hydrated.unmount() + result.value = null + await nextTick() + expect(root.textContent).toBe('') + app.unmount() + }) + + test('identical tags in different arms get distinct identity', async () => { + const state = ref('a') + const App = component( + ``, + () => ({ state }), + ) + const root = document.createElement('div') + const app = createApp(App) + app.mount(root) + const first = root.querySelector('input')! + first.value = 'edited' + state.value = 'b' + await nextTick() + expect(root.querySelector('input')).not.toBe(first) + expect(root.querySelector('input')!.value).toBe('b') + state.value = 'unmatched' + await nextTick() + expect(root.querySelector('input')).toBeNull() + app.unmount() + }) + + test('nested scopes do not leak and array rest stays reactive', async () => { + const items = ref([[1, 2], [3]]) + const App = component( + `
`, + () => ({ items }), + ) + const root = document.createElement('div') + const app = createApp(App) + app.mount(root) + expect(root.textContent).toBe('one:23:0') + items.value[0].push(4) + items.value[1] = [] + await nextTick() + expect(root.textContent).toBe('one:2,4empty') + app.unmount() + }) +}) From 061e3564d302acafc5296a42ee993c6db48db7b9 Mon Sep 17 00:00:00 2001 From: ubugeeei Date: Wed, 16 Sep 2026 14:46:46 +0900 Subject: [PATCH 2/8] fix(compiler): preserve cached match arm scopes --- .../compiler-core/src/transforms/vMatch.ts | 21 +++++---- packages/compiler-core/src/utils.ts | 15 +++++- .../runtime-vapor/__tests__/vMatch.spec.ts | 47 +++++++++++++++++++ 3 files changed, 73 insertions(+), 10 deletions(-) diff --git a/packages/compiler-core/src/transforms/vMatch.ts b/packages/compiler-core/src/transforms/vMatch.ts index c124a97765c..9a968fc2912 100644 --- a/packages/compiler-core/src/transforms/vMatch.ts +++ b/packages/compiler-core/src/transforms/vMatch.ts @@ -194,14 +194,19 @@ export function lowerMatchDirectives( const arm = arms[index] const content = child.tagType === ElementTypes.TEMPLATE ? child.children : [child] - const bindings = arm.bindings.length - ? scope( - content, - `[, ${arm.bindings.map(b => b.name).join(', ')}]`, - local, - child.loc, - ) - : undefined + 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 && diff --git a/packages/compiler-core/src/utils.ts b/packages/compiler-core/src/utils.ts index ca2bc62c372..25ee8a758e5 100644 --- a/packages/compiler-core/src/utils.ts +++ b/packages/compiler-core/src/utils.ts @@ -391,16 +391,27 @@ function getUnnormalizedProps( return [props, callPath] } export function injectProp( - node: BlockCodegenNode, + 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_CONDITIONAL_EXPRESSION) { + 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 ( diff --git a/packages/runtime-vapor/__tests__/vMatch.spec.ts b/packages/runtime-vapor/__tests__/vMatch.spec.ts index 2a35154ad05..250a07241e1 100644 --- a/packages/runtime-vapor/__tests__/vMatch.spec.ts +++ b/packages/runtime-vapor/__tests__/vMatch.spec.ts @@ -23,6 +23,34 @@ describe('v-match VDOM / Vapor parity', () => { ) }) + test.each(['input', 'template'])( + 'v-once retains bindings and arm identity on %s', + async tag => { + const arm = + tag === 'input' + ? `` + : `` + await renderParity( + { + App: ``, + }, + () => ref({ kind: 'a', value: 'first' }), + async (data, root) => { + const first = root.querySelector('input')! + expect(first.value).toBe('first') + data.value.value = 'updated' + await nextTick() + expect(root.querySelector('input')).toBe(first) + expect(first.value).toBe('first') + data.value = { kind: 'b', value: 'second' } + await nextTick() + expect(root.querySelector('input')).not.toBe(first) + expect(root.querySelector('input')!.value).toBe('second') + }, + ) + }, + ) + test('reactive selection, guards, rest and event binding lifetime', async () => { const results = await renderParity( { @@ -52,6 +80,25 @@ describe('v-match VDOM / Vapor parity', () => { expect(results.vapor.text).toBe(results.vdom.text) }) + test('branch bindings compose with component props and scoped slots', async () => { + await renderParity( + { + Panel: '', + App: ``, + }, + () => ref({ result: { text: 'first' }, suffix: '!' }), + async (data, root) => { + expect(root.textContent).toBe('first:!!') + expect(root.querySelector('div')!.title).toBe('first') + data.value.result.text = 'second' + data.value.suffix = '?' + await nextTick() + expect(root.textContent).toBe('second:??') + expect(root.querySelector('div')!.title).toBe('second') + }, + ) + }) + test('nested match and array rest react to in-place updates', async () => { const results = await renderParity( { From 5071d79304f83ff67f77935fefc631ebc89491d9 Mon Sep 17 00:00:00 2001 From: ubugeeei Date: Wed, 16 Sep 2026 14:57:51 +0900 Subject: [PATCH 3/8] refactor(compiler): clarify match selector evaluation phases --- packages/compiler-core/src/patterns.ts | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/packages/compiler-core/src/patterns.ts b/packages/compiler-core/src/patterns.ts index 9d1963bbcd5..5fe80dcbc71 100644 --- a/packages/compiler-core/src/patterns.ts +++ b/packages/compiler-core/src/patterns.ts @@ -337,6 +337,8 @@ export function generateMatchSelector( 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[] = [] @@ -389,7 +391,13 @@ export function generateMatchSelector( .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 }); }`, + `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};`) } @@ -417,7 +425,16 @@ export function generateMatchSelector( } emit(arm.pattern, root) const result = temp() - return `{ const ${result} = (() => { ${tests.join(' ')} ${copies.join(' ')} { ${declarations.join(' ')} ${arm.guard ? `if (!(${arm.guard.text})) return null;` : ''} return [${index}${arm.bindings.map(b => `, ${b.name}`).join('')}]; } })(); if (${result} !== null) return ${result}; }` + 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}; }` + ) }) return `((${root}) => { ${branches.join(' ')} return [-1]; })(${subject})` } From da1d25060eaab3863fd61d7930cac7428593492d Mon Sep 17 00:00:00 2001 From: ubugeeei Date: Wed, 16 Sep 2026 15:02:11 +0900 Subject: [PATCH 4/8] test(transition): wait for animation frames before duration --- packages/vue/__tests__/e2e/Transition.spec.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/vue/__tests__/e2e/Transition.spec.ts b/packages/vue/__tests__/e2e/Transition.spec.ts index 81bca1806e7..72ab8852d00 100644 --- a/packages/vue/__tests__/e2e/Transition.spec.ts +++ b/packages/vue/__tests__/e2e/Transition.spec.ts @@ -19,7 +19,12 @@ describe('e2e: Transition', () => { const nextTick = () => (window as any).Vue.nextTick() - const transitionFinish = (time = duration) => timeout(time + buffer) + const transitionFinish = async (time = duration) => { + // Vue applies the enter/leave-to classes on the next two animation frames. + // Start the duration wait after those frames, including on busy CI runners. + await nextFrame() + await timeout(time + buffer) + } const classWhenTransitionStart = () => page().evaluate(() => { From 256088bbfcae35fe525742a2d9766cf909e190d8 Mon Sep 17 00:00:00 2001 From: ubugeeei Date: Wed, 16 Sep 2026 15:07:35 +0900 Subject: [PATCH 5/8] test(transition): poll final states in asynchronous cases --- packages/vue/__tests__/e2e/Transition.spec.ts | 41 +++++++++++-------- 1 file changed, 23 insertions(+), 18 deletions(-) diff --git a/packages/vue/__tests__/e2e/Transition.spec.ts b/packages/vue/__tests__/e2e/Transition.spec.ts index 72ab8852d00..29218f7e3f4 100644 --- a/packages/vue/__tests__/e2e/Transition.spec.ts +++ b/packages/vue/__tests__/e2e/Transition.spec.ts @@ -19,12 +19,7 @@ describe('e2e: Transition', () => { const nextTick = () => (window as any).Vue.nextTick() - const transitionFinish = async (time = duration) => { - // Vue applies the enter/leave-to classes on the next two animation frames. - // Start the duration wait after those frames, including on busy CI runners. - await nextFrame() - await timeout(time + buffer) - } + const transitionFinish = (time = duration) => timeout(time + buffer) const classWhenTransitionStart = () => page().evaluate(() => { @@ -1698,17 +1693,21 @@ describe('e2e: Transition', () => { }) await transitionFinish() - expect(await html('#container')).toBe('
CompA
') + await expect.poll(() => html('#container')).toBe('
CompA
') await click('#switchToB') await transitionFinish() await transitionFinish() - expect(await html('#container')).toBe('
CompB
') + await expect + .poll(() => html('#container')) + .toBe('
CompB
') await click('#switchToA') await transitionFinish() await transitionFinish() - expect(await html('#container')).toBe('
CompA
') + await expect + .poll(() => html('#container')) + .toBe('
CompA
') expect(onUnmountedSpyB).toBeCalledTimes(1) }, @@ -2411,21 +2410,27 @@ describe('e2e: Transition', () => { }) await transitionFinish(60) - expect(await html('#container')).toBe( - '
1
', - ) + await expect + .poll(() => html('#container')) + .toBe( + '
1
', + ) await click('button') await transitionFinish(60) - expect(await html('#container')).toBe( - '
', - ) + await expect + .poll(() => html('#container')) + .toBe( + '
', + ) await click('button') await transitionFinish(60) - expect(await html('#container')).toBe( - '
3
', - ) + await expect + .poll(() => html('#container')) + .toBe( + '
3
', + ) }, E2E_TIMEOUT, ) From 5f7075b286abf48aea626d97b8f28c422df3fa1c Mon Sep 17 00:00:00 2001 From: ubugeeei Date: Wed, 16 Sep 2026 16:21:06 +0900 Subject: [PATCH 6/8] test(compiler): cover patterned template binding scopes --- .../compiler-core/__tests__/patterns.spec.ts | 5 + .../runtime-vapor/__tests__/vMatch.spec.ts | 99 +++++++++++++++++++ packages/vue/__tests__/vMatch.spec.ts | 26 +++++ 3 files changed, 130 insertions(+) diff --git a/packages/compiler-core/__tests__/patterns.spec.ts b/packages/compiler-core/__tests__/patterns.spec.ts index 6d011b9b138..cdb9b5cc093 100644 --- a/packages/compiler-core/__tests__/patterns.spec.ts +++ b/packages/compiler-core/__tests__/patterns.spec.ts @@ -71,6 +71,11 @@ describe('RFC 823 pattern grammar', () => { '{ 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', diff --git a/packages/runtime-vapor/__tests__/vMatch.spec.ts b/packages/runtime-vapor/__tests__/vMatch.spec.ts index 250a07241e1..bebb3df77ff 100644 --- a/packages/runtime-vapor/__tests__/vMatch.spec.ts +++ b/packages/runtime-vapor/__tests__/vMatch.spec.ts @@ -116,4 +116,103 @@ describe('v-match VDOM / Vapor parity', () => { expect(results.vdom.text).toBe('empty') expect(results.vapor.text).toBe('empty') }) + + test('arm bindings shadow setup, loop and slot bindings without leaking', async () => { + await renderParity( + { + Panel: '', + App: ``, + }, + () => + ref({ + rows: [ + { tag: 'setup', value: 'arm' }, + { tag: 'setup', value: 'other' }, + ], + slot: 'slot', + }), + async (data, root) => { + const text = () => root.textContent!.replace(/\s/g, '') + expect(text()).toBe('arm12slotarmarmarmotherothersetup') + expect(root.querySelector('section')!.title).toBe('arm') + data.value.slot = 'updated' + data.value.rows[0].value = 'fallback' + await nextTick() + expect(text()).toBe('fallbackfallbackotherothersetup') + }, + ) + }) + + test('$event is local to an inline handler and shadows an arm binding', async () => { + await renderParity( + { + App: ``, + }, + () => ref({ value: 'arm', clicked: [] as string[] }), + (data, root) => { + const button = root.querySelector('button')! + expect(button.title).toBe('arm') + expect(button.textContent).toBe('arm') + button.click() + expect(data.value.clicked).toEqual(['click']) + }, + ) + }) + + test('props and component slot parameters occupy different arm scopes', async () => { + await renderParity( + { + Panel: ``, + Child: ``, + App: '', + }, + () => ref({ row: { value: 'arm' }, prop: 'prop', slot: 'slot' }), + async (data, root) => { + expect(root.querySelector('div')!.title).toBe('arm') + expect(root.querySelector('b')!.textContent).toBe('slot') + expect(root.querySelector('p')!.textContent).toBe('prop') + data.value.prop = 'updated' + data.value.slot = 'nested' + await nextTick() + expect(root.querySelector('div')!.title).toBe('arm') + expect(root.querySelector('b')!.textContent).toBe('nested') + expect(root.querySelector('p')!.textContent).toBe('updated') + }, + ) + }) }) diff --git a/packages/vue/__tests__/vMatch.spec.ts b/packages/vue/__tests__/vMatch.spec.ts index aee7e731ddc..6f0e79d2a33 100644 --- a/packages/vue/__tests__/vMatch.spec.ts +++ b/packages/vue/__tests__/vMatch.spec.ts @@ -148,4 +148,30 @@ describe('v-match rendering', () => { expect(root.textContent).toBe('one:2,4empty') app.unmount() }) + + test('SSR and hydration preserve shadowed loop bindings and props', async () => { + const rows = ref([{ value: 'arm' }]) + const App = { + ...component( + `

{{ value.value }}

{{ value }}
`, + () => ({ rows }), + ), + props: ['value'], + } + const root = document.createElement('div') + root.innerHTML = await SSR.renderToString( + createSSRApp(App, { value: 'prop' }), + ) + expect(root.textContent).toBe('12armarmprop') + const section = root.querySelector('section')! + expect(section.title).toBe('arm') + const app = createSSRApp(App, { value: 'prop' }) + app.mount(root) + expect(root.querySelector('section')).toBe(section) + rows.value[0].value = 'updated' + await nextTick() + expect(root.textContent).toBe('12updatedupdatedprop') + expect(section.title).toBe('updated') + app.unmount() + }) }) From 83c5fcc2f27b1e47b9c11dfb01e542fa24c10710 Mon Sep 17 00:00:00 2001 From: ubugeeei Date: Wed, 16 Sep 2026 16:27:37 +0900 Subject: [PATCH 7/8] test(compiler): cover shadowed loop sources in match arms --- packages/runtime-vapor/__tests__/vMatch.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/runtime-vapor/__tests__/vMatch.spec.ts b/packages/runtime-vapor/__tests__/vMatch.spec.ts index bebb3df77ff..42a567eb62a 100644 --- a/packages/runtime-vapor/__tests__/vMatch.spec.ts +++ b/packages/runtime-vapor/__tests__/vMatch.spec.ts @@ -130,7 +130,7 @@ describe('v-match VDOM / Vapor parity', () => {