Skip to content

A file asset of a tree-shaken new URL('./x', import.meta.url) is still emitted #24002

Open

Description

@phatnt199

Describe the bug

When a module is imported (through a barrel with "sideEffects": false) but its export is never used, Rollup/Rolldown tree-shakes its JavaScript, so no chunk mentions the asset. The asset itself is still written to the output folder. A plugin that globs the output folder (for example a service-worker precache) then sees it.

With a 4.6 MB .wasm this means every user downloads the file for a precache entry although no code can run it.

The asset is emitted while the module is transformed (vite:asset-import-meta-url -> fileToBuiltUrl -> pluginContext.emitFile({ type: 'asset' }), in packages/vite/src/node/plugins/asset.ts), before tree-shaking decides that the module is not part of the output. Nothing removes an emitted asset that no chunk and no other asset refers to afterwards.

Observed on Vite 8.3.6 (Rolldown 1.2.16). I did not check Vite 7 with Rollup.

Reproduction

package.json

{ "name": "repro", "private": true, "type": "module" }
bun add vite@^8.3.5   # or npm i vite
mkdir -p node_modules/fake-lib src
head -c 4000000 /dev/urandom > node_modules/fake-lib/pdfium.wasm

node_modules/fake-lib/package.json

{ "name": "fake-lib", "version": "1.0.0", "type": "module", "main": "index.js", "sideEffects": false }

node_modules/fake-lib/pdf.js

export const wasmUrl = new URL('./pdfium.wasm', import.meta.url).href;
export const loadPdf = () => fetch(wasmUrl);

node_modules/fake-lib/render.js

export const render = (text) => `rendered:${text}`;

node_modules/fake-lib/index.js

export { render } from './render.js';
export { loadPdf } from './pdf.js';

src/main.js (uses only render)

import { render } from 'fake-lib';
document.body.textContent = render('hello');

src/index.html

<!doctype html><html><body><script type="module" src="./main.js"></script></body></html>

vite.config.mjs

import { defineConfig } from 'vite';
export default defineConfig({ root: 'src', build: { outDir: '../dist', emptyOutDir: true } });
npx vite build
ls dist/assets

Actual

dist/assets/pdfium-DuhTPA_B.wasm   4,000.00 kB
dist/assets/index-CZ04JbqN.js          0.73 kB

index-*.js does not contain pdfium or .wasm.

Expected

No pdfium-*.wasm in dist, as when the module is never imported. (If the library has side effects, so that new URL(...) stays in a chunk, the asset is referenced and must stay: the same build without "sideEffects": false emits it and the chunk names it.)

What I checked

  • A module that nobody imports emits nothing, so only "imported, then tree-shaken" leaks.
  • Assets referenced from CSS (url(./font.woff2)), from HTML, and by a used new URL('./used.png', import.meta.url) all stay, as they should.
  • An unused import url from './unused.png?url' (value dropped by tree-shaking) leaks the same way.
  • A service-worker plugin that globs the output (vite-plugin-pwa 2.0.0 with globPatterns: ['**/*.{js,css,html,wasm}'] and a raised maximumFileSizeToCacheInBytes) lists the wasm in the precache manifest. With its default patterns and size cap it would not.

A fix that worked in a plugin

This plugin, enforce: 'post', removes the leak; it keeps everything listed above:

export const pruneUnreferencedAssets = () => ({
  name: 'prune-unreferenced-assets',
  apply: 'build',
  enforce: 'post',
  generateBundle(_options, bundle) {
    const texts = Object.values(bundle).flatMap((it) =>
      it.type === 'chunk' ? [it.code] : typeof it.source === 'string' ? [it.source] : [],
    );
    for (const [name, it] of Object.entries(bundle)) {
      const isFileAsset =
        it.type === 'asset' && !it.fileName.endsWith('.html') && (it.originalFileNames?.length ?? 0) > 0;
      if (isFileAsset && !texts.some((text) => text.includes(it.fileName))) {
        delete bundle[name];
      }
    }
  },
});

It is a text search, so it is a sketch. A real fix would use the references Vite already tracks (chunk.viteMetadata.importedAssets, the reference ids of emitFile) instead of scanning code. Not tested: assets emitted by worker builds, and a binary asset referenced only from another binary asset.

Question

Is dropping an emitted asset that no remaining chunk or asset references something Vite wants in core, or should it stay a plugin?

System info

Vite 8.3.6, Rolldown 1.2.16, Bun 1.4.4 on Linux (the reproduction does not depend on Bun).

Related

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions