From b38dc097f6cfc948d85187cab4bce3b8b6572a44 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Tue, 19 Apr 2022 20:13:55 +0100 Subject: [PATCH] feat(nuxt3): allow separating client and server components (#4390) Co-authored-by: Anthony Fu --- examples/auto-imports/components/app.vue | 4 ++++ .../components/ClientAndServer.client.vue | 10 ++++++++ .../components/ClientAndServer.server.vue | 10 ++++++++ .../components/JustClient.client.vue | 10 ++++++++ packages/kit/src/components.ts | 1 + .../nuxt3/src/app/components/client-only.mjs | 16 +++++++++++++ .../src/app/components/server-placeholder.ts | 8 +++++++ packages/nuxt3/src/components/loader.ts | 23 +++++++++++++------ packages/nuxt3/src/components/module.ts | 22 ++++++++++++++---- packages/nuxt3/src/components/scan.ts | 17 +++++++++----- packages/nuxt3/src/components/templates.ts | 11 +++++---- packages/nuxt3/src/core/nuxt.ts | 6 +++++ .../{Nuxt3.vue => Nuxt3.client.vue} | 0 .../test/fixture/components/Nuxt3.server.vue | 5 ++++ .../{index.vue => index.server.vue} | 0 packages/nuxt3/test/scan-components.test.ts | 22 ++++++++++++++---- packages/schema/src/types/components.ts | 1 + 17 files changed, 140 insertions(+), 26 deletions(-) create mode 100644 examples/auto-imports/components/components/ClientAndServer.client.vue create mode 100644 examples/auto-imports/components/components/ClientAndServer.server.vue create mode 100644 examples/auto-imports/components/components/JustClient.client.vue create mode 100644 packages/nuxt3/src/app/components/server-placeholder.ts rename packages/nuxt3/test/fixture/components/{Nuxt3.vue => Nuxt3.client.vue} (100%) create mode 100644 packages/nuxt3/test/fixture/components/Nuxt3.server.vue rename packages/nuxt3/test/fixture/components/parent-folder/{index.vue => index.server.vue} (100%) diff --git a/examples/auto-imports/components/app.vue b/examples/auto-imports/components/app.vue index fe3d060950..fdbfbb8949 100644 --- a/examples/auto-imports/components/app.vue +++ b/examples/auto-imports/components/app.vue @@ -8,6 +8,10 @@ + +
[Slot]
+
+ diff --git a/examples/auto-imports/components/components/ClientAndServer.client.vue b/examples/auto-imports/components/components/ClientAndServer.client.vue new file mode 100644 index 0000000000..3e055a3d66 --- /dev/null +++ b/examples/auto-imports/components/components/ClientAndServer.client.vue @@ -0,0 +1,10 @@ + + + diff --git a/examples/auto-imports/components/components/ClientAndServer.server.vue b/examples/auto-imports/components/components/ClientAndServer.server.vue new file mode 100644 index 0000000000..8e1af3bf58 --- /dev/null +++ b/examples/auto-imports/components/components/ClientAndServer.server.vue @@ -0,0 +1,10 @@ + + + diff --git a/examples/auto-imports/components/components/JustClient.client.vue b/examples/auto-imports/components/components/JustClient.client.vue new file mode 100644 index 0000000000..68549aef07 --- /dev/null +++ b/examples/auto-imports/components/components/JustClient.client.vue @@ -0,0 +1,10 @@ + + + diff --git a/packages/kit/src/components.ts b/packages/kit/src/components.ts index 44092da047..b86c1cb83c 100644 --- a/packages/kit/src/components.ts +++ b/packages/kit/src/components.ts @@ -39,6 +39,7 @@ export async function addComponent (opts: AddComponentOptions) { pascalName: pascalCase(opts.name || ''), prefetch: false, preload: false, + mode: 'all', // Nuxt 2 support shortPath: opts.filePath, diff --git a/packages/nuxt3/src/app/components/client-only.mjs b/packages/nuxt3/src/app/components/client-only.mjs index 45054c56b3..28421c6000 100644 --- a/packages/nuxt3/src/app/components/client-only.mjs +++ b/packages/nuxt3/src/app/components/client-only.mjs @@ -17,3 +17,19 @@ export default defineComponent({ } } }) + +export function createClientOnly (component) { + return defineComponent({ + name: 'ClientOnlyWrapper', + setup (props, { attrs, slots }) { + const mounted = ref(false) + onMounted(() => { mounted.value = true }) + return () => { + if (mounted.value) { + return h(component, { props, attrs }, slots) + } + return h('div') + } + } + }) +} diff --git a/packages/nuxt3/src/app/components/server-placeholder.ts b/packages/nuxt3/src/app/components/server-placeholder.ts new file mode 100644 index 0000000000..4fcc1db8a3 --- /dev/null +++ b/packages/nuxt3/src/app/components/server-placeholder.ts @@ -0,0 +1,8 @@ +import { defineComponent, createElementBlock } from 'vue' + +export default defineComponent({ + name: 'ServerPlaceholder', + render () { + return createElementBlock('div') + } +}) diff --git a/packages/nuxt3/src/components/loader.ts b/packages/nuxt3/src/components/loader.ts index bb94fc3487..b90f082ce0 100644 --- a/packages/nuxt3/src/components/loader.ts +++ b/packages/nuxt3/src/components/loader.ts @@ -8,6 +8,7 @@ import { pascalCase } from 'scule' interface LoaderOptions { getComponents(): Component[] + mode: 'server' | 'client' } export const loaderPlugin = createUnplugin((options: LoaderOptions) => ({ @@ -21,16 +22,20 @@ export const loaderPlugin = createUnplugin((options: LoaderOptions) => ({ return pathname.endsWith('.vue') && (query.type === 'template' || !!query.macro || !search) }, transform (code, id) { - return transform(code, id, options.getComponents()) + return transform(code, id, options.getComponents(), options.mode) } })) -function findComponent (components: Component[], name: string) { +function findComponent (components: Component[], name: string, mode: LoaderOptions['mode']) { const id = pascalCase(name).replace(/["']/g, '') - return components.find(component => id === component.pascalName) + const component = components.find(component => id === component.pascalName && ['all', mode, undefined].includes(component.mode)) + if (!component && components.some(component => id === component.pascalName)) { + return components.find(component => component.pascalName === 'ServerPlaceholder') + } + return component } -function transform (code: string, id: string, components: Component[]) { +function transform (code: string, id: string, components: Component[], mode: LoaderOptions['mode']) { let num = 0 const imports = new Set() const map = new Map() @@ -38,17 +43,21 @@ function transform (code: string, id: string, components: Component[]) { // replace `_resolveComponent("...")` to direct import s.replace(/(?<=[ (])_?resolveComponent\(["'](lazy-|Lazy)?([^'"]*?)["']\)/g, (full, lazy, name) => { - const component = findComponent(components, name) + const component = findComponent(components, name, mode) if (component) { const identifier = map.get(component) || `__nuxt_component_${num++}` map.set(component, identifier) + const isClientOnly = component.mode === 'client' + if (isClientOnly) { + imports.add(genImport('#app/components/client-only', [{ name: 'createClientOnly' }])) + } if (lazy) { // Nuxt will auto-import `defineAsyncComponent` for us imports.add(`const ${identifier}_lazy = defineAsyncComponent(${genDynamicImport(component.filePath)})`) - return `${identifier}_lazy` + return isClientOnly ? `createClientOnly(${identifier}_lazy)` : `${identifier}_lazy` } else { imports.add(genImport(component.filePath, [{ name: component.export, as: identifier }])) - return identifier + return isClientOnly ? `createClientOnly(${identifier})` : identifier } } // no matched diff --git a/packages/nuxt3/src/components/module.ts b/packages/nuxt3/src/components/module.ts index 5dd0015d82..c546a1b71a 100644 --- a/packages/nuxt3/src/components/module.ts +++ b/packages/nuxt3/src/components/module.ts @@ -1,6 +1,6 @@ import { statSync } from 'node:fs' import { resolve, basename } from 'pathe' -import { defineNuxtModule, resolveAlias, addVitePlugin, addWebpackPlugin, addTemplate, addPluginTemplate } from '@nuxt/kit' +import { defineNuxtModule, resolveAlias, addTemplate, addPluginTemplate } from '@nuxt/kit' import type { Component, ComponentsDir, ComponentsOptions } from '@nuxt/schema' import { componentsPluginTemplate, componentsTemplate, componentsTypeTemplate } from './templates' import { scanComponents } from './scan' @@ -128,8 +128,22 @@ export default defineNuxtModule({ } }) - const loaderOptions = { getComponents: () => options.components } - addWebpackPlugin(loaderPlugin.webpack(loaderOptions)) - addVitePlugin(loaderPlugin.vite(loaderOptions)) + const getComponents = () => options.components + nuxt.hook('vite:extendConfig', (config, { isClient }) => { + config.plugins = config.plugins || [] + config.plugins.push(loaderPlugin.vite({ + getComponents, + mode: isClient ? 'client' : 'server' + })) + }) + nuxt.hook('webpack:config', (configs) => { + configs.forEach((config) => { + config.plugins = config.plugins || [] + config.plugins.push(loaderPlugin.webpack({ + getComponents, + mode: config.name === 'client' ? 'client' : 'server' + })) + }) + }) } }) diff --git a/packages/nuxt3/src/components/scan.ts b/packages/nuxt3/src/components/scan.ts index f0e5c8d942..8af5604d1d 100644 --- a/packages/nuxt3/src/components/scan.ts +++ b/packages/nuxt3/src/components/scan.ts @@ -60,6 +60,9 @@ export async function scanComponents (dirs: ComponentsDir[], srcDir: string): Pr */ let fileName = basename(filePath, extname(filePath)) + const mode = fileName.match(/(?<=\.)(client|server)$/)?.[0] as 'client' | 'server' || 'all' + fileName = fileName.replace(/\.(client|server)$/, '') + if (fileName.toLowerCase() === 'index') { fileName = dir.pathPrefix === false ? basename(dirname(filePath)) : '' /* inherits from path */ } @@ -81,20 +84,21 @@ export async function scanComponents (dirs: ComponentsDir[], srcDir: string): Pr } const componentName = pascalCase(componentNameParts) + pascalCase(fileNameParts) + const suffix = (mode !== 'all' ? `-${mode}` : '') - if (resolvedNames.has(componentName)) { + if (resolvedNames.has(componentName + suffix) || resolvedNames.has(componentName)) { console.warn(`Two component files resolving to the same name \`${componentName}\`:\n` + `\n - ${filePath}` + `\n - ${resolvedNames.get(componentName)}` ) continue } - resolvedNames.set(componentName, filePath) + resolvedNames.set(componentName + suffix, filePath) const pascalName = pascalCase(componentName).replace(/["']/g, '') const kebabName = hyphenate(componentName) const shortPath = relative(srcDir, filePath) - const chunkName = 'components/' + kebabName + const chunkName = 'components/' + kebabName + suffix let component: Component = { filePath, @@ -105,15 +109,16 @@ export async function scanComponents (dirs: ComponentsDir[], srcDir: string): Pr export: 'default', global: dir.global, prefetch: Boolean(dir.prefetch), - preload: Boolean(dir.preload) + preload: Boolean(dir.preload), + mode } if (typeof dir.extendComponent === 'function') { component = (await dir.extendComponent(component)) || component } - // Ignore component if component is already defined - if (!components.find(c => c.pascalName === component.pascalName)) { + // Ignore component if component is already defined (with same mode) + if (!components.some(c => c.pascalName === component.pascalName && ['all', component.mode].includes(c.mode))) { components.push(component) } } diff --git a/packages/nuxt3/src/components/templates.ts b/packages/nuxt3/src/components/templates.ts index 26429e014e..29ffdeaceb 100644 --- a/packages/nuxt3/src/components/templates.ts +++ b/packages/nuxt3/src/components/templates.ts @@ -1,7 +1,7 @@ - import { isAbsolute, relative } from 'pathe' import type { Component } from '@nuxt/schema' import { genDynamicImport, genExport, genObjectFromRawEntries } from 'knitwork' +import { upperFirst } from 'scule' export type ComponentsTemplateOptions = { buildDir?: string @@ -53,10 +53,11 @@ export const componentsTemplate = { ...options.components.flatMap((c) => { const exp = c.export === 'default' ? 'c.default || c' : `c['${c.export}']` const comment = createImportMagicComments(c) + const nameWithSuffix = `${c.pascalName}${c.mode !== 'all' ? upperFirst(c.mode) : ''}` return [ - genExport(c.filePath, [{ name: c.export, as: c.pascalName }]), - `export const Lazy${c.pascalName} = defineAsyncComponent(${genDynamicImport(c.filePath, { comment })}.then(c => ${exp}))` + genExport(c.filePath, [{ name: c.export, as: nameWithSuffix }]), + `export const Lazy${nameWithSuffix} = defineAsyncComponent(${genDynamicImport(c.filePath, { comment })}.then(c => ${exp}))` ] }), `export const componentNames = ${JSON.stringify(options.components.map(c => c.pascalName))}` @@ -73,8 +74,8 @@ ${options.components.map(c => ` '${c.pascalName}': typeof ${genDynamicImport( ${options.components.map(c => ` 'Lazy${c.pascalName}': typeof ${genDynamicImport(isAbsolute(c.filePath) ? relative(options.buildDir, c.filePath) : c.filePath, { wrapper: false })}['${c.export}']`).join(',\n')} } } -${options.components.map(c => `export const ${c.pascalName}: typeof ${genDynamicImport(isAbsolute(c.filePath) ? relative(options.buildDir, c.filePath) : c.filePath, { wrapper: false })}['${c.export}']`).join('\n')} -${options.components.map(c => `export const Lazy${c.pascalName}: typeof ${genDynamicImport(isAbsolute(c.filePath) ? relative(options.buildDir, c.filePath) : c.filePath, { wrapper: false })}['${c.export}']`).join('\n')} +${options.components.map(c => `export const ${c.pascalName}${c.mode !== 'all' ? upperFirst(c.mode) : ''}: typeof ${genDynamicImport(isAbsolute(c.filePath) ? relative(options.buildDir, c.filePath) : c.filePath, { wrapper: false })}['${c.export}']`).join('\n')} +${options.components.map(c => `export const Lazy${c.pascalName}${c.mode !== 'all' ? upperFirst(c.mode) : ''}: typeof ${genDynamicImport(isAbsolute(c.filePath) ? relative(options.buildDir, c.filePath) : c.filePath, { wrapper: false })}['${c.export}']`).join('\n')} export const componentNames: string[] ` } diff --git a/packages/nuxt3/src/core/nuxt.ts b/packages/nuxt3/src/core/nuxt.ts index 39bbd80a5f..81b49b8c73 100644 --- a/packages/nuxt3/src/core/nuxt.ts +++ b/packages/nuxt3/src/core/nuxt.ts @@ -97,6 +97,12 @@ async function initNuxt (nuxt: Nuxt) { filePath: resolve(nuxt.options.appDir, 'components/client-only') }) + // Add + addComponent({ + name: 'ServerPlaceholder', + filePath: resolve(nuxt.options.appDir, 'components/server-placeholder') + }) + // Add addComponent({ name: 'NuxtLink', diff --git a/packages/nuxt3/test/fixture/components/Nuxt3.vue b/packages/nuxt3/test/fixture/components/Nuxt3.client.vue similarity index 100% rename from packages/nuxt3/test/fixture/components/Nuxt3.vue rename to packages/nuxt3/test/fixture/components/Nuxt3.client.vue diff --git a/packages/nuxt3/test/fixture/components/Nuxt3.server.vue b/packages/nuxt3/test/fixture/components/Nuxt3.server.vue new file mode 100644 index 0000000000..0faa5c7efe --- /dev/null +++ b/packages/nuxt3/test/fixture/components/Nuxt3.server.vue @@ -0,0 +1,5 @@ + diff --git a/packages/nuxt3/test/fixture/components/parent-folder/index.vue b/packages/nuxt3/test/fixture/components/parent-folder/index.server.vue similarity index 100% rename from packages/nuxt3/test/fixture/components/parent-folder/index.vue rename to packages/nuxt3/test/fixture/components/parent-folder/index.server.vue diff --git a/packages/nuxt3/test/scan-components.test.ts b/packages/nuxt3/test/scan-components.test.ts index 326ed629d9..8bbdd03777 100644 --- a/packages/nuxt3/test/scan-components.test.ts +++ b/packages/nuxt3/test/scan-components.test.ts @@ -58,6 +58,7 @@ const dirs: ComponentsDir[] = [ const expectedComponents = [ { + mode: 'all', pascalName: 'HelloWorld', kebabName: 'hello-world', chunkName: 'components/hello-world', @@ -68,20 +69,33 @@ const expectedComponents = [ preload: false }, { + mode: 'client', pascalName: 'Nuxt3', kebabName: 'nuxt3', - chunkName: 'components/nuxt3', - shortPath: 'components/Nuxt3.vue', + chunkName: 'components/nuxt3-client', + shortPath: 'components/Nuxt3.client.vue', export: 'default', global: undefined, prefetch: false, preload: false }, { + mode: 'server', + pascalName: 'Nuxt3', + kebabName: 'nuxt3', + chunkName: 'components/nuxt3-server', + shortPath: 'components/Nuxt3.server.vue', + export: 'default', + global: undefined, + prefetch: false, + preload: false + }, + { + mode: 'server', pascalName: 'ParentFolder', kebabName: 'parent-folder', - chunkName: 'components/parent-folder', - shortPath: 'components/parent-folder/index.vue', + chunkName: 'components/parent-folder-server', + shortPath: 'components/parent-folder/index.server.vue', export: 'default', global: undefined, prefetch: false, diff --git a/packages/schema/src/types/components.ts b/packages/schema/src/types/components.ts index a575cf995d..57e3abe792 100644 --- a/packages/schema/src/types/components.ts +++ b/packages/schema/src/types/components.ts @@ -8,6 +8,7 @@ export interface Component { prefetch: boolean preload: boolean global?: boolean + mode?: 'client' | 'server' | 'all' /** @deprecated */ level?: number