Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
170 changes: 165 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,167 @@ 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('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(
`<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