mirror of
https://github.com/nuxt/nuxt.git
synced 2024-11-25 15:15:19 +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>
|
</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.
|
||||||
|
@ -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
|
||||||
|
}
|
||||||
|
@ -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)
|
||||||
|
@ -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
|
||||||
|
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