From ab08b18175c160fc1f67658c3fe866f4ffbb4556 Mon Sep 17 00:00:00 2001 From: Julien Huang Date: Thu, 6 Feb 2025 22:57:17 +0100 Subject: [PATCH] fix(nuxt): remove `div` wrapper in client-only pages (#30425) --- .../components/runtime/client-component.ts | 97 +++++++++++++------ test/bundle.test.ts | 4 +- test/nuxt/client-only.test.ts | 90 +++++++++++++++++ test/nuxt/client.test.ts | 67 ------------- 4 files changed, 161 insertions(+), 97 deletions(-) create mode 100644 test/nuxt/client-only.test.ts delete mode 100644 test/nuxt/client.test.ts diff --git a/packages/nuxt/src/components/runtime/client-component.ts b/packages/nuxt/src/components/runtime/client-component.ts index fd401f8836..42c8976352 100644 --- a/packages/nuxt/src/components/runtime/client-component.ts +++ b/packages/nuxt/src/components/runtime/client-component.ts @@ -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) => { - // mark component as client-only for `definePageMeta` - (m.default || m).__clientOnlyPage = true - return m.default || m - }) - : loader) - - return defineComponent({ - inheritAttrs: false, - setup (_, { attrs }) { - 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) - }, - }) +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` + c.__clientOnlyPage = true + } + return pageToClientOnly(c) +} + +const cache = new WeakMap() + +function pageToClientOnly (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 = ` + + + ` + } + + clone.setup = (props, ctx) => { + const nuxtApp = useNuxtApp() + 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 } diff --git a/test/bundle.test.ts b/test/bundle.test.ts index 08da13e03c..0d28939249 100644 --- a/test/bundle.test.ts +++ b/test/bundle.test.ts @@ -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"`) diff --git a/test/nuxt/client-only.test.ts b/test/nuxt/client-only.test.ts new file mode 100644 index 0000000000..f29e4ffe16 --- /dev/null +++ b/test/nuxt/client-only.test.ts @@ -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(` + "
+
loading
+
" + `) + resolve() + await flushPromises() + expect(wrapper.html()).toMatchInlineSnapshot(` + "
+
async resolved
+
" + `) + }) + + it('createClient should retrieve attributes with useAttrs()', async () => { + const wrapper = await mountSuspended(createClientOnly(Client as ComponentOptions), { + attrs: { + id: 'client', + }, + }) + + expect(wrapper.html()).toMatchInlineSnapshot(` + "
{ + "id": "client" + }
" + `) + }) + + 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((_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 } +} diff --git a/test/nuxt/client.test.ts b/test/nuxt/client.test.ts deleted file mode 100644 index 87d34acc44..0000000000 --- a/test/nuxt/client.test.ts +++ /dev/null @@ -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(` - "
{ - "id": "client" - }
" - `) - }) -}) - -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) - }) -})