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(`${tag.value!.content}>`)
}
} 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())
+}