diff --git a/packages/nuxt/src/app/components/client-only.mjs b/packages/nuxt/src/app/components/client-only.mjs index 3e0cf41bdd6..e6530f74298 100644 --- a/packages/nuxt/src/app/components/client-only.mjs +++ b/packages/nuxt/src/app/components/client-only.mjs @@ -1,4 +1,4 @@ -import { ref, onMounted, defineComponent, createElementBlock, h, Fragment } from 'vue' +import { ref, onMounted, defineComponent, createElementBlock, h, createElementVNode } from 'vue' export default defineComponent({ name: 'ClientOnly', @@ -30,9 +30,14 @@ export function createClientOnly (component) { if (clone.render) { // override the component render (non script setup component) clone.render = (ctx, ...args) => { - return ctx.mounted$ - ? h(Fragment, ctx.$attrs ?? ctx._.attrs, component.render(ctx, ...args)) - : h('div', ctx.$attrs ?? ctx._.attrs) + if (ctx.mounted$) { + const res = component.render(ctx, ...args) + return (res.children === null || typeof res.children === 'string') + ? createElementVNode(res.type, res.props, res.children, res.patchFlag, res.dynamicProps, res.shapeFlag) + : h(res) + } else { + return h('div', ctx.$attrs ?? ctx._.attrs) + } } } else if (clone.template) { // handle runtime-compiler template @@ -51,10 +56,14 @@ export function createClientOnly (component) { return typeof setupState !== 'function' ? { ...setupState, mounted$ } : (...args) => { - return mounted$.value - // use Fragment to avoid oldChildren is null issue - ? h(Fragment, ctx.attrs, setupState(...args)) - : h('div', ctx.attrs) + if (mounted$.value) { + const res = setupState(...args) + return (res.children === null || typeof res.children === 'string') + ? createElementVNode(res.type, res.props, res.children, res.patchFlag, res.dynamicProps, res.shapeFlag) + : h(res) + } else { + return h('div', ctx.attrs) + } } }) } diff --git a/test/basic.test.ts b/test/basic.test.ts index 2fe9c019dba..cf143ddfe76 100644 --- a/test/basic.test.ts +++ b/test/basic.test.ts @@ -145,12 +145,74 @@ describe('pages', () => { it('/client-only-components', async () => { const html = await $fetch('/client-only-components') + // ensure fallbacks with classes and arbitrary attributes are rendered expect(html).toContain('
') expect(html).toContain('
') expect(html).toContain('
Fallback
') + // ensure components are not rendered server-side expect(html).not.toContain('Should not be server rendered') await expectNoClientErrors('/client-only-components') + + const page = await createPage('/client-only-components') + + await page.waitForLoadState('networkidle') + + const hiddenSelectors = [ + '.string-stateful-should-be-hidden', + '.client-script-should-be-hidden', + '.string-stateful-script-should-be-hidden', + '.no-state-hidden' + ] + const visibleSelectors = [ + '.string-stateful', + '.string-stateful-script', + '.client-only-script', + '.client-only-script-setup', + '.no-state' + ] + // ensure directives are correctly applied + await Promise.all(hiddenSelectors.map(selector => page.locator(selector).isHidden())) + .then(results => results.forEach(isHidden => expect(isHidden).toBeTruthy())) + // ensure hidden components are still rendered + await Promise.all(hiddenSelectors.map(selector => page.locator(selector).innerHTML())) + .then(results => results.forEach(innerHTML => expect(innerHTML).not.toBe(''))) + + // ensure single root node components are rendered once on client (should not be empty) + await Promise.all(visibleSelectors.map(selector => page.locator(selector).innerHTML())) + .then(results => results.forEach(innerHTML => expect(innerHTML).not.toBe(''))) + + // ensure multi-root-node is correctly rendered + expect(await page.locator('.multi-root-node-count').innerHTML()).toContain('0') + expect(await page.locator('.multi-root-node-button').innerHTML()).toContain('add 1 to count') + expect(await page.locator('.multi-root-node-script-count').innerHTML()).toContain('0') + expect(await page.locator('.multi-root-node-script-button').innerHTML()).toContain('add 1 to count') + + // ensure components reactivity + await page.locator('.multi-root-node-button').click() + await page.locator('.multi-root-node-script-button').click() + await page.locator('.client-only-script button').click() + await page.locator('.client-only-script-setup button').click() + + expect(await page.locator('.multi-root-node-count').innerHTML()).toContain('1') + expect(await page.locator('.multi-root-node-script-count').innerHTML()).toContain('1') + expect(await page.locator('.client-only-script-setup button').innerHTML()).toContain('1') + expect(await page.locator('.client-only-script button').innerHTML()).toContain('1') + + // ensure components ref is working and reactive + await page.locator('button.test-ref-1').click() + await page.locator('button.test-ref-2').click() + await page.locator('button.test-ref-3').click() + await page.locator('button.test-ref-4').click() + expect(await page.locator('.client-only-script-setup button').innerHTML()).toContain('2') + 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') + + // 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())) }) }) diff --git a/test/fixtures/basic/components/client/MultiRootNode.client.vue b/test/fixtures/basic/components/client/MultiRootNode.client.vue new file mode 100644 index 00000000000..247ad18db1a --- /dev/null +++ b/test/fixtures/basic/components/client/MultiRootNode.client.vue @@ -0,0 +1,14 @@ + + + diff --git a/test/fixtures/basic/components/client/MultiRootNodeScript.client.vue b/test/fixtures/basic/components/client/MultiRootNodeScript.client.vue new file mode 100644 index 00000000000..67ed503ca3d --- /dev/null +++ b/test/fixtures/basic/components/client/MultiRootNodeScript.client.vue @@ -0,0 +1,19 @@ + + + diff --git a/test/fixtures/basic/components/client/NoState.client.vue b/test/fixtures/basic/components/client/NoState.client.vue new file mode 100644 index 00000000000..92f3b3e3bec --- /dev/null +++ b/test/fixtures/basic/components/client/NoState.client.vue @@ -0,0 +1,3 @@ + diff --git a/test/fixtures/basic/components/client/Script.client.vue b/test/fixtures/basic/components/client/Script.client.vue new file mode 100644 index 00000000000..86767ad9378 --- /dev/null +++ b/test/fixtures/basic/components/client/Script.client.vue @@ -0,0 +1,45 @@ + + + + + + + diff --git a/test/fixtures/basic/components/client/SetupScript.client.vue b/test/fixtures/basic/components/client/SetupScript.client.vue new file mode 100644 index 00000000000..becd6eb01e6 --- /dev/null +++ b/test/fixtures/basic/components/client/SetupScript.client.vue @@ -0,0 +1,18 @@ + + + diff --git a/test/fixtures/basic/components/client/StringChildStateful.client.vue b/test/fixtures/basic/components/client/StringChildStateful.client.vue new file mode 100644 index 00000000000..16b39a204a7 --- /dev/null +++ b/test/fixtures/basic/components/client/StringChildStateful.client.vue @@ -0,0 +1,14 @@ + + + diff --git a/test/fixtures/basic/components/client/StringChildStatefulScript.client.vue b/test/fixtures/basic/components/client/StringChildStatefulScript.client.vue new file mode 100644 index 00000000000..b87df7c74da --- /dev/null +++ b/test/fixtures/basic/components/client/StringChildStatefulScript.client.vue @@ -0,0 +1,18 @@ + + + diff --git a/test/fixtures/basic/pages/client-only-components.vue b/test/fixtures/basic/pages/client-only-components.vue index f020ea1cfc1..e5e1094a92e 100644 --- a/test/fixtures/basic/pages/client-only-components.vue +++ b/test/fixtures/basic/pages/client-only-components.vue @@ -1,18 +1,67 @@ + +