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
35 changes: 35 additions & 0 deletions packages/runtime-core/__tests__/hydration.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2109,6 +2109,41 @@ describe('SSR hydration', () => {
expect(root.innerHTML).toBe('<div>bar</div>')
})

// #14635
test('duplicate component VNode rendered after hydration in SSR mode', async () => {
const MyLink = defineComponent({
setup() {
return () => h('a', { href: '#' }, 'link')
},
})

const DuplicateTest = defineComponent({
setup() {
return () => {
const link = h(MyLink)
return h('p', ['Click this ', link, ' and that ', link, '.'])
}
},
})

const show = ref(false)
const App = defineComponent({
setup() {
return () => [show.value ? h(DuplicateTest) : null]
},
})

const container = document.createElement('div')
container.innerHTML = await renderToString(h(App))
createSSRApp(App).mount(container)
// toggle to show DuplicateTest (mounted fresh, not hydrated)
show.value = true
await nextTick()
expect(container.innerHTML).toContain(
'<p>Click this <a href="#">link</a> and that <a href="#">link</a>.</p>',
)
})

describe('mismatch handling', () => {
test('text node', () => {
const { container } = mountWithHydration(`foo`, () => 'bar')
Expand Down
10 changes: 8 additions & 2 deletions packages/runtime-core/__tests__/vnode.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,12 +203,18 @@ describe('vnode', () => {
const vnode = createVNode('div')
expect(normalizeVNode(vnode)).toBe(vnode)

// mounted VNode -> cloned VNode
// mounted VNode -> cloned VNode with el/anchor reset
const mounted = createVNode('div')
mounted.el = {}
const normalized = normalizeVNode(mounted)
expect(normalized).not.toBe(mounted)
expect(normalized).toEqual(mounted)
// el and anchor are reset so the clone is treated as fresh during mount
expect(normalized.el).toBe(null)
expect(normalized.anchor).toBe(null)
// everything else should match the original
expect({ ...normalized, el: mounted.el, anchor: mounted.anchor }).toEqual(
mounted,
)

// primitive types
expect(normalizeVNode('foo')).toMatchObject({ type: Text, children: `foo` })
Expand Down
15 changes: 12 additions & 3 deletions packages/runtime-core/src/vnode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -809,10 +809,19 @@ export function normalizeVNode(child: VNodeChild): VNode {

// optimized normalization for template-compiled render fns
export function cloneIfMounted(child: VNode): VNode {
return (child.el === null && child.patchFlag !== PatchFlags.CACHED) ||
if (
(child.el === null && child.patchFlag !== PatchFlags.CACHED) ||
child.memo
? child
: cloneVNode(child)
) {
return child
}
const cloned = cloneVNode(child)
// reset el so that the cloned vnode is treated as fresh during mount
// this is important in SSR mode where a non-null el causes the renderer
// to enter the hydration path instead of the normal mount path (#14635)
cloned.el = null
cloned.anchor = null
return cloned
}

export function normalizeChildren(vnode: VNode, children: unknown): void {
Expand Down