Skip to content

Commit 4a36eb1

Browse files
authored
fix(lazy-load): use NuxtComponent name for auto-imports (#235)
1 parent 51cbf34 commit 4a36eb1

File tree

2 files changed

+127
-15
lines changed

2 files changed

+127
-15
lines changed

src/plugins/lazy-load.ts

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,26 @@
11
import { genImport } from 'knitwork'
22
import MagicString from 'magic-string'
3-
import { basename, resolve } from 'node:path'
3+
import { parse, resolve } from 'node:path'
44
import { parseSync, type CallExpression, type ImportDeclaration, type ImportDefaultSpecifier, type ImportSpecifier } from 'oxc-parser'
55
import { createUnplugin } from 'unplugin'
66
import { distDir } from '../dirs'
77
import { findDefineComponentCalls } from './utils'
8+
import { useNuxt } from '@nuxt/kit'
9+
import type { Component } from '@nuxt/schema'
810

911
const INCLUDE_FILES = /\.(vue|tsx?|jsx?)$/
1012
// Exclude node_moduels as users can have control over it
1113
const EXCLUDE_NODE_MODULES = /node_modules/
1214
const skipPath = normalizePath(resolve(distDir, 'runtime/lazy-load'))
1315

1416
export const LazyLoadHintPlugin = createUnplugin(() => {
17+
const nuxt = useNuxt()
18+
19+
let nuxtComponents: Component[] = nuxt.apps.default!.components
20+
nuxt.hook('components:extend', (extendedComponents) => {
21+
nuxtComponents = extendedComponents
22+
})
23+
1524
return {
1625
name: '@nuxt/hints:lazy-load-plugin',
1726
enforce: 'post',
@@ -77,8 +86,9 @@ export const LazyLoadHintPlugin = createUnplugin(() => {
7786
const wrapperStatements = directComponentImports
7887
.map((imp) => {
7988
const originalName = `__original_${imp.name}`
89+
const resolvedName = resolveComponentName(imp, nuxtComponents)
8090
// Rename the import to __original_X and create a wrapped version as X
81-
return `const ${imp.name} = __wrapImportedComponent(${originalName}, '${imp.name}', '${imp.source}', '${normalizePath(id)}')`
91+
return `const ${imp.name} = __wrapImportedComponent(${originalName}, '${resolvedName}', '${imp.source}', '${normalizePath(id)}')`
8292
})
8393
.join('\n')
8494

@@ -117,20 +127,16 @@ export const LazyLoadHintPlugin = createUnplugin(() => {
117127
// Inject useLazyComponentTracking in main component setup if applicable
118128
if (code.includes('_sfc_main')) {
119129
const wrappedComponents = directComponentImports.map((imp) => {
120-
if (imp.name.startsWith('__nuxt')) {
121-
// Auto imported components are using __nuxt_component_
122-
// See nuxt loadeer plugin
123-
return `{ componentName: '${basename(imp.source)}', importSource: '${imp.source}', importedBy: '${normalizePath(id)}', rendered: false }`
124-
}
125-
return `{ componentName: '${imp.name}', importSource: '${imp.source}', importedBy: '${normalizePath(id)}', rendered: false }`
130+
const componentName = resolveComponentName(imp, nuxtComponents)
131+
return `{ componentName: '${componentName}', importSource: '${imp.source}', importedBy: '${normalizePath(id)}', rendered: false }`
126132
}).join(', ')
127133
m.replace('export default _sfc_main', `const _sfc_main_wrapped = __wrapMainComponent(_sfc_main, [${wrappedComponents}]);\nexport default _sfc_main_wrapped`)
128134
}
129135
const components = findDefineComponentCalls(program)
130136

131137
if (components && components.length > 0) {
132138
for (const comp of components) {
133-
injectUseLazyComponentTrackingInComponentSetup(comp, m, directComponentImports, id)
139+
injectUseLazyComponentTrackingInComponentSetup(comp, m, directComponentImports, id, nuxtComponents)
134140
}
135141
}
136142

@@ -151,13 +157,22 @@ function normalizePath(path: string): string {
151157
return path.replace(/\\/g, '/')
152158
}
153159

160+
function resolveComponentName(
161+
imp: { name: string, source: string },
162+
nuxtComponents: Component[],
163+
): string {
164+
const component = nuxtComponents.find(c => c.filePath === imp.source)
165+
if (component) return component.pascalName
166+
return imp.name.startsWith('__nuxt') ? parse(imp.source).name : imp.name
167+
}
168+
154169
function injectUseLazyComponentTrackingInComponentSetup(node: CallExpression, magicString: MagicString, directComponentImports: {
155170
name: string
156171
source: string
157172
start: number
158173
end: number
159174
specifier: ImportDefaultSpecifier | ImportSpecifier
160-
}[], id: string) {
175+
}[], id: string, nuxtComponents: Component[]) {
161176
if (node.arguments.length === 1) {
162177
const arg = node.arguments[0]
163178
if (arg?.type === 'ObjectExpression') {
@@ -173,7 +188,7 @@ function injectUseLazyComponentTrackingInComponentSetup(node: CallExpression, ma
173188
// Inject useLazyComponentTracking call at the start of the setup function body
174189
const insertPos = (setupFunc.body?.start ?? 0) + 1 // after {
175190
const componentsArray = directComponentImports.map((imp) => {
176-
const componentName = imp.name.startsWith('__nuxt') ? basename(imp.source) : imp.name
191+
const componentName = resolveComponentName(imp, nuxtComponents)
177192
return `{ componentName: '${componentName}', importSource: '${imp.source}', importedBy: '${normalizePath(id)}', rendered: false }`
178193
}).join(', ')
179194
const injectionCode = `\nconst lazyHydrationState = useLazyComponentTracking([${componentsArray}]);\n`

test/unit/hydration/lazy-hydration-plugin.test.ts

Lines changed: 101 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,32 @@
11
/* eslint-disable @typescript-eslint/no-explicit-any */
2-
import { describe, it, expect } from 'vitest'
3-
import { LazyLoadHintPlugin } from '../../../src/plugins/lazy-load'
2+
import { describe, it, expect, vi } from 'vitest'
43
import type { Plugin } from 'vite'
5-
import type { ObjectHook } from 'unplugin'
4+
import type { HookFnMap, ObjectHook, UnpluginBuildContext, UnpluginContext } from 'unplugin'
5+
import { LazyLoadHintPlugin } from '../../../src/plugins/lazy-load'
6+
import { useNuxt } from '@nuxt/kit'
7+
8+
vi.mock('@nuxt/kit', () => ({
9+
useNuxt: vi.fn(() => ({
10+
apps: {
11+
default: {
12+
components: [],
13+
},
14+
},
15+
hook: vi.fn(),
16+
})),
17+
}))
618

719
const plugin = LazyLoadHintPlugin.vite() as Plugin
820
const transform = (plugin.transform as ObjectHook<any, any>).handler
9-
21+
const unpluginCtx: UnpluginBuildContext & UnpluginContext = {
22+
addWatchFile: vi.fn(),
23+
emitFile: vi.fn(),
24+
getWatchFiles: vi.fn(),
25+
parse: vi.fn(),
26+
getNativeBuildContext: vi.fn(),
27+
error: vi.fn(),
28+
warn: vi.fn(),
29+
}
1030
describe('LazyLoadHintPlugin', () => {
1131
describe('default imports', () => {
1232
it('should wrap a default import from a .vue file', async () => {
@@ -67,6 +87,83 @@ describe('LazyLoadHintPlugin', () => {
6787
})
6888
})
6989

90+
describe('skipped imports', () => {
91+
it('should not transform type-only imports', async () => {
92+
const code = `import type MyComp from './MyComp.vue'\nexport default {}`
93+
const result = await transform(code, '/src/Parent.vue')
94+
expect(result).toBeUndefined()
95+
})
96+
97+
it('should return undefined when there are no .vue imports at all', async () => {
98+
const code = `const x = 1\nexport default { x }`
99+
const result = await transform(code, '/src/Parent.vue')
100+
expect(result).toBeUndefined()
101+
})
102+
})
103+
104+
describe('nuxt auto-imported components (__nuxt prefix)', () => {
105+
it('should use file basename as componentName for __nuxt prefixed imports in _sfc_main', async () => {
106+
const code = [
107+
`import __nuxt_component_0 from './ChildComp.vue'`,
108+
`const _sfc_main = {}`,
109+
`export default _sfc_main`,
110+
].join('\n')
111+
const result = await transform(code, '/src/Parent.vue')
112+
expect(result.code).toContain(`componentName: 'ChildComp'`)
113+
expect(result.code).toContain(`importSource: './ChildComp.vue'`)
114+
})
115+
116+
it('should use file basename as componentName for __nuxt prefixed imports in defineComponent setup', async () => {
117+
const code = [
118+
`import { defineComponent } from 'vue'`,
119+
`import __nuxt_component_0 from './SomeWidget.vue'`,
120+
`export default defineComponent({`,
121+
` setup() {`,
122+
` return {}`,
123+
` }`,
124+
`})`,
125+
].join('\n')
126+
const result = await transform(code, '/src/Parent.ts')
127+
expect(result.code).toContain(`componentName: 'SomeWidget'`)
128+
expect(result.code).toContain('useLazyComponentTracking(')
129+
})
130+
})
131+
132+
describe('nuxtComponents matching (pascalName)', () => {
133+
it('should use pascalName from nuxtComponents when component filePath matches', async () => {
134+
vi.mocked(useNuxt).mockReturnValueOnce({
135+
apps: {
136+
// @ts-expect-error partial mock
137+
default: {
138+
components: [
139+
{
140+
filePath: './MyWidget.vue',
141+
pascalName: 'MyWidgetPascal',
142+
kebabName: '',
143+
export: '',
144+
shortPath: '',
145+
chunkName: '',
146+
prefetch: false,
147+
preload: false,
148+
},
149+
],
150+
},
151+
},
152+
hook: vi.fn(),
153+
})
154+
155+
const { LazyLoadHintPlugin: PluginWithComponents } = await import('../../../src/plugins/lazy-load')
156+
const p = PluginWithComponents.vite() as Plugin
157+
const t = (p.transform as ObjectHook<HookFnMap['transform'], 'code' | 'id'>).handler
158+
159+
const code = `import MyWidget from './MyWidget.vue'\nexport default { components: { MyWidget } }`
160+
const result = await t.call(unpluginCtx, code, '/src/Parent.vue') as unknown as { code: string }
161+
expect(result.code).toContain(
162+
`__wrapImportedComponent(__original_MyWidget, 'MyWidgetPascal', './MyWidget.vue'`,
163+
)
164+
})
165+
})
166+
70167
describe('defineComponent setup injection', () => {
71168
it('should inject useLazyComponentTracking in defineComponent setup', async () => {
72169
const code = [

0 commit comments

Comments
 (0)