Skip to content
Open
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 107 additions & 0 deletions packages/compiler-ssr/__tests__/ssrTransitionGroup.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,4 +147,111 @@ describe('transition-group', () => {
}"
`)
})

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(
`<transition-group tag="ul" name="fade" appear="true" class="container" data-test="value">
</transition-group>`,
).code,
).toMatchInlineSnapshot(`
"const { mergeProps: _mergeProps } = require("vue")
const { ssrRenderAttrs: _ssrRenderAttrs } = require("vue/server-renderer")

return function ssrRender(_ctx, _push, _parent, _attrs) {
_push(\`<ul\${_ssrRenderAttrs(_mergeProps({
class: "container",
"data-test": "value"
}, _attrs))}></ul>\`)
}"
`)
})

test('filters out transition-specific props', () => {
expect(
compile(
`<transition-group tag="ul" name="fade" mode="out-in" appear :duration="300" enter-from-class="fade-enter-from" enter-active-class="fade-enter-active" enter-to-class="fade-enter-to" leave-from-class="fade-leave-from" leave-active-class="fade-leave-active" leave-to-class="fade-leave-to" appear-from-class="fade-appear-from" appear-active-class="fade-appear-active" appear-to-class="fade-appear-to" class="container" id="list">
</transition-group>`,
).code,
).toMatchInlineSnapshot(`
"const { mergeProps: _mergeProps } = require("vue")
const { ssrRenderAttrs: _ssrRenderAttrs } = require("vue/server-renderer")

return function ssrRender(_ctx, _push, _parent, _attrs) {
_push(\`<ul\${_ssrRenderAttrs(_mergeProps({
class: "container",
id: "list"
}, _attrs))}></ul>\`)
}"
`)
})

test('filters out moveClass prop', () => {
expect(
compile(
`<transition-group tag="div" move-class="move-transition" class="list">
</transition-group>`,
).code,
).toMatchInlineSnapshot(`
"const { mergeProps: _mergeProps } = require("vue")
const { ssrRenderAttrs: _ssrRenderAttrs } = require("vue/server-renderer")

return function ssrRender(_ctx, _push, _parent, _attrs) {
_push(\`<div\${_ssrRenderAttrs(_mergeProps({ class: "list" }, _attrs))}></div>\`)
}"
`)
})

test('filters out dynamic transition props', () => {
expect(
compile(
`<transition-group tag="ul" :name="transitionName" :mode="transitionMode" :appear="shouldAppear" class="dynamic-list" data-test="true">
</transition-group>`,
).code,
).toMatchInlineSnapshot(`
"const { mergeProps: _mergeProps } = require("vue")
const { ssrRenderAttrs: _ssrRenderAttrs } = require("vue/server-renderer")

return function ssrRender(_ctx, _push, _parent, _attrs) {
_push(\`<ul\${_ssrRenderAttrs(_mergeProps({
class: "dynamic-list",
"data-test": "true"
}, _attrs))}></ul>\`)
}"
`)
})

test('filters out transition event handlers', () => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Event handlers are not included in the SSR output, so this test is redundant.

see ssr output in Playground

expect(
compile(
`<transition-group tag="div" @before-enter="onBeforeEnter" @enter="onEnter" @after-enter="onAfterEnter" @enter-cancelled="onEnterCancelled" @before-leave="onBeforeLeave" @leave="onLeave" @after-leave="onAfterLeave" @leave-cancelled="onLeaveCancelled" @before-appear="onBeforeAppear" @appear="onAppear" @after-appear="onAfterAppear" @appear-cancelled="onAppearCancelled" @click="onClick" class="events">
</transition-group>`,
).code,
).toMatchInlineSnapshot(`
"const { mergeProps: _mergeProps } = require("vue")
const { ssrRenderAttrs: _ssrRenderAttrs } = require("vue/server-renderer")

return function ssrRender(_ctx, _push, _parent, _attrs) {
_push(\`<div\${_ssrRenderAttrs(_mergeProps({ class: "events" }, _attrs))}></div>\`)
}"
`)
})

test('filters out all transition props including empty values', () => {
expect(
compile(
`<transition-group tag="div" appear="" persisted="" css="true" type="transition" :duration="500" move-class="custom-move" enter-from-class="custom-enter-from" enter-active-class="custom-enter-active" enter-to-class="custom-enter-to" leave-from-class="custom-leave-from" leave-active-class="custom-leave-active" leave-to-class="custom-leave-to" appear-from-class="custom-appear-from" appear-active-class="custom-appear-active" appear-to-class="custom-appear-to" class="container">
</transition-group>`,
).code,
).toMatchInlineSnapshot(`
"const { mergeProps: _mergeProps } = require("vue")
const { ssrRenderAttrs: _ssrRenderAttrs } = require("vue/server-renderer")

return function ssrRender(_ctx, _push, _parent, _attrs) {
_push(\`<div\${_ssrRenderAttrs(_mergeProps({ class: "container" }, _attrs))}></div>\`)
}"
`)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,63 @@ import {
createCallExpression,
findProp,
} from '@vue/compiler-dom'
import { hasOwn } from '@vue/shared'
import { SSR_RENDER_ATTRS } from '../runtimeHelpers'
import {
type SSRTransformContext,
processChildren,
} 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<ComponentNode, WIPEntry>()

interface WIPEntry {
Expand All @@ -32,7 +82,49 @@ 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
} else if (!p.arg) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would be a breaking change, considering this scenario where the class was removed.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This filtering should be done at ssr runtime, for example, by adding an extra parameter to _ssrRenderAttrs: _ssrRenderAttrs(attrs, tag, isTransition). If isTransition = true, filter out the transition-specific props.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let me have a try.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This filtering should be done at ssr runtime, for example, by adding an extra parameter to _ssrRenderAttrs: _ssrRenderAttrs(attrs, tag, isTransition). If isTransition = true, filter out the transition-specific props.

I've dealt with it.

// v-bind without argument (e.g., v-bind="props") - filter out entirely
// since it may contain transition-specific props that should not be rendered
return false
}
}

return true
})
const { props, directives } = buildProps(
node,
context,
Expand Down