diff --git a/docs/content/2.guide/2.directory-structure/1.components.md b/docs/content/2.guide/2.directory-structure/1.components.md index 8bac9fa14e..d85986fac2 100644 --- a/docs/content/2.guide/2.directory-structure/1.components.md +++ b/docs/content/2.guide/2.directory-structure/1.components.md @@ -201,7 +201,7 @@ If a component is meant to be rendered only client-side, you can add the `.clien ``` ::alert{type=warning} -This feature only works with Nuxt auto-imports. Explicitly importing these components does not convert them into client-only components. +This feature only works with Nuxt auto-imports and `#components` imports. Explicitly importing these components from their real paths does not convert them into client-only components. :: ## .server Components diff --git a/packages/nuxt/src/components/module.ts b/packages/nuxt/src/components/module.ts index 891617d654..b7fc286119 100644 --- a/packages/nuxt/src/components/module.ts +++ b/packages/nuxt/src/components/module.ts @@ -2,6 +2,7 @@ import { statSync } from 'node:fs' import { relative, resolve } from 'pathe' import { defineNuxtModule, resolveAlias, addTemplate, addPluginTemplate } from '@nuxt/kit' import type { Component, ComponentsDir, ComponentsOptions } from '@nuxt/schema' +import { distDir } from '../dirs' import { componentsPluginTemplate, componentsTemplate, componentsTypeTemplate } from './templates' import { scanComponents } from './scan' import { loaderPlugin } from './loader' @@ -146,6 +147,17 @@ export default defineNuxtModule({ nuxt.hook('app:templates', async () => { const newComponents = await scanComponents(componentDirs, nuxt.options.srcDir!) await nuxt.callHook('components:extend', newComponents) + // add server placeholder for .client components server side. issue: #7085 + for (const component of newComponents) { + if (component.mode === 'client' && !newComponents.some(c => c.pascalName === component.pascalName && c.mode === 'server')) { + newComponents.push({ + ...component, + mode: 'server', + filePath: resolve(distDir, 'app/components/server-placeholder'), + chunkName: 'components/' + component.kebabName + }) + } + } context.components = newComponents }) diff --git a/packages/nuxt/src/components/templates.ts b/packages/nuxt/src/components/templates.ts index bf085cb0be..00b3356dbf 100644 --- a/packages/nuxt/src/components/templates.ts +++ b/packages/nuxt/src/components/templates.ts @@ -1,6 +1,6 @@ import { isAbsolute, relative } from 'pathe' import type { Component, Nuxt, NuxtPluginTemplate, NuxtTemplate } from '@nuxt/schema' -import { genDynamicImport, genExport, genObjectFromRawEntries } from 'knitwork' +import { genDynamicImport, genExport, genImport, genObjectFromRawEntries } from 'knitwork' export interface ComponentsTemplateContext { nuxt: Nuxt @@ -53,17 +53,31 @@ export default defineNuxtPlugin(nuxtApp => { export const componentsTemplate: NuxtTemplate = { // components.[server|client].mjs' getContents ({ options }) { - return [ - 'import { defineAsyncComponent } from \'vue\'', - ...options.getComponents(options.mode).flatMap((c) => { - const exp = c.export === 'default' ? 'c.default || c' : `c['${c.export}']` - const comment = createImportMagicComments(c) + const imports = new Set() + imports.add('import { defineAsyncComponent } from \'vue\'') - return [ - genExport(c.filePath, [{ name: c.export, as: c.pascalName }]), - `export const Lazy${c.pascalName} = defineAsyncComponent(${genDynamicImport(c.filePath, { comment })}.then(c => ${exp}))` - ] - }), + let num = 0 + const components = options.getComponents(options.mode).flatMap((c) => { + const exp = c.export === 'default' ? 'c.default || c' : `c['${c.export}']` + const comment = createImportMagicComments(c) + + const isClient = c.mode === 'client' + const definitions = [] + if (isClient) { + num++ + const identifier = `__nuxt_component_${num}` + imports.add(genImport('#app/components/client-only', [{ name: 'createClientOnly' }])) + imports.add(genImport(c.filePath, [{ name: c.export, as: identifier }])) + definitions.push(`export const ${c.pascalName} = /*#__PURE__*/ createClientOnly(${identifier})`) + } else { + definitions.push(genExport(c.filePath, [{ name: c.export, as: c.pascalName }])) + } + definitions.push(`export const Lazy${c.pascalName} = defineAsyncComponent(${genDynamicImport(c.filePath, { comment })}.then(c => ${isClient ? `createClientOnly(${exp})` : exp}))`) + return definitions + }) + return [ + ...imports, + ...components, `export const componentNames = ${JSON.stringify(options.getComponents().map(c => c.pascalName))}` ].join('\n') } diff --git a/test/basic.test.ts b/test/basic.test.ts index c288fc1244..cc964d76fc 100644 --- a/test/basic.test.ts +++ b/test/basic.test.ts @@ -219,6 +219,17 @@ describe('pages', () => { await Promise.all(hiddenSelectors.map(selector => page.locator(selector).isVisible())) .then(results => results.forEach(isVisible => expect(isVisible).toBeTruthy())) }) + + it('/client-only-explicit-import', async () => { + const html = await $fetch('/client-only-explicit-import') + + // ensure fallbacks with classes and arbitrary attributes are rendered + expect(html).toContain('
') + expect(html).toContain('
') + // ensure components are not rendered server-side + expect(html).not.toContain('client only script') + await expectNoClientErrors('/client-only-components') + }) }) describe('head tags', () => { diff --git a/test/fixtures/basic/components/BreaksServer.ts b/test/fixtures/basic/components/BreaksServer.client.ts similarity index 100% rename from test/fixtures/basic/components/BreaksServer.ts rename to test/fixtures/basic/components/BreaksServer.client.ts diff --git a/test/fixtures/basic/pages/client-only-explicit-import.vue b/test/fixtures/basic/pages/client-only-explicit-import.vue new file mode 100644 index 0000000000..23463cd401 --- /dev/null +++ b/test/fixtures/basic/pages/client-only-explicit-import.vue @@ -0,0 +1,11 @@ + + + + diff --git a/test/fixtures/basic/pages/client.vue b/test/fixtures/basic/pages/client.vue index 95da86f78d..682d0272d2 100644 --- a/test/fixtures/basic/pages/client.vue +++ b/test/fixtures/basic/pages/client.vue @@ -1,12 +1,15 @@