Skip to content

Commit

Permalink
fix(nuxt): transform #components imports into direct component impo…
Browse files Browse the repository at this point in the history
…rts (#20547)

Co-authored-by: Daniel Roe <daniel@roe.dev>
  • Loading branch information
antfu and danielroe committed Apr 28, 2023
1 parent ecf4153 commit 98b20c4
Show file tree
Hide file tree
Showing 4 changed files with 135 additions and 61 deletions.
31 changes: 14 additions & 17 deletions packages/nuxt/src/components/module.ts
@@ -1,14 +1,15 @@
import { statSync } from 'node:fs'
import { relative, resolve } from 'pathe'
import { addPluginTemplate, addTemplate, defineNuxtModule, resolveAlias, updateTemplates } from '@nuxt/kit'
import { addPluginTemplate, addTemplate, addVitePlugin, addWebpackPlugin, defineNuxtModule, resolveAlias, updateTemplates } from '@nuxt/kit'
import type { Component, ComponentsDir, ComponentsOptions } from 'nuxt/schema'

import { distDir } from '../dirs'
import { clientFallbackAutoIdPlugin } from './client-fallback-auto-id'
import { componentsIslandsTemplate, componentsPluginTemplate, componentsTemplate, componentsTypeTemplate } from './templates'
import { componentNamesTemplate, componentsIslandsTemplate, componentsPluginTemplate, componentsTypeTemplate } from './templates'
import { scanComponents } from './scan'
import { loaderPlugin } from './loader'
import { TreeShakeTemplatePlugin } from './tree-shake'
import { createTransformPlugin } from './transform'

const isPureObjectOrString = (val: any) => (!Array.isArray(val) && typeof val === 'object') || typeof val === 'string'
const isDirectory = (p: string) => { try { return statSync(p).isDirectory() } catch (_e) { return false } }
Expand All @@ -18,7 +19,7 @@ function compareDirByPathLength ({ path: pathA }: { path: string }, { path: path

const DEFAULT_COMPONENTS_DIRS_RE = /\/components(\/global|\/islands)?$/

type getComponentsT = (mode?: 'client' | 'server' | 'all') => Component[]
export type getComponentsT = (mode?: 'client' | 'server' | 'all') => Component[]

export default defineNuxtModule<ComponentsOptions>({
meta: {
Expand Down Expand Up @@ -115,27 +116,23 @@ export default defineNuxtModule<ComponentsOptions>({
addTemplate({ ...componentsTypeTemplate, options: { getComponents } })
// components.plugin.mjs
addPluginTemplate({ ...componentsPluginTemplate, options: { getComponents } } as any)
// components.server.mjs
addTemplate({ ...componentsTemplate, filename: 'components.server.mjs', options: { getComponents, mode: 'server' } })
// components.client.mjs
addTemplate({ ...componentsTemplate, filename: 'components.client.mjs', options: { getComponents, mode: 'client' } })
// component-names.mjs
addTemplate({ ...componentNamesTemplate, options: { getComponents, mode: 'all' } })
// components.islands.mjs
if (nuxt.options.experimental.componentIslands) {
addTemplate({ ...componentsIslandsTemplate, filename: 'components.islands.mjs', options: { getComponents } })
} else {
addTemplate({ filename: 'components.islands.mjs', getContents: () => 'export default {}' })
}

nuxt.hook('vite:extendConfig', (config, { isClient }) => {
const mode = isClient ? 'client' : 'server'
; (config.resolve!.alias as any)['#components'] = resolve(nuxt.options.buildDir, `components.${mode}.mjs`)
})
nuxt.hook('webpack:config', (configs) => {
for (const config of configs) {
const mode = config.name === 'server' ? 'server' : 'client'
; (config.resolve!.alias as any)['#components'] = resolve(nuxt.options.buildDir, `components.${mode}.mjs`)
}
})
const unpluginServer = createTransformPlugin(nuxt, getComponents, 'server')
const unpluginClient = createTransformPlugin(nuxt, getComponents, 'client')

addVitePlugin(unpluginServer.vite(), { server: true, client: false })
addVitePlugin(unpluginClient.vite(), { server: false, client: true })

addWebpackPlugin(unpluginServer.webpack(), { server: true, client: false })
addWebpackPlugin(unpluginClient.webpack(), { server: false, client: true })

// Do not prefetch global components chunks
nuxt.hook('build:manifest', (manifest) => {
Expand Down
63 changes: 23 additions & 40 deletions packages/nuxt/src/components/templates.ts
@@ -1,5 +1,5 @@
import { isAbsolute, relative } from 'pathe'
import { genDynamicImport, genExport, genImport, genObjectFromRawEntries } from 'knitwork'
import { genDynamicImport } from 'knitwork'
import type { Component, Nuxt, NuxtPluginTemplate, NuxtTemplate } from 'nuxt/schema'

export interface ComponentsTemplateContext {
Expand All @@ -25,59 +25,42 @@ const createImportMagicComments = (options: ImportMagicCommentsOptions) => {
].filter(Boolean).join(', ')
}

const emptyComponentsPlugin = `
import { defineNuxtPlugin } from '#app/nuxt'
export default defineNuxtPlugin({
name: 'nuxt:global-components',
})
`

export const componentsPluginTemplate: NuxtPluginTemplate<ComponentsTemplateContext> = {
filename: 'components.plugin.mjs',
getContents ({ options }) {
const globalComponents = options.getComponents().filter(c => c.global)
if (!globalComponents.length) { return emptyComponentsPlugin }

return `import { defineNuxtPlugin } from '#app/nuxt'
import { lazyGlobalComponents } from '#components'
import { ${globalComponents.map(c => 'Lazy' + c.pascalName).join(', ')} } from '#components'
const lazyGlobalComponents = [
${globalComponents.map(c => `["${c.pascalName}", Lazy${c.pascalName}]`).join(',\n')}
]
export default defineNuxtPlugin({
name: 'nuxt:global-components',` +
(options.getComponents().filter(c => c.global).length
? `
name: 'nuxt:global-components',
setup (nuxtApp) {
for (const name in lazyGlobalComponents) {
nuxtApp.vueApp.component(name, lazyGlobalComponents[name])
nuxtApp.vueApp.component('Lazy' + name, lazyGlobalComponents[name])
for (const [name, component] of lazyGlobalComponents) {
nuxtApp.vueApp.component(name, component)
nuxtApp.vueApp.component('Lazy' + name, component)
}
}`
: '') + `
}
})
`
}
}

export const componentsTemplate: NuxtTemplate<ComponentsTemplateContext> = {
// components.[server|client].mjs'
export const componentNamesTemplate: NuxtPluginTemplate<ComponentsTemplateContext> = {
filename: 'component-names.mjs',
getContents ({ options }) {
const imports = new Set<string>()
imports.add('import { defineAsyncComponent } from \'vue\'')

let num = 0
const components = options.getComponents(options.mode).filter(c => !c.island).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} = /* #__PURE__ */ defineAsyncComponent(${genDynamicImport(c.filePath, { comment })}.then(c => ${isClient ? `createClientOnly(${exp})` : exp}))`)
return definitions
})
return [
...imports,
...components,
`export const lazyGlobalComponents = ${genObjectFromRawEntries(options.getComponents().filter(c => c.global).map(c => [c.pascalName, `Lazy${c.pascalName}`]))}`,
`export const componentNames = ${JSON.stringify(options.getComponents().filter(c => !c.island).map(c => c.pascalName))}`
].join('\n')
return `export const componentNames = ${JSON.stringify(options.getComponents().filter(c => !c.island).map(c => c.pascalName))}`
}
}

Expand Down
95 changes: 95 additions & 0 deletions packages/nuxt/src/components/transform.ts
@@ -0,0 +1,95 @@
import { isIgnored } from '@nuxt/kit'
import type { Nuxt } from 'nuxt/schema'
import type { Import } from 'unimport'
import { createUnimport } from 'unimport'
import { createUnplugin } from 'unplugin'
import { parseURL } from 'ufo'
import { parseQuery } from 'vue-router'
import type { getComponentsT } from './module'

export function createTransformPlugin (nuxt: Nuxt, getComponents: getComponentsT, mode: 'client' | 'server' | 'all') {
const componentUnimport = createUnimport({
imports: [
{
name: 'componentNames',
from: '#build/component-names'
}
],
virtualImports: ['#components']
})

function getComponentsImports (): Import[] {
const components = getComponents(mode)
return components.flatMap((c): Import[] => {
return [
{
as: c.pascalName,
from: c.filePath + (c.mode === 'client' ? '?component=client' : ''),
name: 'default'
},
{
as: 'Lazy' + c.pascalName,
from: c.filePath + '?component=' + [c.mode === 'client' ? 'client' : '', 'async'].filter(Boolean).join(','),
name: 'default'
}
]
})
}

return createUnplugin(() => ({
name: 'nuxt:components:imports',
transformInclude (id) {
return !isIgnored(id)
},
async transform (code, id) {
// Virtual component wrapper
if (id.includes('?component')) {
const { search } = parseURL(id)
const query = parseQuery(search)
const mode = query.component
const bare = id.replace(/\?.*/, '')
if (mode === 'async') {
return [
'import { defineAsyncComponent } from "vue"',
`export default defineAsyncComponent(() => import(${JSON.stringify(bare)}).then(r => r.default))`
].join('\n')
} else if (mode === 'client') {
return [
`import __component from ${JSON.stringify(bare)}`,
'import { createClientOnly } from "#app/components/client-only"',
'export default createClientOnly(__component)'
].join('\n')
} else if (mode === 'client,async') {
return [
'import { defineAsyncComponent } from "vue"',
'import { createClientOnly } from "#app/components/client-only"',
`export default defineAsyncComponent(() => import(${JSON.stringify(bare)}).then(r => createClientOnly(r.default)))`
].join('\n')
} else {
throw new Error(`Unknown component mode: ${mode}, this might be an internal bug of Nuxt.`)
}
}

if (!code.includes('#components')) {
return null
}

componentUnimport.modifyDynamicImports((imports) => {
imports.length = 0
imports.push(...getComponentsImports())
return imports
})

const result = await componentUnimport.injectImports(code, id, { autoImport: false, transformVirtualImports: true })
if (!result) {
return null
}
return {
code: result.code,
map: nuxt.options.sourcemap
? result.s.generateMap({ hires: true })
: undefined
}
}
}))
}
7 changes: 3 additions & 4 deletions test/bundle.test.ts
Expand Up @@ -34,7 +34,7 @@ describe.skipIf(isWindows || process.env.TEST_BUILDER === 'webpack' || process.e

it('default client bundle size', async () => {
stats.client = await analyzeSizes('**/*.js', publicDir)
expect(roundToKilobytes(stats.client.totalBytes)).toMatchInlineSnapshot('"94.4k"')
expect(roundToKilobytes(stats.client.totalBytes)).toMatchInlineSnapshot('"94.3k"')
expect(stats.client.files.map(f => f.replace(/\..*\.js/, '.js'))).toMatchInlineSnapshot(`
[
"_nuxt/entry.js",
Expand All @@ -45,10 +45,10 @@ describe.skipIf(isWindows || process.env.TEST_BUILDER === 'webpack' || process.e

it('default server bundle size', async () => {
stats.server = await analyzeSizes(['**/*.mjs', '!node_modules'], serverDir)
expect(roundToKilobytes(stats.server.totalBytes)).toMatchInlineSnapshot('"67.3k"')
expect(roundToKilobytes(stats.server.totalBytes)).toMatchInlineSnapshot('"66.8k"')

const modules = await analyzeSizes('node_modules/**/*', serverDir)
expect(roundToKilobytes(modules.totalBytes)).toMatchInlineSnapshot('"2657k"')
expect(roundToKilobytes(modules.totalBytes)).toMatchInlineSnapshot('"2654k"')

const packages = modules.files
.filter(m => m.endsWith('package.json'))
Expand Down Expand Up @@ -76,7 +76,6 @@ describe.skipIf(isWindows || process.env.TEST_BUILDER === 'webpack' || process.e
"h3",
"hookable",
"iron-webcrypto",
"klona",
"node-fetch-native",
"ofetch",
"ohash",
Expand Down

0 comments on commit 98b20c4

Please sign in to comment.