diff --git a/packages/compiler-ssr/__tests__/ssrTransitionGroup.spec.ts b/packages/compiler-ssr/__tests__/ssrTransitionGroup.spec.ts index 82122e621c7..b23d6020491 100644 --- a/packages/compiler-ssr/__tests__/ssrTransitionGroup.spec.ts +++ b/packages/compiler-ssr/__tests__/ssrTransitionGroup.spec.ts @@ -29,7 +29,7 @@ describe('transition-group', () => { "const { ssrRenderAttrs: _ssrRenderAttrs, ssrRenderList: _ssrRenderList } = require("vue/server-renderer") return function ssrRender(_ctx, _push, _parent, _attrs) { - _push(\`\`) + _push(\`\`) _ssrRenderList(_ctx.list, (i) => { _push(\`
\`) }) @@ -48,7 +48,7 @@ describe('transition-group', () => { "const { ssrRenderAttrs: _ssrRenderAttrs, ssrRenderList: _ssrRenderList } = require("vue/server-renderer") return function ssrRender(_ctx, _push, _parent, _attrs) { - _push(\`\`) + _push(\`\`) _ssrRenderList(_ctx.list, (i) => { _push(\`
\`) }) @@ -70,7 +70,7 @@ describe('transition-group', () => { "const { ssrRenderAttrs: _ssrRenderAttrs, ssrRenderList: _ssrRenderList } = require("vue/server-renderer") return function ssrRender(_ctx, _push, _parent, _attrs) { - _push(\`\`) + _push(\`\`) _ssrRenderList(_ctx.list, (i) => { _push(\`
\`) }) @@ -91,7 +91,7 @@ describe('transition-group', () => { _push(\`<\${ _ctx.someTag }\${ - _ssrRenderAttrs(_attrs) + _ssrRenderAttrs(_attrs, _ctx.someTag, true) }>\`) _ssrRenderList(_ctx.list, (i) => { _push(\`
\`) @@ -143,7 +143,167 @@ describe('transition-group', () => { _push(\`\`) + }, _attrs), "ul", true)}>\`) + }" + `) + }) + + test('transition props should NOT fallthrough (runtime should handle this)', () => { + // This test verifies that if runtime fallthrough is working correctly, + // SSR should still filter out transition props for clean HTML + expect( + compile( + ` + `, + ).code, + ).toMatchInlineSnapshot(` + "const { mergeProps: _mergeProps } = require("vue") + const { ssrRenderAttrs: _ssrRenderAttrs } = require("vue/server-renderer") + + return function ssrRender(_ctx, _push, _parent, _attrs) { + _push(\`\`) + }" + `) + }) + + test('filters out transition-specific props', () => { + expect( + compile( + ` + `, + ).code, + ).toMatchInlineSnapshot(` + "const { mergeProps: _mergeProps } = require("vue") + const { ssrRenderAttrs: _ssrRenderAttrs } = require("vue/server-renderer") + + return function ssrRender(_ctx, _push, _parent, _attrs) { + _push(\`\`) + }" + `) + }) + + test('filters out moveClass prop', () => { + expect( + compile( + ` + `, + ).code, + ).toMatchInlineSnapshot(` + "const { mergeProps: _mergeProps } = require("vue") + const { ssrRenderAttrs: _ssrRenderAttrs } = require("vue/server-renderer") + + return function ssrRender(_ctx, _push, _parent, _attrs) { + _push(\`\`) + }" + `) + }) + + test('filters out dynamic transition props', () => { + expect( + compile( + ` + `, + ).code, + ).toMatchInlineSnapshot(` + "const { mergeProps: _mergeProps } = require("vue") + const { ssrRenderAttrs: _ssrRenderAttrs } = require("vue/server-renderer") + + return function ssrRender(_ctx, _push, _parent, _attrs) { + _push(\`\`) + }" + `) + }) + + test('event handlers are omitted in SSR (not transition-specific)', () => { + // This test verifies that event handlers are filtered out during SSR compilation, + // not because of transition filtering but because SSR skips event listeners entirely + expect( + compile( + ` + `, + ).code, + ).toMatchInlineSnapshot(` + "const { mergeProps: _mergeProps } = require("vue") + const { ssrRenderAttrs: _ssrRenderAttrs } = require("vue/server-renderer") + + return function ssrRender(_ctx, _push, _parent, _attrs) { + _push(\`\`) + }" + `) + }) + + test('filters out all transition props including empty values', () => { + expect( + compile( + ` + `, + ).code, + ).toMatchInlineSnapshot(` + "const { mergeProps: _mergeProps } = require("vue") + const { ssrRenderAttrs: _ssrRenderAttrs } = require("vue/server-renderer") + + return function ssrRender(_ctx, _push, _parent, _attrs) { + _push(\`\`) + }" + `) + }) + + test('object v-bind with mixed valid and transition props', () => { + expect( + compile( + ` + `, + ).code, + ).toMatchInlineSnapshot(` + "const { mergeProps: _mergeProps } = require("vue") + const { ssrRenderAttrs: _ssrRenderAttrs } = require("vue/server-renderer") + + return function ssrRender(_ctx, _push, _parent, _attrs) { + _push(\`\`) + }" + `) + }) + + test('object v-bind filters runtime computed transition props', () => { + expect( + compile( + ` + `, + ).code, + ).toMatchInlineSnapshot(` + "const { mergeProps: _mergeProps } = require("vue") + const { ssrRenderAttrs: _ssrRenderAttrs } = require("vue/server-renderer") + + return function ssrRender(_ctx, _push, _parent, _attrs) { + _push(\`\`) + }" + `) + }) + + test('mixed single prop bindings and object v-bind', () => { + expect( + compile( + ` + `, + ).code, + ).toMatchInlineSnapshot(` + "const { mergeProps: _mergeProps } = require("vue") + const { ssrRenderAttrs: _ssrRenderAttrs } = require("vue/server-renderer") + + return function ssrRender(_ctx, _push, _parent, _attrs) { + _push(\`\`) }" `) }) diff --git a/packages/compiler-ssr/src/transforms/ssrTransformTransitionGroup.ts b/packages/compiler-ssr/src/transforms/ssrTransformTransitionGroup.ts index 27ddebec103..8a9381c697b 100644 --- a/packages/compiler-ssr/src/transforms/ssrTransformTransitionGroup.ts +++ b/packages/compiler-ssr/src/transforms/ssrTransformTransitionGroup.ts @@ -9,6 +9,7 @@ import { createCallExpression, findProp, } from '@vue/compiler-dom' +import { hasOwn } from '@vue/shared' import { SSR_RENDER_ATTRS } from '../runtimeHelpers' import { type SSRTransformContext, @@ -16,6 +17,55 @@ import { } from '../ssrCodegenTransform' import { buildSSRProps } from './ssrTransformElement' +// Import transition props validators from the runtime +const TransitionPropsValidators = (() => { + // Re-create the TransitionPropsValidators structure that's used at runtime + // This mirrors the logic from @vue/runtime-dom/src/components/Transition.ts + const BaseTransitionPropsValidators = { + mode: String, + appear: Boolean, + persisted: Boolean, + onBeforeEnter: [Function, Array], + onEnter: [Function, Array], + onAfterEnter: [Function, Array], + onEnterCancelled: [Function, Array], + onBeforeLeave: [Function, Array], + onLeave: [Function, Array], + onAfterLeave: [Function, Array], + onLeaveCancelled: [Function, Array], + onBeforeAppear: [Function, Array], + onAppear: [Function, Array], + onAfterAppear: [Function, Array], + onAppearCancelled: [Function, Array], + } + + const DOMTransitionPropsValidators = { + name: String, + type: String, + css: { type: Boolean, default: true }, + duration: [String, Number, Object], + enterFromClass: String, + enterActiveClass: String, + enterToClass: String, + appearFromClass: String, + appearActiveClass: String, + appearToClass: String, + leaveFromClass: String, + leaveActiveClass: String, + leaveToClass: String, + } + + return { + ...BaseTransitionPropsValidators, + ...DOMTransitionPropsValidators, + } +})() + +// Helper function to convert kebab-case to camelCase +function kebabToCamel(str: string): string { + return str.replace(/-([a-z])/g, (_, char) => char.toUpperCase()) +} + const wipMap = new WeakMap() interface WIPEntry { @@ -32,7 +82,45 @@ export function ssrTransformTransitionGroup( return (): void => { const tag = findProp(node, 'tag') if (tag) { - const otherProps = node.props.filter(p => p !== tag) + // Filter out all transition-related private props when processing TransitionGroup attributes + const otherProps = node.props.filter(p => { + // Exclude tag (already handled separately) + if (p === tag) { + return false + } + + // Exclude all transition-related attributes and TransitionGroup-specific attributes + // This logic mirrors the runtime TransitionGroup attribute filtering logic + if (p.type === NodeTypes.ATTRIBUTE) { + // Static attributes: check attribute name (supports kebab-case to camelCase conversion) + const propName = p.name + const camelCaseName = kebabToCamel(propName) + const shouldFilter = + hasOwn(TransitionPropsValidators, propName) || + hasOwn(TransitionPropsValidators, camelCaseName) || + propName === 'moveClass' || + propName === 'move-class' + return !shouldFilter + } else if (p.type === NodeTypes.DIRECTIVE && p.name === 'bind') { + // Dynamic attributes: check bound attribute name + if ( + p.arg && + p.arg.type === NodeTypes.SIMPLE_EXPRESSION && + p.arg.isStatic + ) { + const argName = p.arg.content + const camelCaseArgName = kebabToCamel(argName) + const shouldFilter = + hasOwn(TransitionPropsValidators, argName) || + hasOwn(TransitionPropsValidators, camelCaseArgName) || + argName === 'moveClass' || + argName === 'move-class' + return !shouldFilter + } + } + + return true + }) const { props, directives } = buildProps( node, context, @@ -45,6 +133,10 @@ export function ssrTransformTransitionGroup( if (props || directives.length) { propsExp = createCallExpression(context.helper(SSR_RENDER_ATTRS), [ buildSSRProps(props, directives, context), + tag.type === NodeTypes.ATTRIBUTE + ? `"${tag.value!.content}"` + : tag.exp!, + `true`, // isTransition flag ]) } wipMap.set(node, { @@ -70,6 +162,11 @@ export function ssrProcessTransitionGroup( context.pushStringPart(tag.exp!) if (propsExp) { context.pushStringPart(propsExp) + } else { + // No component props, but we still need to handle _attrs with transition filtering + context.pushStringPart(`\${_ssrRenderAttrs(_attrs, `) + context.pushStringPart(tag.exp!) + context.pushStringPart(`, true)}`) } if (scopeId) { context.pushStringPart(` ${scopeId}`) @@ -103,6 +200,11 @@ export function ssrProcessTransitionGroup( context.pushStringPart(`<${tag.value!.content}`) if (propsExp) { context.pushStringPart(propsExp) + } else { + // No component props, but we still need to handle _attrs with transition filtering + context.pushStringPart( + `\${_ssrRenderAttrs(_attrs, "${tag.value!.content}", true)}`, + ) } if (scopeId) { context.pushStringPart(` ${scopeId}`) @@ -112,7 +214,7 @@ export function ssrProcessTransitionGroup( context.pushStringPart(``) } } else { - // fragment + // fragment - no tag, just render children processChildren(node, context, true, true, true) } } diff --git a/packages/server-renderer/__tests__/ssrRenderAttrs.spec.ts b/packages/server-renderer/__tests__/ssrRenderAttrs.spec.ts index 984387bb864..830bc496426 100644 --- a/packages/server-renderer/__tests__/ssrRenderAttrs.spec.ts +++ b/packages/server-renderer/__tests__/ssrRenderAttrs.spec.ts @@ -7,6 +7,57 @@ import { import { escapeHtml } from '@vue/shared' describe('ssr: renderAttrs', () => { + test('filters transition props when isTransition is true', () => { + expect( + ssrRenderAttrs( + { + id: 'test', + class: 'container', + name: 'fade', + moveClass: 'move', + 'data-value': 42, + appear: true, + duration: 300, + 'enter-from-class': 'enter', + }, + 'ul', + true, + ), + ).toBe(' id="test" class="container" data-value="42"') + }) + + test('keeps all props when isTransition is false', () => { + expect( + ssrRenderAttrs( + { + id: 'test', + class: 'container', + name: 'fade', + moveClass: 'move', + 'data-value': 42, + }, + 'ul', + false, + ), + ).toBe( + ' id="test" class="container" name="fade" moveclass="move" data-value="42"', + ) + }) + + test('keeps all props when isTransition is undefined', () => { + expect( + ssrRenderAttrs({ + id: 'test', + class: 'container', + name: 'fade', + moveClass: 'move', + 'data-value': 42, + }), + ).toBe( + ' id="test" class="container" name="fade" moveclass="move" data-value="42"', + ) + }) + test('ignore reserved props', () => { expect( ssrRenderAttrs({ diff --git a/packages/server-renderer/src/helpers/ssrRenderAttrs.ts b/packages/server-renderer/src/helpers/ssrRenderAttrs.ts index b082da03fe8..693a37dc89c 100644 --- a/packages/server-renderer/src/helpers/ssrRenderAttrs.ts +++ b/packages/server-renderer/src/helpers/ssrRenderAttrs.ts @@ -27,13 +27,16 @@ const shouldIgnoreProp = /*@__PURE__*/ makeMap( export function ssrRenderAttrs( props: Record, tag?: string, + isTransition?: boolean, ): string { let ret = '' for (const key in props) { if ( shouldIgnoreProp(key) || isOn(key) || - (tag === 'textarea' && key === 'value') + (tag === 'textarea' && key === 'value') || + (isTransition && transitionPropsToFilter(key)) || + (isTransition && transitionPropsToFilter(kebabToCamel(key))) ) { continue } @@ -115,3 +118,17 @@ function ssrResetCssVars(raw: unknown) { } return raw } + +// TransitionGroup transition props that should be filtered in SSR +const transitionPropsToFilter = /*@__PURE__*/ makeMap( + `mode,appear,persisted,onBeforeEnter,onEnter,onAfterEnter,onEnterCancelled,` + + `onBeforeLeave,onLeave,onAfterLeave,onLeaveCancelled,onBeforeAppear,` + + `onAppear,onAfterAppear,onAppearCancelled,name,type,css,duration,` + + `enterFromClass,enterActiveClass,enterToClass,appearFromClass,` + + `appearActiveClass,appearToClass,leaveFromClass,leaveActiveClass,` + + `leaveToClass,moveClass,move-class`, +) + +function kebabToCamel(str: string): string { + return str.replace(/-([a-z])/g, (_, char) => char.toUpperCase()) +}