Skip to content

fix(html): resolve modulepreload href for plugin-resolved module ids#22879

Closed
codewithsupra wants to merge 1 commit into
vitejs:mainfrom
codewithsupra:fix/html-modulepreload-plugin-resolved-id
Closed

fix(html): resolve modulepreload href for plugin-resolved module ids#22879
codewithsupra wants to merge 1 commit into
vitejs:mainfrom
codewithsupra:fix/html-modulepreload-plugin-resolved-id

Conversation

@codewithsupra

Copy link
Copy Markdown

Fixes #22845

references a module, the same as a <script type="module">, but its href was only ever handled through the generic asset pipeline (processAssetUrl -> urlToBuiltUrl), which resolves ids as files and silently leaves the href untouched when the file doesn't exist on disk. Module scripts, by contrast, are resolved through this.resolve()/this.load() (via the import injected into the generated JS) so plugin-resolved (virtual) ids work correctly there.

This meant a modulepreload pointing at a virtual/plugin-resolved module id (e.g. React's prerenderToNodeStream bootstrapModules emitting both a modulepreload and a script tag for the same server-rendered client entry) kept its original, unresolved href in the build output -- a 404 in production -- while the sibling <script> for the exact same module worked fine, since "real" filesystem modules happened to already exist at that same href.

Fix: give modulepreload links their own resolution path. During transform, resolve the href via this.resolve() (same as module scripts) and, when it resolves to a non-external module, replace the href with a placeholder and record which built module it should point at. In generateBundle, once the bundle's chunks are known, look up the chunk containing that resolved module (preferring the chunk it's the facade of, then any chunk that includes it) and substitute the placeholder with that chunk's output path. An href that can't be resolved is left untouched with a warning, so runtime-handled urls (e.g. server middleware responses) keep working as before.

Also fixed a String.replace() footgun found while writing this: the existing modulepreload rewrite (and the new one) use a function replacer instead of a plain string, since a filename or original url containing '$' sequences (e.g. '$&', '$1') would otherwise be interpreted as a special replacement pattern and corrupt the output.

Tests: added playground/html/modulepreloadResolved.html covering both a plugin-resolved (virtual) modulepreload id and an unresolvable one. Verified the new test fails with the exact bug from the issue (the plugin-resolved href reaches the build output unchanged) by reverting only src/node/plugins/html.ts and rebuilding, then passes again with the fix restored. Full html playground suite (build: 55, serve: 54) and the vite package's unit suite (821 tests) all pass.

Fixes vitejs#22845

<link rel="modulepreload"> references a module, the same as a
<script type="module">, but its href was only ever handled through
the generic asset pipeline (processAssetUrl -> urlToBuiltUrl), which
resolves ids as files and silently leaves the href untouched when the
file doesn't exist on disk. Module scripts, by contrast, are resolved
through this.resolve()/this.load() (via the import injected into the
generated JS) so plugin-resolved (virtual) ids work correctly there.

This meant a modulepreload pointing at a virtual/plugin-resolved
module id (e.g. React's prerenderToNodeStream bootstrapModules
emitting both a modulepreload and a script tag for the same
server-rendered client entry) kept its original, unresolved href in
the build output -- a 404 in production -- while the sibling <script>
for the exact same module worked fine, since "real" filesystem
modules happened to already exist at that same href.

Fix: give modulepreload links their own resolution path. During
transform, resolve the href via this.resolve() (same as module
scripts) and, when it resolves to a non-external module, replace the
href with a placeholder and record which built module it should point
at. In generateBundle, once the bundle's chunks are known, look up
the chunk containing that resolved module (preferring the chunk it's
the facade of, then any chunk that includes it) and substitute the
placeholder with that chunk's output path. An href that can't be
resolved is left untouched with a warning, so runtime-handled urls
(e.g. server middleware responses) keep working as before.

Also fixed a String.replace() footgun found while writing this: the
existing modulepreload rewrite (and the new one) use a function
replacer instead of a plain string, since a filename or original url
containing '$' sequences (e.g. '$&', '$1') would otherwise be
interpreted as a special replacement pattern and corrupt the output.

Tests: added playground/html/modulepreloadResolved.html covering both
a plugin-resolved (virtual) modulepreload id and an unresolvable one.
Verified the new test fails with the exact bug from the issue (the
plugin-resolved href reaches the build output unchanged) by reverting
only src/node/plugins/html.ts and rebuilding, then passes again with
the fix restored. Full html playground suite (build: 55, serve: 54)
and the vite package's unit suite (821 tests) all pass.
@sapphi-red

sapphi-red commented Jul 7, 2026

Copy link
Copy Markdown
Member

We first need to understand what the expected behavior is. The semantics of <link rel="modulepreload" href="somefile.js"> is not clear (e.g. What happens if somefile.js is not an entrypoint).

@sapphi-red sapphi-red closed this Jul 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Vite does not resolve modulepreload for plugin-resolved IDs

2 participants