Skip to content
Open
Show file tree
Hide file tree
Changes from 11 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
158 changes: 158 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,162 @@ 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>\`)
}"
`)
})

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, ssrFilterTransitionProps: _ssrFilterTransitionProps } = require("vue/server-renderer")

return function ssrRender(_ctx, _push, _parent, _attrs) {
_push(\`<ul\${_ssrRenderAttrs(_ssrFilterTransitionProps(_mergeProps(_ctx.transitionProps, { class: "container" }, _attrs)))}></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, ssrFilterTransitionProps: _ssrFilterTransitionProps } = require("vue/server-renderer")

return function ssrRender(_ctx, _push, _parent, _attrs) {
_push(\`<div\${_ssrRenderAttrs(_ssrFilterTransitionProps(_mergeProps({ id: 'test', 'data-value': 42, name: 'fade', moveClass: 'move', class: 'dynamic' }, _attrs)))}></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, ssrFilterTransitionProps: _ssrFilterTransitionProps } = require("vue/server-renderer")

return function ssrRender(_ctx, _push, _parent, _attrs) {
_push(\`<ul\${_ssrRenderAttrs(_ssrFilterTransitionProps(_mergeProps(_ctx.extraProps, {
class: "mixed",
"data-test": "static"
}, _attrs)))}></ul>\`)
}"
`)
})
})
4 changes: 4 additions & 0 deletions packages/compiler-ssr/src/runtimeHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ export const SSR_RENDER_TELEPORT: unique symbol = Symbol(`ssrRenderTeleport`)
export const SSR_RENDER_SUSPENSE: unique symbol = Symbol(`ssrRenderSuspense`)
export const SSR_GET_DIRECTIVE_PROPS: unique symbol =
Symbol(`ssrGetDirectiveProps`)
export const SSR_FILTER_TRANSITION_PROPS: unique symbol = Symbol(
`ssrFilterTransitionProps`,
)

export const ssrHelpers: Record<symbol, string> = {
[SSR_INTERPOLATE]: `ssrInterpolate`,
Expand All @@ -48,6 +51,7 @@ export const ssrHelpers: Record<symbol, string> = {
[SSR_RENDER_TELEPORT]: `ssrRenderTeleport`,
[SSR_RENDER_SUSPENSE]: `ssrRenderSuspense`,
[SSR_GET_DIRECTIVE_PROPS]: `ssrGetDirectiveProps`,
[SSR_FILTER_TRANSITION_PROPS]: `ssrFilterTransitionProps`,
}

// Note: these are helpers imported from @vue/server-renderer
Expand Down
118 changes: 115 additions & 3 deletions packages/compiler-ssr/src/transforms/ssrTransformTransitionGroup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,73 @@ import {
createCallExpression,
findProp,
} from '@vue/compiler-dom'
import { SSR_RENDER_ATTRS } from '../runtimeHelpers'
import { hasOwn } from '@vue/shared'
import {
SSR_FILTER_TRANSITION_PROPS,
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 {
tag: AttributeNode | DirectiveNode
propsExp: string | JSChildNode | null
scopeId: string | null
sawObjectVBind: boolean
}

// phase 1: build props
Expand All @@ -32,7 +86,58 @@ export function ssrTransformTransitionGroup(
return (): void => {
const tag = findProp(node, 'tag')
if (tag) {
const otherProps = node.props.filter(p => p !== tag)
// Track whether we saw object v-bind (v-bind without argument)
let sawObjectVBind = false

// 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 &&
p.exp &&
p.exp.type === NodeTypes.SIMPLE_EXPRESSION &&
p.exp.content !== '_attrs'
) {
// Object v-bind (v-bind="props") - only count user-written bindings
// Exclude compiler-generated _attrs binding
sawObjectVBind = true
return true // Keep the object v-bind directive
}
}

return true
})
const { props, directives } = buildProps(
node,
context,
Expand All @@ -43,14 +148,21 @@ export function ssrTransformTransitionGroup(
)
let propsExp = null
if (props || directives.length) {
const ssrPropsExp = buildSSRProps(props, directives, context)
propsExp = createCallExpression(context.helper(SSR_RENDER_ATTRS), [
buildSSRProps(props, directives, context),
sawObjectVBind
? createCallExpression(
context.helper(SSR_FILTER_TRANSITION_PROPS),
[ssrPropsExp],
)
: ssrPropsExp,
])
}
wipMap.set(node, {
tag,
propsExp,
scopeId: context.scopeId || null,
sawObjectVBind,
})
}
}
Expand Down
30 changes: 30 additions & 0 deletions packages/server-renderer/src/helpers/ssrRenderAttrs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,3 +115,33 @@ 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())
}

export function ssrFilterTransitionProps(
props: Record<string, unknown>,
): Record<string, unknown> {
const filtered: Record<string, unknown> = {}
for (const key in props) {
// Filter out transition-specific props (both camelCase and kebab-case)
if (
!transitionPropsToFilter(key) &&
!transitionPropsToFilter(kebabToCamel(key))
) {
filtered[key] = props[key]
}
}
return filtered
}
1 change: 1 addition & 0 deletions packages/server-renderer/src/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export {
ssrRenderAttrs,
ssrRenderAttr,
ssrRenderDynamicAttr,
ssrFilterTransitionProps,
} from './helpers/ssrRenderAttrs'
export { ssrInterpolate } from './helpers/ssrInterpolate'
export { ssrRenderList } from './helpers/ssrRenderList'
Expand Down