Skip to content

Commit 5ea8a8a

Browse files
authored
fix(transition/ssr): make transition appear work with SSR (#8859)
close #6951
1 parent 16ecb44 commit 5ea8a8a

File tree

6 files changed

+241
-26
lines changed

6 files changed

+241
-26
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { compile } from '../src'
2+
3+
describe('transition', () => {
4+
test('basic', () => {
5+
expect(compile(`<transition><div>foo</div></transition>`).code)
6+
.toMatchInlineSnapshot(`
7+
"const { ssrRenderAttrs: _ssrRenderAttrs } = require(\\"vue/server-renderer\\")
8+
9+
return function ssrRender(_ctx, _push, _parent, _attrs) {
10+
_push(\`<div\${_ssrRenderAttrs(_attrs)}>foo</div>\`)
11+
}"
12+
`)
13+
})
14+
15+
test('with appear', () => {
16+
expect(compile(`<transition appear><div>foo</div></transition>`).code)
17+
.toMatchInlineSnapshot(`
18+
"const { ssrRenderAttrs: _ssrRenderAttrs } = require(\\"vue/server-renderer\\")
19+
20+
return function ssrRender(_ctx, _push, _parent, _attrs) {
21+
_push(\`<template><div\${_ssrRenderAttrs(_attrs)}>foo</div></template>\`)
22+
}"
23+
`)
24+
})
25+
})

packages/compiler-ssr/src/transforms/ssrTransformComponent.ts

+8-4
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,10 @@ import {
5656
} from './ssrTransformTransitionGroup'
5757
import { isSymbol, isObject, isArray } from '@vue/shared'
5858
import { buildSSRProps } from './ssrTransformElement'
59+
import {
60+
ssrProcessTransition,
61+
ssrTransformTransition
62+
} from './ssrTransformTransition'
5963

6064
// We need to construct the slot functions in the 1st pass to ensure proper
6165
// scope tracking, but the children of each slot cannot be processed until
@@ -99,9 +103,10 @@ export const ssrTransformComponent: NodeTransform = (node, context) => {
99103
if (isSymbol(component)) {
100104
if (component === SUSPENSE) {
101105
return ssrTransformSuspense(node, context)
102-
}
103-
if (component === TRANSITION_GROUP) {
106+
} else if (component === TRANSITION_GROUP) {
104107
return ssrTransformTransitionGroup(node, context)
108+
} else if (component === TRANSITION) {
109+
return ssrTransformTransition(node, context)
105110
}
106111
return // other built-in components: fallthrough
107112
}
@@ -216,9 +221,8 @@ export function ssrProcessComponent(
216221
if ((parent as WIPSlotEntry).type === WIP_SLOT) {
217222
context.pushStringPart(``)
218223
}
219-
// #5351: filter out comment children inside transition
220224
if (component === TRANSITION) {
221-
node.children = node.children.filter(c => c.type !== NodeTypes.COMMENT)
225+
return ssrProcessTransition(node, context)
222226
}
223227
processChildren(node, context)
224228
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import {
2+
ComponentNode,
3+
findProp,
4+
NodeTypes,
5+
TransformContext
6+
} from '@vue/compiler-dom'
7+
import { processChildren, SSRTransformContext } from '../ssrCodegenTransform'
8+
9+
const wipMap = new WeakMap<ComponentNode, Boolean>()
10+
11+
export function ssrTransformTransition(
12+
node: ComponentNode,
13+
context: TransformContext
14+
) {
15+
return () => {
16+
const appear = findProp(node, 'appear', false, true)
17+
wipMap.set(node, !!appear)
18+
}
19+
}
20+
21+
export function ssrProcessTransition(
22+
node: ComponentNode,
23+
context: SSRTransformContext
24+
) {
25+
// #5351: filter out comment children inside transition
26+
node.children = node.children.filter(c => c.type !== NodeTypes.COMMENT)
27+
28+
const appear = wipMap.get(node)
29+
if (appear) {
30+
context.pushStringPart(`<template>`)
31+
processChildren(node, context, false, true)
32+
context.pushStringPart(`</template>`)
33+
} else {
34+
processChildren(node, context, false, true)
35+
}
36+
}

packages/runtime-core/__tests__/hydration.spec.ts

+74-2
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,14 @@ import {
1818
createVNode,
1919
withDirectives,
2020
vModelCheckbox,
21-
renderSlot
21+
renderSlot,
22+
Transition,
23+
createCommentVNode,
24+
vShow
2225
} from '@vue/runtime-dom'
2326
import { renderToString, SSRContext } from '@vue/server-renderer'
24-
import { PatchFlags } from '../../shared/src'
27+
import { PatchFlags } from '@vue/shared'
28+
import { vShowOldKey } from '../../runtime-dom/src/directives/vShow'
2529

2630
function mountWithHydration(html: string, render: () => any) {
2731
const container = document.createElement('div')
@@ -1016,6 +1020,74 @@ describe('SSR hydration', () => {
10161020
expect(`mismatch`).not.toHaveBeenWarned()
10171021
})
10181022

1023+
test('transition appear', () => {
1024+
const { vnode, container } = mountWithHydration(
1025+
`<template><div>foo</div></template>`,
1026+
() =>
1027+
h(
1028+
Transition,
1029+
{ appear: true },
1030+
{
1031+
default: () => h('div', 'foo')
1032+
}
1033+
)
1034+
)
1035+
expect(container.firstChild).toMatchInlineSnapshot(`
1036+
<div
1037+
class="v-enter-from v-enter-active"
1038+
>
1039+
foo
1040+
</div>
1041+
`)
1042+
expect(vnode.el).toBe(container.firstChild)
1043+
expect(`mismatch`).not.toHaveBeenWarned()
1044+
})
1045+
1046+
test('transition appear with v-if', () => {
1047+
const show = false
1048+
const { vnode, container } = mountWithHydration(
1049+
`<template><!----></template>`,
1050+
() =>
1051+
h(
1052+
Transition,
1053+
{ appear: true },
1054+
{
1055+
default: () => (show ? h('div', 'foo') : createCommentVNode(''))
1056+
}
1057+
)
1058+
)
1059+
expect(container.firstChild).toMatchInlineSnapshot('<!---->')
1060+
expect(vnode.el).toBe(container.firstChild)
1061+
expect(`mismatch`).not.toHaveBeenWarned()
1062+
})
1063+
1064+
test('transition appear with v-show', () => {
1065+
const show = false
1066+
const { vnode, container } = mountWithHydration(
1067+
`<template><div style="display: none;">foo</div></template>`,
1068+
() =>
1069+
h(
1070+
Transition,
1071+
{ appear: true },
1072+
{
1073+
default: () =>
1074+
withDirectives(createVNode('div', null, 'foo'), [[vShow, show]])
1075+
}
1076+
)
1077+
)
1078+
expect(container.firstChild).toMatchInlineSnapshot(`
1079+
<div
1080+
class="v-enter-from v-enter-active"
1081+
style="display: none;"
1082+
>
1083+
foo
1084+
</div>
1085+
`)
1086+
expect((container.firstChild as any)[vShowOldKey]).toBe('')
1087+
expect(vnode.el).toBe(container.firstChild)
1088+
expect(`mismatch`).not.toHaveBeenWarned()
1089+
})
1090+
10191091
describe('mismatch handling', () => {
10201092
test('text node', () => {
10211093
const { container } = mountWithHydration(`foo`, () => 'bar')

packages/runtime-core/src/hydration.ts

+85-16
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import { ComponentInternalInstance } from './component'
1515
import { invokeDirectiveHook } from './directives'
1616
import { warn } from './warning'
1717
import { PatchFlags, ShapeFlags, isReservedProp, isOn } from '@vue/shared'
18-
import { RendererInternals } from './renderer'
18+
import { needTransition, RendererInternals } from './renderer'
1919
import { setRef } from './rendererTemplateRef'
2020
import {
2121
SuspenseImpl,
@@ -146,7 +146,17 @@ export function createHydrationFunctions(
146146
break
147147
case Comment:
148148
if (domType !== DOMNodeTypes.COMMENT || isFragmentStart) {
149-
nextNode = onMismatch()
149+
if ((node as Element).tagName.toLowerCase() === 'template') {
150+
const content = (vnode.el! as HTMLTemplateElement).content
151+
.firstChild!
152+
153+
// replace <template> node with inner children
154+
replaceNode(content, node, parentComponent)
155+
vnode.el = node = content
156+
nextNode = nextSibling(node)
157+
} else {
158+
nextNode = onMismatch()
159+
}
150160
} else {
151161
nextNode = nextSibling(node)
152162
}
@@ -196,9 +206,10 @@ export function createHydrationFunctions(
196206
default:
197207
if (shapeFlag & ShapeFlags.ELEMENT) {
198208
if (
199-
domType !== DOMNodeTypes.ELEMENT ||
200-
(vnode.type as string).toLowerCase() !==
201-
(node as Element).tagName.toLowerCase()
209+
(domType !== DOMNodeTypes.ELEMENT ||
210+
(vnode.type as string).toLowerCase() !==
211+
(node as Element).tagName.toLowerCase()) &&
212+
!isTemplateNode(node as Element)
202213
) {
203214
nextNode = onMismatch()
204215
} else {
@@ -217,15 +228,6 @@ export function createHydrationFunctions(
217228
// on its sub-tree.
218229
vnode.slotScopeIds = slotScopeIds
219230
const container = parentNode(node)!
220-
mountComponent(
221-
vnode,
222-
container,
223-
null,
224-
parentComponent,
225-
parentSuspense,
226-
isSVGContainer(container),
227-
optimized
228-
)
229231

230232
// Locate the next node.
231233
if (isFragmentStart) {
@@ -241,6 +243,16 @@ export function createHydrationFunctions(
241243
nextNode = nextSibling(node)
242244
}
243245

246+
mountComponent(
247+
vnode,
248+
container,
249+
null,
250+
parentComponent,
251+
parentSuspense,
252+
isSVGContainer(container),
253+
optimized
254+
)
255+
244256
// #3787
245257
// if component is async, it may get moved / unmounted before its
246258
// inner component is loaded, so we need to give it a placeholder
@@ -307,7 +319,7 @@ export function createHydrationFunctions(
307319
optimized: boolean
308320
) => {
309321
optimized = optimized || !!vnode.dynamicChildren
310-
const { type, props, patchFlag, shapeFlag, dirs } = vnode
322+
const { type, props, patchFlag, shapeFlag, dirs, transition } = vnode
311323
// #4006 for form elements with non-string v-model value bindings
312324
// e.g. <option :value="obj">, <input type="checkbox" :true-value="1">
313325
const forcePatchValue = (type === 'input' && dirs) || type === 'option'
@@ -359,12 +371,40 @@ export function createHydrationFunctions(
359371
if ((vnodeHooks = props && props.onVnodeBeforeMount)) {
360372
invokeVNodeHook(vnodeHooks, parentComponent, vnode)
361373
}
374+
375+
// handle appear transition
376+
let needCallTransitionHooks = false
377+
if (isTemplateNode(el)) {
378+
needCallTransitionHooks =
379+
needTransition(parentSuspense, transition) &&
380+
parentComponent &&
381+
parentComponent.vnode.props &&
382+
parentComponent.vnode.props.appear
383+
384+
const content = (el as HTMLTemplateElement).content
385+
.firstChild as Element
386+
387+
if (needCallTransitionHooks) {
388+
transition!.beforeEnter(content)
389+
}
390+
391+
// replace <template> node with inner children
392+
replaceNode(content, el, parentComponent)
393+
vnode.el = el = content
394+
}
395+
362396
if (dirs) {
363397
invokeDirectiveHook(vnode, null, parentComponent, 'beforeMount')
364398
}
365-
if ((vnodeHooks = props && props.onVnodeMounted) || dirs) {
399+
400+
if (
401+
(vnodeHooks = props && props.onVnodeMounted) ||
402+
dirs ||
403+
needCallTransitionHooks
404+
) {
366405
queueEffectWithSuspense(() => {
367406
vnodeHooks && invokeVNodeHook(vnodeHooks, parentComponent, vnode)
407+
needCallTransitionHooks && transition!.enter(el)
368408
dirs && invokeDirectiveHook(vnode, null, parentComponent, 'mounted')
369409
}, parentSuspense)
370410
}
@@ -582,5 +622,34 @@ export function createHydrationFunctions(
582622
return node
583623
}
584624

625+
const replaceNode = (
626+
newNode: Node,
627+
oldNode: Node,
628+
parentComponent: ComponentInternalInstance | null
629+
): void => {
630+
// replace node
631+
const parentNode = oldNode.parentNode
632+
if (parentNode) {
633+
parentNode.replaceChild(newNode, oldNode)
634+
}
635+
636+
// update vnode
637+
let parent = parentComponent
638+
while (parent) {
639+
if (parent.vnode.el === oldNode) {
640+
parent.vnode.el = newNode
641+
parent.subTree.el = newNode
642+
}
643+
parent = parent.parent
644+
}
645+
}
646+
647+
const isTemplateNode = (node: Element): boolean => {
648+
return (
649+
node.nodeType === DOMNodeTypes.ELEMENT &&
650+
node.tagName.toLowerCase() === 'template'
651+
)
652+
}
653+
585654
return [hydrate, hydrateNode] as const
586655
}

packages/runtime-core/src/renderer.ts

+13-4
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ import { initFeatureFlags } from './featureFlags'
7272
import { isAsyncWrapper } from './apiAsyncComponent'
7373
import { isCompatEnabled } from './compat/compatConfig'
7474
import { DeprecationTypes } from './compat/compatConfig'
75+
import { TransitionHooks } from './components/BaseTransition'
7576

7677
export interface Renderer<HostElement = RendererElement> {
7778
render: RootRenderFunction<HostElement>
@@ -701,10 +702,7 @@ function baseCreateRenderer(
701702
}
702703
// #1583 For inside suspense + suspense not resolved case, enter hook should call when suspense resolved
703704
// #1689 For inside suspense + suspense resolved case, just call it
704-
const needCallTransitionHooks =
705-
(!parentSuspense || (parentSuspense && !parentSuspense.pendingBranch)) &&
706-
transition &&
707-
!transition.persisted
705+
const needCallTransitionHooks = needTransition(parentSuspense, transition)
708706
if (needCallTransitionHooks) {
709707
transition!.beforeEnter(el)
710708
}
@@ -2365,6 +2363,17 @@ function toggleRecurse(
23652363
effect.allowRecurse = update.allowRecurse = allowed
23662364
}
23672365

2366+
export function needTransition(
2367+
parentSuspense: SuspenseBoundary | null,
2368+
transition: TransitionHooks | null
2369+
) {
2370+
return (
2371+
(!parentSuspense || (parentSuspense && !parentSuspense.pendingBranch)) &&
2372+
transition &&
2373+
!transition.persisted
2374+
)
2375+
}
2376+
23682377
/**
23692378
* #1156
23702379
* When a component is HMR-enabled, we need to make sure that all static nodes

0 commit comments

Comments
 (0)