Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 14 additions & 4 deletions src/plugins/lazy-load.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,26 @@
import { genImport } from 'knitwork'
import MagicString from 'magic-string'
import { basename, resolve } from 'node:path'
import { parse, resolve } from 'node:path'
import { parseSync, type CallExpression, type ImportDeclaration, type ImportDefaultSpecifier, type ImportSpecifier } from 'oxc-parser'
import { createUnplugin } from 'unplugin'
import { distDir } from '../dirs'
import { findDefineComponentCalls } from './utils'
import { useNuxt } from '@nuxt/kit'
import type { Component } from '@nuxt/schema'

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

export const LazyLoadHintPlugin = createUnplugin(() => {
const nuxt = useNuxt()

let nuxtComponents: Component[] = nuxt.apps.default!.components
nuxt.hook('components:extend', (extendedComponents) => {
nuxtComponents = extendedComponents
})

return {
name: '@nuxt/hints:lazy-load-plugin',
enforce: 'post',
Expand Down Expand Up @@ -77,8 +86,9 @@ export const LazyLoadHintPlugin = createUnplugin(() => {
const wrapperStatements = directComponentImports
.map((imp) => {
const originalName = `__original_${imp.name}`
const component = nuxtComponents.find(c => c.filePath === imp.source)
// Rename the import to __original_X and create a wrapped version as X
return `const ${imp.name} = __wrapImportedComponent(${originalName}, '${imp.name}', '${imp.source}', '${normalizePath(id)}')`
return `const ${imp.name} = __wrapImportedComponent(${originalName}, '${component ? component.pascalName : imp.name}', '${imp.source}', '${normalizePath(id)}')`
})
.join('\n')

Expand Down Expand Up @@ -120,7 +130,7 @@ export const LazyLoadHintPlugin = createUnplugin(() => {
if (imp.name.startsWith('__nuxt')) {
// Auto imported components are using __nuxt_component_
// See nuxt loadeer plugin
return `{ componentName: '${basename(imp.source)}', importSource: '${imp.source}', importedBy: '${normalizePath(id)}', rendered: false }`
return `{ componentName: '${parse(imp.source).name}', importSource: '${imp.source}', importedBy: '${normalizePath(id)}', rendered: false }`
}
return `{ componentName: '${imp.name}', importSource: '${imp.source}', importedBy: '${normalizePath(id)}', rendered: false }`
}).join(', ')
Expand Down Expand Up @@ -173,7 +183,7 @@ function injectUseLazyComponentTrackingInComponentSetup(node: CallExpression, ma
// Inject useLazyComponentTracking call at the start of the setup function body
const insertPos = (setupFunc.body?.start ?? 0) + 1 // after {
const componentsArray = directComponentImports.map((imp) => {
const componentName = imp.name.startsWith('__nuxt') ? basename(imp.source) : imp.name
const componentName = imp.name.startsWith('__nuxt') ? parse(imp.source).name : imp.name
return `{ componentName: '${componentName}', importSource: '${imp.source}', importedBy: '${normalizePath(id)}', rendered: false }`
}).join(', ')
const injectionCode = `\nconst lazyHydrationState = useLazyComponentTracking([${componentsArray}]);\n`
Expand Down
105 changes: 101 additions & 4 deletions test/unit/hydration/lazy-hydration-plugin.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,32 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { describe, it, expect } from 'vitest'
import { LazyLoadHintPlugin } from '../../../src/plugins/lazy-load'
import { describe, it, expect, vi } from 'vitest'
import type { Plugin } from 'vite'
import type { ObjectHook } from 'unplugin'
import type { HookFnMap, ObjectHook, UnpluginBuildContext, UnpluginContext } from 'unplugin'
import { LazyLoadHintPlugin } from '../../../src/plugins/lazy-load'
import { useNuxt } from '@nuxt/kit'

vi.mock('@nuxt/kit', () => ({
useNuxt: vi.fn(() => ({
apps: {
default: {
components: [],
},
},
hook: vi.fn(),
})),
}))

const plugin = LazyLoadHintPlugin.vite() as Plugin
const transform = (plugin.transform as ObjectHook<any, any>).handler

const unpluginCtx: UnpluginBuildContext & UnpluginContext = {
addWatchFile: vi.fn(),
emitFile: vi.fn(),
getWatchFiles: vi.fn(),
parse: vi.fn(),
getNativeBuildContext: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
}
describe('LazyLoadHintPlugin', () => {
describe('default imports', () => {
it('should wrap a default import from a .vue file', async () => {
Expand Down Expand Up @@ -67,6 +87,83 @@ describe('LazyLoadHintPlugin', () => {
})
})

describe('skipped imports', () => {
it('should not transform type-only imports', async () => {
const code = `import type MyComp from './MyComp.vue'\nexport default {}`
const result = await transform(code, '/src/Parent.vue')
expect(result).toBeUndefined()
})

it('should return undefined when there are no .vue imports at all', async () => {
const code = `const x = 1\nexport default { x }`
const result = await transform(code, '/src/Parent.vue')
expect(result).toBeUndefined()
})
})

describe('nuxt auto-imported components (__nuxt prefix)', () => {
it('should use file basename as componentName for __nuxt prefixed imports in _sfc_main', async () => {
const code = [
`import __nuxt_component_0 from './ChildComp.vue'`,
`const _sfc_main = {}`,
`export default _sfc_main`,
].join('\n')
const result = await transform(code, '/src/Parent.vue')
expect(result.code).toContain(`componentName: 'ChildComp'`)
expect(result.code).toContain(`importSource: './ChildComp.vue'`)
})

it('should use file basename as componentName for __nuxt prefixed imports in defineComponent setup', async () => {
const code = [
`import { defineComponent } from 'vue'`,
`import __nuxt_component_0 from './SomeWidget.vue'`,
`export default defineComponent({`,
` setup() {`,
` return {}`,
` }`,
`})`,
].join('\n')
const result = await transform(code, '/src/Parent.ts')
expect(result.code).toContain(`componentName: 'SomeWidget'`)
expect(result.code).toContain('useLazyComponentTracking(')
})
})

describe('nuxtComponents matching (pascalName)', () => {
it('should use pascalName from nuxtComponents when component filePath matches', async () => {
vi.mocked(useNuxt).mockReturnValueOnce({
apps: {
// @ts-expect-error partial mock
default: {
components: [
{
filePath: './MyWidget.vue',
pascalName: 'MyWidgetPascal',
kebabName: '',
export: '',
shortPath: '',
chunkName: '',
prefetch: false,
preload: false,
},
],
},
},
hook: vi.fn(),
})

const { LazyLoadHintPlugin: PluginWithComponents } = await import('../../../src/plugins/lazy-load')
const p = PluginWithComponents.vite() as Plugin
const t = (p.transform as ObjectHook<HookFnMap['transform'], 'code' | 'id'>).handler

const code = `import MyWidget from './MyWidget.vue'\nexport default { components: { MyWidget } }`
const result = await t.call(unpluginCtx, code, '/src/Parent.vue') as unknown as { code: string }
expect(result.code).toContain(
`__wrapImportedComponent(__original_MyWidget, 'MyWidgetPascal', './MyWidget.vue'`,
)
})
})

describe('defineComponent setup injection', () => {
it('should inject useLazyComponentTracking in defineComponent setup', async () => {
const code = [
Expand Down
Loading