diff --git a/packages/nuxt/src/app/components/client-only.ts b/packages/nuxt/src/app/components/client-only.ts index 5483dbe0c6..0a8472109c 100644 --- a/packages/nuxt/src/app/components/client-only.ts +++ b/packages/nuxt/src/app/components/client-only.ts @@ -1,5 +1,6 @@ import { cloneVNode, createElementBlock, createStaticVNode, defineComponent, getCurrentInstance, h, onMounted, provide, ref } from 'vue' import type { ComponentInternalInstance, ComponentOptions, InjectionKey } from 'vue' +import { isPromise } from '@vue/shared' import { useNuxtApp } from '../nuxt' import { getFragmentHTML } from './utils' @@ -42,9 +43,10 @@ export function createClientOnly (component: T) { const clone = { ...component } if (clone.render) { - // override the component render (non script setup component) + // 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) => { - if ($setup.mounted$ ?? ctx.mounted$) { + // import.meta.client for server-side treeshakking + if (import.meta.client && ($setup.mounted$ ?? ctx.mounted$)) { const res = component.render?.bind(ctx)(ctx, cache, $props, $setup, $data, $options) return (res.children === null || typeof res.children === 'string') ? cloneVNode(res) @@ -63,33 +65,39 @@ export function createClientOnly (component: T) { } clone.setup = (props, ctx) => { + const nuxtApp = useNuxtApp() + const mounted$ = ref(import.meta.client && nuxtApp.isHydrating === false) const instance = getCurrentInstance()! - const attrs = { ...instance.attrs } + if (import.meta.server || nuxtApp.isHydrating) { + const attrs = { ...instance.attrs } + // remove existing directives during hydration + const directives = extractDirectives(instance) + // prevent attrs inheritance since a staticVNode is rendered before hydration + for (const key in attrs) { + delete instance.attrs[key] + } - // remove existing directives during hydration - const directives = extractDirectives(instance) - // prevent attrs inheritance since a staticVNode is rendered before hydration - for (const key in attrs) { - delete instance.attrs[key] + onMounted(() => { + Object.assign(instance.attrs, attrs) + instance.vnode.dirs = directives + }) } - const mounted$ = ref(false) onMounted(() => { - Object.assign(instance.attrs, attrs) - instance.vnode.dirs = directives mounted$.value = true }) + const setupState = component.setup?.(props, ctx) || {} - return Promise.resolve(component.setup?.(props, ctx) || {}) - .then((setupState) => { + if (isPromise(setupState)) { + return Promise.resolve(setupState).then((setupState) => { if (typeof setupState !== 'function') { setupState = setupState || {} setupState.mounted$ = mounted$ return setupState } return (...args: any[]) => { - if (mounted$.value) { + if (import.meta.client && (mounted$.value || !nuxtApp.isHydrating)) { const res = setupState(...args) return (res.children === null || typeof res.children === 'string') ? cloneVNode(res) @@ -100,6 +108,20 @@ export function createClientOnly (component: T) { } } }) + } else { + if (typeof setupState === 'function') { + return (...args: any[]) => { + if (mounted$.value) { + return h(setupState(...args), ctx.attrs) + } + const fragment = getFragmentHTML(instance?.vnode.el ?? null) ?? ['
'] + return import.meta.client + ? createStaticVNode(fragment.join(''), fragment.length) : + h('div', ctx.attrs) + } + } + return Object.assign(setupState, { mounted$ }) + } } cache.set(component, clone) diff --git a/test/basic.test.ts b/test/basic.test.ts index 0c756211e0..8834c138dd 100644 --- a/test/basic.test.ts +++ b/test/basic.test.ts @@ -392,12 +392,14 @@ describe('pages', () => { expect(await page.locator('.client-only-script button').innerHTML()).toContain('2') expect(await page.locator('.string-stateful-script').innerHTML()).toContain('1') expect(await page.locator('.string-stateful').innerHTML()).toContain('1') + const waitForConsoleLog = page.waitForEvent('console', consoleLog => consoleLog.text() === 'has $el') // ensure directives are reactive await page.locator('button#show-all').click() await Promise.all(hiddenSelectors.map(selector => page.locator(selector).isVisible())) .then(results => results.forEach(isVisible => expect(isVisible).toBeTruthy())) + await waitForConsoleLog expect(pageErrors).toEqual([]) await page.close() // don't expect any errors or warning on client-side navigation diff --git a/test/fixtures/basic/components/WrapClientComponent.vue b/test/fixtures/basic/components/WrapClientComponent.vue new file mode 100644 index 0000000000..3aebe022b9 --- /dev/null +++ b/test/fixtures/basic/components/WrapClientComponent.vue @@ -0,0 +1,16 @@ + + + diff --git a/test/fixtures/basic/pages/client-only-components.vue b/test/fixtures/basic/pages/client-only-components.vue index d353cf81ca..c6b69dfcda 100644 --- a/test/fixtures/basic/pages/client-only-components.vue +++ b/test/fixtures/basic/pages/client-only-components.vue @@ -59,6 +59,7 @@ class="no-state-hidden" /> +