fix(nuxt): remove div wrapper in client-only pages (#30425)

This commit is contained in:
Julien Huang 2025-02-06 22:57:17 +01:00 committed by GitHub
parent 090bc6d7da
commit ab08b18175
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 161 additions and 97 deletions

View File

@ -1,32 +1,73 @@
import { defineAsyncComponent, defineComponent, h } from 'vue'
import type { AsyncComponentLoader } from 'vue'
import ClientOnly from '#app/components/client-only'
import { h, onMounted, ref } from 'vue'
import type { AsyncComponentLoader, ComponentOptions } from 'vue'
import { isPromise } from '@vue/shared'
import { useNuxtApp } from '#app/nuxt'
import ServerPlaceholder from '#app/components/server-placeholder'
/* @__NO_SIDE_EFFECTS__ */
export const createClientPage = (loader: AsyncComponentLoader) => {
const page = defineAsyncComponent(import.meta.dev
? () => loader().then((m) => {
export async function createClientPage (loader: AsyncComponentLoader) {
// vue-router: Write "() => import('./MyPage.vue')" instead of "defineAsyncComponent(() => import('./MyPage.vue'))".
const m = await loader()
const c = m.default || m
if (import.meta.dev) {
// mark component as client-only for `definePageMeta`
(m.default || m).__clientOnlyPage = true
return m.default || m
})
: loader)
c.__clientOnlyPage = true
}
return pageToClientOnly(c)
}
return defineComponent({
inheritAttrs: false,
setup (_, { attrs }) {
const cache = new WeakMap()
function pageToClientOnly<T extends ComponentOptions> (component: T) {
if (import.meta.server) {
return ServerPlaceholder
}
if (cache.has(component)) {
return cache.get(component)
}
const clone = { ...component }
if (clone.render) {
// override the component render (non script setup component) or dev mode
clone.render = (ctx: any, cache: any, $props: any, $setup: any, $data: any, $options: any) => ($setup.mounted$ ?? ctx.mounted$)
? h(component.render?.bind(ctx)(ctx, cache, $props, $setup, $data, $options))
: h('div')
} else if (clone.template) {
// handle runtime-compiler template
clone.template = `
<template v-if="mounted$">${component.template}</template>
<template v-else><div></div></template>
`
}
clone.setup = (props, ctx) => {
const nuxtApp = useNuxtApp()
if (import.meta.server || nuxtApp.isHydrating) {
// wrapped with div to avoid Transition issues
// @see https://github.com/nuxt/nuxt/pull/25037#issuecomment-1877423894
return () => h('div', [
h(ClientOnly, undefined, {
default: () => h(page, attrs),
}),
])
}
return () => h(page, attrs)
},
const mounted$ = ref(nuxtApp.isHydrating === false)
onMounted(() => {
mounted$.value = true
})
const setupState = component.setup?.(props, ctx) || {}
if (isPromise(setupState)) {
return Promise.resolve(setupState).then((setupState: any) => {
if (typeof setupState !== 'function') {
setupState = setupState || {}
setupState.mounted$ = mounted$
return setupState
}
return (...args: any[]) => (mounted$.value || !nuxtApp.isHydrating) ? h(setupState(...args)) : h('div')
})
} else {
return typeof setupState === 'function'
? (...args: any[]) => (mounted$.value || !nuxtApp.isHydrating)
? h(setupState(...args))
: h('div')
: Object.assign(setupState, { mounted$ })
}
}
cache.set(component, clone)
return clone
}

View File

@ -38,7 +38,7 @@ describe.skipIf(process.env.SKIP_BUNDLE_SIZE === 'true' || process.env.ECOSYSTEM
it('default client bundle size (pages)', async () => {
const clientStats = await analyzeSizes(['**/*.js'], join(pagesRootDir, '.output/public'))
expect.soft(roundToKilobytes(clientStats!.totalBytes)).toMatchInlineSnapshot(`"175k"`)
expect.soft(roundToKilobytes(clientStats!.totalBytes)).toMatchInlineSnapshot(`"176k"`)
const files = clientStats!.files.map(f => f.replace(/\..*\.js/, '.js'))
@ -127,7 +127,7 @@ describe.skipIf(process.env.SKIP_BUNDLE_SIZE === 'true' || process.env.ECOSYSTEM
const serverDir = join(pagesRootDir, '.output/server')
const serverStats = await analyzeSizes(['**/*.mjs', '!node_modules'], serverDir)
expect.soft(roundToKilobytes(serverStats.totalBytes)).toMatchInlineSnapshot(`"301k"`)
expect.soft(roundToKilobytes(serverStats.totalBytes)).toMatchInlineSnapshot(`"300k"`)
const modules = await analyzeSizes(['node_modules/**/*'], serverDir)
expect.soft(roundToKilobytes(modules.totalBytes)).toMatchInlineSnapshot(`"1402k"`)

View File

@ -0,0 +1,90 @@
import { describe, expect, it } from 'vitest'
import type { ComponentOptions } from 'vue'
import { Suspense, defineComponent, h, toDisplayString, useAttrs } from 'vue'
import { mountSuspended } from '@nuxt/test-utils/runtime'
import { flushPromises, mount } from '@vue/test-utils'
import { createClientOnly } from '../../packages/nuxt/src/app/components/client-only'
import { createClientPage } from '../../packages/nuxt/dist/components/runtime/client-component'
describe('client pages', () => {
it('should render without a wrapper', async () => {
const { resolve, wrapper } = createWrappedClientPage()
expect(wrapper.html()).toMatchInlineSnapshot(`
"<div>
<div id="fallback">loading</div>
</div>"
`)
resolve()
await flushPromises()
expect(wrapper.html()).toMatchInlineSnapshot(`
"<div>
<div id="async">async resolved</div>
</div>"
`)
})
it('createClient should retrieve attributes with useAttrs()', async () => {
const wrapper = await mountSuspended(createClientOnly(Client as ComponentOptions), {
attrs: {
id: 'client',
},
})
expect(wrapper.html()).toMatchInlineSnapshot(`
"<div id="client">{
"id": "client"
}</div>"
`)
})
it('should be suspensed when out of hydration', async () => {
const { resolve, wrapper } = createWrappedClientPage()
await flushPromises()
expect(wrapper.find('#fallback').exists()).toBe(true)
expect(wrapper.find('#async').exists()).toBe(false)
resolve!()
await flushPromises()
expect(wrapper.find('#async').exists()).toBe(true)
expect(wrapper.find('#fallback').exists()).toBe(false)
})
})
const Client = defineComponent({
name: 'TestClient',
setup () {
const attrs = useAttrs()
return () => h('div', {}, toDisplayString(attrs))
},
})
function createWrappedClientPage () {
let resolve: () => void
const promise = new Promise<void>((_resolve) => {
resolve = _resolve
})
const comp = defineComponent({
async setup () {
await promise
return () => h('div', { id: 'async' }, 'async resolved')
},
})
const ClientPage = defineAsyncComponent(() => createClientPage(() => Promise.resolve(comp)))
const wrapper = mount({
setup () {
return () => h('div', {}, [
h(Suspense, {}, {
default: () => h(ClientPage, {}),
fallback: () => h('div', { id: 'fallback' }, 'loading'),
}),
])
},
})
return { resolve: resolve!, wrapper }
}

View File

@ -1,67 +0,0 @@
import { describe, expect, it } from 'vitest'
import type { ComponentOptions } from 'vue'
import { Suspense, defineComponent, h, toDisplayString, useAttrs } from 'vue'
import { mountSuspended } from '@nuxt/test-utils/runtime'
import { flushPromises, mount } from '@vue/test-utils'
import { createClientOnly } from '../../packages/nuxt/src/app/components/client-only'
import { createClientPage } from '../../packages/nuxt/dist/components/runtime/client-component'
const Client = defineComponent({
name: 'TestClient',
setup () {
const attrs = useAttrs()
return () => h('div', {}, toDisplayString(attrs))
},
})
describe('createClient attribute inheritance', () => {
it('should retrieve attributes with useAttrs()', async () => {
const wrapper = await mountSuspended(createClientOnly(Client as ComponentOptions), {
attrs: {
id: 'client',
},
})
expect(wrapper.html()).toMatchInlineSnapshot(`
"<div id="client">{
"id": "client"
}</div>"
`)
})
})
describe('client page', () => {
it('Should be suspensed when out of hydration', async () => {
let resolve
const promise = new Promise((_resolve) => {
resolve = _resolve
})
const comp = defineComponent({
async setup () {
await promise
return () => h('div', { id: 'async' }, 'async resolved')
},
})
const wrapper = mount({
setup () {
return () => h('div', {}, [
h(Suspense, {}, {
default: () => h(createClientPage(() => Promise.resolve(comp)), {}),
fallback: () => h('div', { id: 'fallback' }, 'loading'),
}),
])
},
})
await flushPromises()
expect(wrapper.find('#fallback').exists()).toBe(true)
expect(wrapper.find('#async').exists()).toBe(false)
resolve!()
await flushPromises()
expect(wrapper.find('#async').exists()).toBe(true)
expect(wrapper.find('#fallback').exists()).toBe(false)
})
})