diff --git a/packages/vite/src/node/plugins/asset.ts b/packages/vite/src/node/plugins/asset.ts index ac18fb1772a4d6..9d6e3f9d1710d4 100644 --- a/packages/vite/src/node/plugins/asset.ts +++ b/packages/vite/src/node/plugins/asset.ts @@ -205,7 +205,15 @@ export function assetPlugin(config: ResolvedConfig): Plugin { } } - return `export default ${JSON.stringify(url)}` + return { + code: `export default ${JSON.stringify(url)}`, + // Force rollup to keep this module from being shared between other entry points if it's an entrypoint. + // If the resulting chunk is empty, it will be removed in generateBundle. + moduleSideEffects: + config.command === 'build' && this.getModuleInfo(id)?.isEntry + ? 'no-treeshake' + : false, + } }, renderChunk(code, chunk, opts) { @@ -224,6 +232,19 @@ export function assetPlugin(config: ResolvedConfig): Plugin { }, generateBundle(_, bundle) { + // Remove empty entry point file + for (const file in bundle) { + const chunk = bundle[file] + if ( + chunk.type === 'chunk' && + chunk.isEntry && + chunk.moduleIds.length === 1 && + config.assetsInclude(chunk.moduleIds[0]) + ) { + delete bundle[file] + } + } + // do not emit assets for SSR build if ( config.command === 'build' && @@ -340,7 +361,7 @@ async function fileToBuiltUrl( const content = await fsp.readFile(file) let url: string - if (shouldInline(config, file, id, content, forceInline)) { + if (shouldInline(config, file, id, content, pluginContext, forceInline)) { if (config.build.lib && isGitLfsPlaceholder(content)) { config.logger.warn( colors.yellow(`Inlined file ${id} was not downloaded via Git LFS`), @@ -405,9 +426,11 @@ const shouldInline = ( file: string, id: string, content: Buffer, + pluginContext: PluginContext, forceInline: boolean | undefined, ): boolean => { if (config.build.lib) return true + if (pluginContext.getModuleInfo(id)?.isEntry) return false if (forceInline !== undefined) return forceInline let limit: number if (typeof config.build.assetsInlineLimit === 'function') { diff --git a/playground/backend-integration/__tests__/backend-integration.spec.ts b/playground/backend-integration/__tests__/backend-integration.spec.ts index 563e03b5f4e7c9..669239af237846 100644 --- a/playground/backend-integration/__tests__/backend-integration.spec.ts +++ b/playground/backend-integration/__tests__/backend-integration.spec.ts @@ -6,8 +6,10 @@ import { getColor, isBuild, isServe, + listAssets, page, readManifest, + serverLogs, untilBrowserLogAfter, untilUpdated, } from '~utils' @@ -39,6 +41,7 @@ describe.runIf(isBuild)('build', () => { const scssAssetEntry = manifest['nested/blue.scss'] const imgAssetEntry = manifest['../images/logo.png'] const dirFooAssetEntry = manifest['../../dir/foo.css'] + const iconEntrypointEntry = manifest['icon.png'] expect(htmlEntry.css.length).toEqual(1) expect(htmlEntry.assets.length).toEqual(1) expect(cssAssetEntry?.file).not.toBeUndefined() @@ -53,6 +56,7 @@ describe.runIf(isBuild)('build', () => { expect(dirFooAssetEntry).not.toBeUndefined() // '\\' should not be used even on windows // use the entry name expect(dirFooAssetEntry.file).toMatch('assets/bar-') + expect(iconEntrypointEntry?.file).not.toBeUndefined() }) test('CSS imported from JS entry should have a non-nested chunk name', () => { @@ -61,6 +65,17 @@ describe.runIf(isBuild)('build', () => { expect(mainTsEntryCss.length).toBe(1) expect(mainTsEntryCss[0].replace('assets/', '')).not.toContain('/') }) + + test('entrypoint assets should not generate empty JS file', () => { + expect(serverLogs).not.toContainEqual( + 'Generated an empty chunk: "icon.png".', + ) + + const assets = listAssets('dev') + expect(assets).not.toContainEqual( + expect.stringMatching(/icon.png-[-\w]{8}\.js$/), + ) + }) }) describe.runIf(isServe)('serve', () => { diff --git a/playground/backend-integration/frontend/entrypoints/icon.png b/playground/backend-integration/frontend/entrypoints/icon.png new file mode 100644 index 00000000000000..4388bfdca3d4d7 Binary files /dev/null and b/playground/backend-integration/frontend/entrypoints/icon.png differ