mirror of
https://github.com/nuxt/nuxt.git
synced 2024-11-21 21:25:11 +00:00
fix(nuxt): skip hydration mismatches with client components (#19231)
This commit is contained in:
parent
830f4f4aa8
commit
24b629e82e
@ -350,10 +350,6 @@ In this case, the `.server` + `.client` components are two 'halves' of a compone
|
||||
</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
|
||||
|
||||
Nuxt provides the `<DevOnly>` component to render a component only during development.
|
||||
|
@ -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<T extends ComponentOptions> (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<T extends ComponentOptions> (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<T extends ComponentOptions> (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<T extends ComponentOptions> (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
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
8
test/fixtures/basic/components/client/FragmentServer.client.vue
vendored
Normal file
8
test/fixtures/basic/components/client/FragmentServer.client.vue
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
<template>
|
||||
<div class="client-fragment-server client">
|
||||
world
|
||||
</div>
|
||||
<div class="client-fragment-server client">
|
||||
world
|
||||
</div>
|
||||
</template>
|
5
test/fixtures/basic/components/client/FragmentServer.server.vue
vendored
Normal file
5
test/fixtures/basic/components/client/FragmentServer.server.vue
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<div class="client-fragment-server server">
|
||||
hello
|
||||
</div>
|
||||
</template>
|
8
test/fixtures/basic/components/client/FragmentServerFragment.client.vue
vendored
Normal file
8
test/fixtures/basic/components/client/FragmentServerFragment.client.vue
vendored
Normal 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>
|
8
test/fixtures/basic/components/client/FragmentServerFragment.server.vue
vendored
Normal file
8
test/fixtures/basic/components/client/FragmentServerFragment.server.vue
vendored
Normal 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>
|
5
test/fixtures/basic/components/client/Server.client.vue
vendored
Normal file
5
test/fixtures/basic/components/client/Server.client.vue
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<div class="client-server client">
|
||||
world !
|
||||
</div>
|
||||
</template>
|
5
test/fixtures/basic/components/client/Server.server.vue
vendored
Normal file
5
test/fixtures/basic/components/client/Server.server.vue
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<div class="client-server server">
|
||||
hello
|
||||
</div>
|
||||
</template>
|
5
test/fixtures/basic/components/client/ServerFragment.client.vue
vendored
Normal file
5
test/fixtures/basic/components/client/ServerFragment.client.vue
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<div class="client-server-fragment client">
|
||||
world
|
||||
</div>
|
||||
</template>
|
8
test/fixtures/basic/components/client/ServerFragment.server.vue
vendored
Normal file
8
test/fixtures/basic/components/client/ServerFragment.server.vue
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
<template>
|
||||
<div class="client-server-fragment server">
|
||||
hello
|
||||
</div>
|
||||
<div class="client-server-fragment server">
|
||||
hello
|
||||
</div>
|
||||
</template>
|
28
test/fixtures/basic/pages/client-server.vue
vendored
Normal file
28
test/fixtures/basic/pages/client-server.vue
vendored
Normal 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>
|
Loading…
Reference in New Issue
Block a user