Skip to content

plugin-legacy: <link rel="modulepreload"> emitted for SystemJS chunks when renderModernChunks: false #22320

@akselinurmio

Description

@akselinurmio

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

  1. Create a Vite 8 project with @vitejs/plugin-legacy
  2. Configure renderModernChunks: false (legacy-only output)
  3. Ensure the build produces multiple chunks (default Rolldown behavior, or use a project with dynamic imports)
  4. npm run build
  5. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    p2-edge-caseBug, but has workaround or limited in scope (priority)plugin: legacyregressionThe issue only appears after a new release

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions