fix(nuxt): skip hydration mismatches with client components (#19231)

This commit is contained in:
Julien Huang 2023-10-16 15:09:54 +02:00 committed by GitHub
parent 830f4f4aa8
commit 24b629e82e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 126 additions and 10 deletions

View File

@ -350,10 +350,6 @@ In this case, the `.server` + `.client` components are two 'halves' of a compone
</template> </template>
``` ```
::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.
::
## `<DevOnly>` Component ## `<DevOnly>` Component
Nuxt provides the `<DevOnly>` component to render a component only during development. Nuxt provides the `<DevOnly>` component to render a component only during development.

View File

@ -1,5 +1,6 @@
import { createElementBlock, createElementVNode, defineComponent, h, mergeProps, onMounted, ref } from 'vue' import { createElementBlock, createElementVNode, createStaticVNode, defineComponent, getCurrentInstance, h, onMounted, ref } from 'vue'
import type { ComponentOptions } from 'vue' import type { ComponentInternalInstance, ComponentOptions } from 'vue'
import { getFragmentHTML } from './utils'
export default defineComponent({ export default defineComponent({
name: 'ClientOnly', name: 'ClientOnly',
@ -39,7 +40,8 @@ export function createClientOnly<T extends ComponentOptions> (component: T) {
? createElementVNode(res.type, res.props, res.children, res.patchFlag, res.dynamicProps, res.shapeFlag) ? createElementVNode(res.type, res.props, res.children, res.patchFlag, res.dynamicProps, res.shapeFlag)
: h(res) : h(res)
} else { } 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) { } else if (clone.template) {
@ -51,8 +53,20 @@ export function createClientOnly<T extends ComponentOptions> (component: T) {
} }
clone.setup = (props, ctx) => { 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) 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) || {}) return Promise.resolve(component.setup?.(props, ctx) || {})
.then((setupState) => { .then((setupState) => {
@ -65,7 +79,8 @@ export function createClientOnly<T extends ComponentOptions> (component: T) {
? createElementVNode(res.type, res.props, res.children, res.patchFlag, res.dynamicProps, res.shapeFlag) ? createElementVNode(res.type, res.props, res.children, res.patchFlag, res.dynamicProps, res.shapeFlag)
: h(res) : h(res)
} else { } 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<T extends ComponentOptions> (component: T) {
return clone 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
}

View File

@ -104,7 +104,7 @@ export function vforToArray (source: any): any[] {
* @param withoutSlots purge all slots from the HTML string retrieved * @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 * @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) {
if (element.nodeName === '#comment' && element.nodeValue === '[') { if (element.nodeName === '#comment' && element.nodeValue === '[') {
return getFragmentChildren(element, [], withoutSlots) return getFragmentChildren(element, [], withoutSlots)

View File

@ -271,6 +271,24 @@ describe('pages', () => {
await expectNoClientErrors('/another-parent') 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 () => { it('/client-only-components', async () => {
const html = await $fetch('/client-only-components') const html = await $fetch('/client-only-components')
// ensure fallbacks with classes and arbitrary attributes are rendered // ensure fallbacks with classes and arbitrary attributes are rendered

View File

@ -0,0 +1,8 @@
<template>
<div class="client-fragment-server client">
world
</div>
<div class="client-fragment-server client">
world
</div>
</template>

View File

@ -0,0 +1,5 @@
<template>
<div class="client-fragment-server server">
hello
</div>
</template>

View File

@ -0,0 +1,8 @@
<template>
<div class="client-fragment-server-fragment client">
world
</div>
<div class="client-fragment-server-fragment client">
world
</div>
</template>

View File

@ -0,0 +1,8 @@
<template>
<div class="client-fragment-server-fragment server">
hello
</div>
<div class="client-fragment-server-fragment server">
hello
</div>
</template>

View File

@ -0,0 +1,5 @@
<template>
<div class="client-server client">
world !
</div>
</template>

View File

@ -0,0 +1,5 @@
<template>
<div class="client-server server">
hello
</div>
</template>

View File

@ -0,0 +1,5 @@
<template>
<div class="client-server-fragment client">
world
</div>
</template>

View File

@ -0,0 +1,8 @@
<template>
<div class="client-server-fragment server">
hello
</div>
<div class="client-server-fragment server">
hello
</div>
</template>

View File

@ -0,0 +1,28 @@
<template>
<div>
<div class="placeholder-to-ensure-no-override">
this should not be removed by hydration
</div>
<ClientFragmentServer />
<div class="placeholder-to-ensure-no-override">
this should not be removed by hydration
</div>
<ClientServerFragment />
<div class="placeholder-to-ensure-no-override">
this should not be removed by hydration
</div>
<ClientServer />
<div class="placeholder-to-ensure-no-override">
this should not be removed by hydration
</div>
<ClientFragmentServerFragment />
<div class="placeholder-to-ensure-no-override">
this should not be removed by hydration
</div>
</div>
</template>