From 24b629e82e321a1c66484673e6803ed34ec120d1 Mon Sep 17 00:00:00 2001 From: Julien Huang Date: Mon, 16 Oct 2023 15:09:54 +0200 Subject: [PATCH] fix(nuxt): skip hydration mismatches with client components (#19231) --- .../2.directory-structure/1.components.md | 4 --- .../nuxt/src/app/components/client-only.ts | 32 ++++++++++++++++--- packages/nuxt/src/app/components/utils.ts | 2 +- test/basic.test.ts | 18 +++++++++++ .../client/FragmentServer.client.vue | 8 +++++ .../client/FragmentServer.server.vue | 5 +++ .../client/FragmentServerFragment.client.vue | 8 +++++ .../client/FragmentServerFragment.server.vue | 8 +++++ .../basic/components/client/Server.client.vue | 5 +++ .../basic/components/client/Server.server.vue | 5 +++ .../client/ServerFragment.client.vue | 5 +++ .../client/ServerFragment.server.vue | 8 +++++ test/fixtures/basic/pages/client-server.vue | 28 ++++++++++++++++ 13 files changed, 126 insertions(+), 10 deletions(-) create mode 100644 test/fixtures/basic/components/client/FragmentServer.client.vue create mode 100644 test/fixtures/basic/components/client/FragmentServer.server.vue create mode 100644 test/fixtures/basic/components/client/FragmentServerFragment.client.vue create mode 100644 test/fixtures/basic/components/client/FragmentServerFragment.server.vue create mode 100644 test/fixtures/basic/components/client/Server.client.vue create mode 100644 test/fixtures/basic/components/client/Server.server.vue create mode 100644 test/fixtures/basic/components/client/ServerFragment.client.vue create mode 100644 test/fixtures/basic/components/client/ServerFragment.server.vue create mode 100644 test/fixtures/basic/pages/client-server.vue diff --git a/docs/2.guide/2.directory-structure/1.components.md b/docs/2.guide/2.directory-structure/1.components.md index a6fb461af3..6e594c77a6 100644 --- a/docs/2.guide/2.directory-structure/1.components.md +++ b/docs/2.guide/2.directory-structure/1.components.md @@ -350,10 +350,6 @@ In this case, the `.server` + `.client` components are two 'halves' of a compone ``` -::alert{type=warning} -It is essential that the client half of the component can 'hydrate' the server-rendered HTML. That is, it should render the same HTML on initial load, or you will experience a hydration mismatch. -:: - ## `` Component Nuxt provides the `` component to render a component only during development. diff --git a/packages/nuxt/src/app/components/client-only.ts b/packages/nuxt/src/app/components/client-only.ts index f6739e118a..894decea0c 100644 --- a/packages/nuxt/src/app/components/client-only.ts +++ b/packages/nuxt/src/app/components/client-only.ts @@ -1,5 +1,6 @@ -import { createElementBlock, createElementVNode, defineComponent, h, mergeProps, onMounted, ref } from 'vue' -import type { ComponentOptions } from 'vue' +import { createElementBlock, createElementVNode, createStaticVNode, defineComponent, getCurrentInstance, h, onMounted, ref } from 'vue' +import type { ComponentInternalInstance, ComponentOptions } from 'vue' +import { getFragmentHTML } from './utils' export default defineComponent({ name: 'ClientOnly', @@ -39,7 +40,8 @@ export function createClientOnly (component: T) { ? createElementVNode(res.type, res.props, res.children, res.patchFlag, res.dynamicProps, res.shapeFlag) : h(res) } else { - return h('div', mergeProps(ctx.$attrs ?? ctx._.attrs, { key: 'placeholder-key' })) + const fragment = getFragmentHTML(ctx._.vnode.el ?? null) + return process.client ? createStaticVNode(fragment.join(''), fragment.length) : h('div', ctx.$attrs ?? ctx._.attrs) } } } else if (clone.template) { @@ -51,8 +53,20 @@ export function createClientOnly (component: T) { } clone.setup = (props, ctx) => { + const instance = getCurrentInstance()! + + const attrs = instance.attrs + // remove existing directives during hydration + const directives = extractDirectives(instance) + // prevent attrs inheritance since a staticVNode is rendered before hydration + instance.attrs = {} const mounted$ = ref(false) - onMounted(() => { mounted$.value = true }) + + onMounted(() => { + instance.attrs = attrs + instance.vnode.dirs = directives + mounted$.value = true + }) return Promise.resolve(component.setup?.(props, ctx) || {}) .then((setupState) => { @@ -65,7 +79,8 @@ export function createClientOnly (component: T) { ? createElementVNode(res.type, res.props, res.children, res.patchFlag, res.dynamicProps, res.shapeFlag) : h(res) } else { - return h('div', mergeProps(ctx.attrs, { key: 'placeholder-key' })) + const fragment = getFragmentHTML(instance?.vnode.el ?? null) + return process.client ? createStaticVNode(fragment.join(''), fragment.length) : h('div', ctx.attrs) } } }) @@ -75,3 +90,10 @@ export function createClientOnly (component: T) { return clone } + +function extractDirectives (instance: ComponentInternalInstance | null) { + if (!instance || !instance.vnode.dirs) { return null } + const directives = instance.vnode.dirs + instance.vnode.dirs = null + return directives +} diff --git a/packages/nuxt/src/app/components/utils.ts b/packages/nuxt/src/app/components/utils.ts index c98d8efaac..3fac26c7bb 100644 --- a/packages/nuxt/src/app/components/utils.ts +++ b/packages/nuxt/src/app/components/utils.ts @@ -104,7 +104,7 @@ export function vforToArray (source: any): any[] { * @param withoutSlots purge all slots from the HTML string retrieved * @returns {string[]} An array of string which represent the content of each element. Use `.join('')` to retrieve a component vnode.el HTML */ -export function getFragmentHTML (element: RendererNode | null, withoutSlots = false) { +export function getFragmentHTML (element: RendererNode | null, withoutSlots = false): string[] { if (element) { if (element.nodeName === '#comment' && element.nodeValue === '[') { return getFragmentChildren(element, [], withoutSlots) diff --git a/test/basic.test.ts b/test/basic.test.ts index 04f54eef72..e3e27c933d 100644 --- a/test/basic.test.ts +++ b/test/basic.test.ts @@ -271,6 +271,24 @@ describe('pages', () => { await expectNoClientErrors('/another-parent') }) + it('/client-server', async () => { + // expect no hydration issues + await expectNoClientErrors('/client-server') + const page = await createPage('/client-server') + await page.waitForLoadState('networkidle') + const bodyHTML = await page.innerHTML('body') + expect(await page.locator('.placeholder-to-ensure-no-override').all()).toHaveLength(5) + expect(await page.locator('.server').all()).toHaveLength(0) + expect(await page.locator('.client-fragment-server.client').all()).toHaveLength(2) + expect(await page.locator('.client-fragment-server-fragment.client').all()).toHaveLength(2) + expect(await page.locator('.client-server.client').all()).toHaveLength(1) + expect(await page.locator('.client-server-fragment.client').all()).toHaveLength(1) + expect(await page.locator('.client-server-fragment.client').all()).toHaveLength(1) + + expect(bodyHTML).not.toContain('hello') + expect(bodyHTML).toContain('world') + }) + it('/client-only-components', async () => { const html = await $fetch('/client-only-components') // ensure fallbacks with classes and arbitrary attributes are rendered diff --git a/test/fixtures/basic/components/client/FragmentServer.client.vue b/test/fixtures/basic/components/client/FragmentServer.client.vue new file mode 100644 index 0000000000..281136bff6 --- /dev/null +++ b/test/fixtures/basic/components/client/FragmentServer.client.vue @@ -0,0 +1,8 @@ + diff --git a/test/fixtures/basic/components/client/FragmentServer.server.vue b/test/fixtures/basic/components/client/FragmentServer.server.vue new file mode 100644 index 0000000000..28bcc947f5 --- /dev/null +++ b/test/fixtures/basic/components/client/FragmentServer.server.vue @@ -0,0 +1,5 @@ + diff --git a/test/fixtures/basic/components/client/FragmentServerFragment.client.vue b/test/fixtures/basic/components/client/FragmentServerFragment.client.vue new file mode 100644 index 0000000000..398f0f0a56 --- /dev/null +++ b/test/fixtures/basic/components/client/FragmentServerFragment.client.vue @@ -0,0 +1,8 @@ + diff --git a/test/fixtures/basic/components/client/FragmentServerFragment.server.vue b/test/fixtures/basic/components/client/FragmentServerFragment.server.vue new file mode 100644 index 0000000000..ca5c6c67a2 --- /dev/null +++ b/test/fixtures/basic/components/client/FragmentServerFragment.server.vue @@ -0,0 +1,8 @@ + diff --git a/test/fixtures/basic/components/client/Server.client.vue b/test/fixtures/basic/components/client/Server.client.vue new file mode 100644 index 0000000000..4a948b67cd --- /dev/null +++ b/test/fixtures/basic/components/client/Server.client.vue @@ -0,0 +1,5 @@ + diff --git a/test/fixtures/basic/components/client/Server.server.vue b/test/fixtures/basic/components/client/Server.server.vue new file mode 100644 index 0000000000..2944736ca6 --- /dev/null +++ b/test/fixtures/basic/components/client/Server.server.vue @@ -0,0 +1,5 @@ + diff --git a/test/fixtures/basic/components/client/ServerFragment.client.vue b/test/fixtures/basic/components/client/ServerFragment.client.vue new file mode 100644 index 0000000000..9aa8067a42 --- /dev/null +++ b/test/fixtures/basic/components/client/ServerFragment.client.vue @@ -0,0 +1,5 @@ + diff --git a/test/fixtures/basic/components/client/ServerFragment.server.vue b/test/fixtures/basic/components/client/ServerFragment.server.vue new file mode 100644 index 0000000000..96e7f42586 --- /dev/null +++ b/test/fixtures/basic/components/client/ServerFragment.server.vue @@ -0,0 +1,8 @@ + diff --git a/test/fixtures/basic/pages/client-server.vue b/test/fixtures/basic/pages/client-server.vue new file mode 100644 index 0000000000..70bf772567 --- /dev/null +++ b/test/fixtures/basic/pages/client-server.vue @@ -0,0 +1,28 @@ +