From b00619dd622f9d811cdf98503262895ed82c4fec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20=C4=8Cern=C3=BD?= Date: Thu, 26 Mar 2026 13:54:03 +0100 Subject: [PATCH] fix(ssr): ensure duplicate component VNodes render after hydration --- .../runtime-core/__tests__/hydration.spec.ts | 35 +++++++++++++++++++ packages/runtime-core/__tests__/vnode.spec.ts | 10 ++++-- packages/runtime-core/src/vnode.ts | 15 ++++++-- 3 files changed, 55 insertions(+), 5 deletions(-) diff --git a/packages/runtime-core/__tests__/hydration.spec.ts b/packages/runtime-core/__tests__/hydration.spec.ts index 92b2edb7833..5be177b5ff7 100644 --- a/packages/runtime-core/__tests__/hydration.spec.ts +++ b/packages/runtime-core/__tests__/hydration.spec.ts @@ -2109,6 +2109,41 @@ describe('SSR hydration', () => { expect(root.innerHTML).toBe('
bar
') }) + // #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( + '

Click this link and that link.

', + ) + }) + describe('mismatch handling', () => { test('text node', () => { const { container } = mountWithHydration(`foo`, () => 'bar') diff --git a/packages/runtime-core/__tests__/vnode.spec.ts b/packages/runtime-core/__tests__/vnode.spec.ts index 4b678917b2c..7344eea5343 100644 --- a/packages/runtime-core/__tests__/vnode.spec.ts +++ b/packages/runtime-core/__tests__/vnode.spec.ts @@ -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` }) diff --git a/packages/runtime-core/src/vnode.ts b/packages/runtime-core/src/vnode.ts index 37ba85eb9ce..3f901bacc1d 100644 --- a/packages/runtime-core/src/vnode.ts +++ b/packages/runtime-core/src/vnode.ts @@ -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 {