From ee41bb6d5d8fa7ce52f1cec2208ce2fc7bcbd3de Mon Sep 17 00:00:00 2001 From: Julien Huang <63512348+huang-julien@users.noreply.github.com> Date: Tue, 11 Oct 2022 17:26:03 +0200 Subject: [PATCH] feat(nuxt): wrap `#components` client exports with createClientOnly (#7412) Co-authored-by: jhuang@hsk-partners.com --- .../2.directory-structure/1.components.md | 2 +- packages/nuxt/src/components/module.ts | 12 +++++++ packages/nuxt/src/components/templates.ts | 36 +++++++++++++------ test/basic.test.ts | 11 ++++++ ...BreaksServer.ts => BreaksServer.client.ts} | 0 .../pages/client-only-explicit-import.vue | 11 ++++++ test/fixtures/basic/pages/client.vue | 19 +++++----- 7 files changed, 71 insertions(+), 20 deletions(-) rename test/fixtures/basic/components/{BreaksServer.ts => BreaksServer.client.ts} (100%) create mode 100644 test/fixtures/basic/pages/client-only-explicit-import.vue 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 8bac9fa14e9..d85986fac23 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 891617d6543..b7fc286119f 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 bf085cb0be3..00b3356dbfc 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\'') + + 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) - return [ - genExport(c.filePath, [{ name: c.export, as: c.pascalName }]), - `export const Lazy${c.pascalName} = defineAsyncComponent(${genDynamicImport(c.filePath, { comment })}.then(c => ${exp}))` - ] - }), + 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 c288fc1244c..cc964d76fce 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 00000000000..23463cd4019 --- /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 95da86f78d4..682d0272d28 100644 --- a/test/fixtures/basic/pages/client.vue +++ b/test/fixtures/basic/pages/client.vue @@ -1,12 +1,15 @@