Skip to content
Open
Show file tree
Hide file tree
Changes from 12 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
168 changes: 163 additions & 5 deletions packages/compiler-ssr/__tests__/ssrTransitionGroup.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ describe('transition-group', () => {
"const { ssrRenderAttrs: _ssrRenderAttrs, ssrRenderList: _ssrRenderList } = require("vue/server-renderer")

return function ssrRender(_ctx, _push, _parent, _attrs) {
_push(\`<ul\${_ssrRenderAttrs(_attrs)}>\`)
_push(\`<ul\${_ssrRenderAttrs(_attrs, "ul", true)}>\`)
_ssrRenderList(_ctx.list, (i) => {
_push(\`<div></div>\`)
})
Expand All @@ -48,7 +48,7 @@ describe('transition-group', () => {
"const { ssrRenderAttrs: _ssrRenderAttrs, ssrRenderList: _ssrRenderList } = require("vue/server-renderer")

return function ssrRender(_ctx, _push, _parent, _attrs) {
_push(\`<ul\${_ssrRenderAttrs(_attrs)}>\`)
_push(\`<ul\${_ssrRenderAttrs(_attrs, "ul", true)}>\`)
_ssrRenderList(_ctx.list, (i) => {
_push(\`<div></div>\`)
})
Expand All @@ -70,7 +70,7 @@ describe('transition-group', () => {
"const { ssrRenderAttrs: _ssrRenderAttrs, ssrRenderList: _ssrRenderList } = require("vue/server-renderer")

return function ssrRender(_ctx, _push, _parent, _attrs) {
_push(\`<ul\${_ssrRenderAttrs(_attrs)}>\`)
_push(\`<ul\${_ssrRenderAttrs(_attrs, "ul", true)}>\`)
_ssrRenderList(_ctx.list, (i) => {
_push(\`<div></div>\`)
})
Expand All @@ -91,7 +91,7 @@ describe('transition-group', () => {
_push(\`<\${
_ctx.someTag
}\${
_ssrRenderAttrs(_attrs)
_ssrRenderAttrs(_attrs, _ctx.someTag, true)
}>\`)
_ssrRenderList(_ctx.list, (i) => {
_push(\`<div></div>\`)
Expand Down Expand Up @@ -143,7 +143,165 @@ describe('transition-group', () => {
_push(\`<ul\${_ssrRenderAttrs(_mergeProps({
class: "red",
id: "ok"
}, _attrs))}></ul>\`)
}, _attrs), "ul", true)}></ul>\`)
}"
`)
})

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", true)}></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", true)}></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", true)}></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", true)}></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", true)}></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", true)}></div>\`)
}"
`)
})

test('object v-bind with mixed valid and transition props', () => {
expect(
compile(
`<transition-group tag="ul" v-bind="transitionProps" 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(\`<ul\${_ssrRenderAttrs(_mergeProps(_ctx.transitionProps, { class: "container" }, _attrs), "ul", true)}></ul>\`)
}"
`)
})

test('object v-bind filters runtime computed transition props', () => {
expect(
compile(
`<transition-group tag="div" v-bind="{ id: 'test', 'data-value': 42, name: 'fade', moveClass: 'move', class: 'dynamic' }">
</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({ id: 'test', 'data-value': 42, name: 'fade', moveClass: 'move', class: 'dynamic' }, _attrs), "div", true)}></div>\`)
}"
`)
})

test('mixed single prop bindings and object v-bind', () => {
expect(
compile(
`<transition-group tag="ul" :name="transitionName" v-bind="extraProps" class="mixed" data-test="static">
</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(_ctx.extraProps, {
class: "mixed",
"data-test": "static"
}, _attrs), "ul", true)}></ul>\`)
}"
`)
})
Expand Down
106 changes: 104 additions & 2 deletions packages/compiler-ssr/src/transforms/ssrTransformTransitionGroup.ts
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,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,
Expand All @@ -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, {
Expand All @@ -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}`)
Expand Down Expand Up @@ -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}`)
Expand All @@ -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)
}
}
Loading