Describe the bug
When renderModernChunks: false, plugin-legacy's transformIndexHtml removes <script type="module"> tags but does not remove <link rel="modulepreload"> tags that Vite core's HTML plugin already injected for all imported entry chunks.
The result is that the built index.html contains <link rel="modulepreload"> pointing at files that are SystemJS IIFE bundles, not native ES modules:
<!-- These are IIFE wrappers containing System.register() — not ES modules -->
<link rel="modulepreload" crossorigin href="/static/build/useLocationWithState-legacy-XXX.js">
<link rel="modulepreload" crossorigin href="/static/build/AppFeaturesContext-legacy-XXX.js">
<!-- ... 50+ more ... -->
<script crossorigin id="vite-legacy-polyfill" src="/static/build/polyfills-legacy.js"></script>
<script crossorigin id="vite-legacy-entry" data-src="/static/build/main-legacy.js">System.import(...)</script>
Root cause
In plugin-legacy's transformIndexHtml, when genModern is false:
// packages/plugin-legacy/src/index.ts (transformIndexHtml hook)
if (!genModern) html = html.replace(/<script type="module".*?<\/script>/g, "");
This removes <script type="module"> but leaves untouched the <link rel="modulepreload"> tags that Vite core's HTML plugin (buildHtmlPlugin) injected for all chunks in the entry's import graph.
Why this only surfaces in Vite 8
In Vite 7 with Rollup, the legacy entry chunk had System.register([], ...) — no external imports — so Vite core emitted zero modulepreload tags and the issue did not manifest. In Vite 8 with Rolldown, the default code splitting strategy creates many more shared chunks. The entry chunk's import graph includes 50+ chunks, triggering 50+ <link rel="modulepreload"> tags for SystemJS files.
Expected behavior
When renderModernChunks: false, the built HTML should contain no <link rel="modulepreload"> tags. All output chunks are SystemJS/IIFE format — none are native ES modules.
Actual behavior
Built HTML contains <link rel="modulepreload"> for every chunk in the entry's dependency graph, all of which are SystemJS IIFE bundles.
Suggested fix
In transformIndexHtml, also strip <link rel="modulepreload"> when genModern is false:
if (!genModern) {
html = html.replace(/<script type="module".*?<\/script>/g, "");
html = html.replace(/<link[^>]*rel=["']modulepreload["'][^>]*\/?>/g, "");
}
Steps to reproduce
- Create a Vite 8 project with
@vitejs/plugin-legacy
- Configure
renderModernChunks: false (legacy-only output)
- Ensure the build produces multiple chunks (default Rolldown behavior, or use a project with dynamic imports)
npm run build
- Inspect
dist/index.html — observe <link rel="modulepreload"> tags pointing at *-legacy-*.js files
Workaround
Disable Rolldown code splitting so the entry chunk has no imports:
// vite.config.ts
rolldownOptions: {
output: {
codeSplitting: false,
}
}
This prevents the entry chunk from having any imported chunks, so Vite core emits no modulepreload tags. Side effect: produces a single large bundle (no lazy loading).
Environment
|
|
@vitejs/plugin-legacy |
8.0.1 |
vite |
8.0.8 |
| Node |
v22.21.1 |
| Platform |
macOS 25.3.0 |
Impact
The primary issue is semantic incorrectness: rel="modulepreload" declares to the browser that these files are native ES modules (HTML spec §4.6.8.12), which they are not — they are IIFE wrappers containing System.register() calls.
Per spec, modulepreload fetches the resource as a module script and places it in the browser's module map for later native import() evaluation. Since the module map is separate from the HTTP cache, and SystemJS loads chunks via XHR (not the native module system), modulepreload does not directly break SystemJS loading in spec-compliant browsers. However, it is semantically incorrect: the plugin should clean up all artifacts it introduced when genModern: false, and <link rel="modulepreload"> for non-module files is one such artifact.
Describe the bug
When
renderModernChunks: false, plugin-legacy'stransformIndexHtmlremoves<script type="module">tags but does not remove<link rel="modulepreload">tags that Vite core's HTML plugin already injected for all imported entry chunks.The result is that the built
index.htmlcontains<link rel="modulepreload">pointing at files that are SystemJS IIFE bundles, not native ES modules:Root cause
In
plugin-legacy'stransformIndexHtml, whengenModernisfalse:This removes
<script type="module">but leaves untouched the<link rel="modulepreload">tags that Vite core's HTML plugin (buildHtmlPlugin) injected for all chunks in the entry's import graph.Why this only surfaces in Vite 8
In Vite 7 with Rollup, the legacy entry chunk had
System.register([], ...)— no external imports — so Vite core emitted zero modulepreload tags and the issue did not manifest. In Vite 8 with Rolldown, the default code splitting strategy creates many more shared chunks. The entry chunk's import graph includes 50+ chunks, triggering 50+<link rel="modulepreload">tags for SystemJS files.Expected behavior
When
renderModernChunks: false, the built HTML should contain no<link rel="modulepreload">tags. All output chunks are SystemJS/IIFE format — none are native ES modules.Actual behavior
Built HTML contains
<link rel="modulepreload">for every chunk in the entry's dependency graph, all of which are SystemJS IIFE bundles.Suggested fix
In
transformIndexHtml, also strip<link rel="modulepreload">whengenModernis false:Steps to reproduce
@vitejs/plugin-legacyrenderModernChunks: false(legacy-only output)npm run builddist/index.html— observe<link rel="modulepreload">tags pointing at*-legacy-*.jsfilesWorkaround
Disable Rolldown code splitting so the entry chunk has no imports:
This prevents the entry chunk from having any imported chunks, so Vite core emits no modulepreload tags. Side effect: produces a single large bundle (no lazy loading).
Environment
@vitejs/plugin-legacyviteImpact
The primary issue is semantic incorrectness:
rel="modulepreload"declares to the browser that these files are native ES modules (HTML spec §4.6.8.12), which they are not — they are IIFE wrappers containingSystem.register()calls.Per spec,
modulepreloadfetches the resource as a module script and places it in the browser's module map for later nativeimport()evaluation. Since the module map is separate from the HTTP cache, and SystemJS loads chunks via XHR (not the native module system), modulepreload does not directly break SystemJS loading in spec-compliant browsers. However, it is semantically incorrect: the plugin should clean up all artifacts it introduced whengenModern: false, and<link rel="modulepreload">for non-module files is one such artifact.