Skip to content

fix(html): resolve modulepreload href through the module pipeline (fix #22845) - #23320

Closed
santhiprakash wants to merge 1 commit into
vitejs:mainfrom
santhiprakash:fix/html-modulepreload-plugin-resolve
Closed

fix(html): resolve modulepreload href through the module pipeline (fix #22845)#23320
santhiprakash wants to merge 1 commit into
vitejs:mainfrom
santhiprakash:fix/html-modulepreload-plugin-resolve

Conversation

@santhiprakash

Copy link
Copy Markdown
Contributor

What is this PR solving?

Closes #22845.

When an HTML entry contains <link rel="modulepreload" href="/@plugin-script"> (a plugin-resolved virtual id) the href is left untouched in the build output, while the matching <script type="module" src="/@plugin-script"> is correctly resolved and bundled. The result: a 404 in production for the modulepreload while the script loads fine.

The root cause is in packages/vite/src/node/plugins/html.ts (the build-html plugin). <link> nodes only get CSS-import or asset-URL treatment; rel="modulepreload" falls into the generic asset path which calls processAssetUrl and does not run the id through this.resolve(). Real files happen to already live at that same href, so the bug stays hidden until you use a virtual module.

Fix

Route rel="modulepreload" hrefs through the same module pipeline the script-src branch already uses:

  • when we see a <link rel="modulepreload"> whose href is not a public file and not excluded, push it onto modulePreloadLinkUrls and emit a transient import "<url>" so the build-import-analysis plugin runs this.resolve() on it;
  • after the iteration, for each entry:
    • if this.resolve() succeeds → strip the original <link> from the HTML (build-import-analysis will re-emit the modulepreload for the resolved chunk URL), and mark moduleSideEffects = true on the resolved module so treeshake cannot drop it;
    • if it fails → drop the transient import and leave the original node intact (preserves runtime-handled URLs, mirroring the existing stylesheet fallback).

This mirrors the established pattern for <link rel="stylesheet"> resolution in the same plugin.

Verification

  • New playground: playground/html/modulepreloadResolved.html exercises a plugin-resolved virtual module id (/@plugin-script) declared by both a modulepreload link and a script type="module" src.
  • New test in playground/html/__tests__/html.spec.ts:
    • build: asserts the unresolved /@plugin-script href/src does NOT survive in the build output, and every remaining <link rel="modulepreload"> points at a hashed bundled asset.
    • serve: loads the page, asserts the script runs (.output text becomes 'loaded') and the unresolved /@plugin-script link is stripped in dev too.
  • pnpm typecheck -F @vitejs/vite passes cleanly.

Alternatives considered

  • Resolve the modulepreload href in a separate Rollup/rolldown hook: rejected — this would require re-running the import-analysis plugin and double-bundling.
  • Mutate this.load() to register the modulepreload href as an alias of the script src: rejected — only fixes the specific co-emitted case from prerenderToNodeStream.

Reviewer attention

  • The transient import "<url>" is dropped exactly once per failed resolution via js.replace(importExpression, ''). If two modulepreloads share the same URL and both fail, only the first match is removed. The existing stylesheet branch has the same characteristic; I left it consistent rather than introducing a new escape strategy. Worth a reviewer call if you want stricter behavior.

Checklist

…vitejs#22845)

Plugin-resolved (virtual) ids referenced via <link rel="modulepreload">
were left unresolved in the build HTML and 404ed in production, while the
matching <script type="module" src> for the same id was bundled correctly.

Route rel=modulepreload hrefs through the module resolution pipeline
(same as the script-src branch) by emitting an import up-front, then in
a post-pass:

  - if the href resolves: strip the original <link> (build-import-analysis
    will re-emit the modulepreload for the resolved chunk URL), mark
    moduleSideEffects on the resolved module so treeshake doesn't drop it;
  - if it doesn't resolve: drop the transient import and leave the link
    intact (preserves runtime-handled URLs, matching the existing
    stylesheet fallback).

Public-file and excluded URLs are untouched.

Adds a playground test (playground/html/modulepreloadResolved.html) that
exercises a plugin-resolved virtual module id and asserts the build
output no longer contains an unresolved /@plugin-script href while the
matching script's src gets bundled to a hashed asset URL.
@santhiprakash
santhiprakash force-pushed the fix/html-modulepreload-plugin-resolve branch from ba72c21 to 9fdd4b4 Compare August 21, 2026 04:48
@santhiprakash santhiprakash reopened this Aug 23, 2026
@github-actions github-actions Bot added the bot: likely Likely a bot, LLM, or agent. Automatically comments and closes the issue or PR label Aug 23, 2026
@github-actions

Copy link
Copy Markdown
Contributor

This PR has been automatically flagged as likely to be created by a bot, LLM, or agent, and will be automatically closed. These contributions harm the maintenance of the project. Please read our AI policy for more information.
If you believe this is a mistake, please reply to this comment and we will review it.

@github-actions github-actions Bot closed this Aug 23, 2026
@santhiprakash

santhiprakash commented Aug 23, 2026

Copy link
Copy Markdown
Contributor Author

Hi, the close/reopen above was a manual CI retrigger after the Windows Build&Test job flaked, because the santhiprakash account cannot rerun only the failed job on vitejs/vite. The branch and diff are unchanged. Could a maintainer please add the bot: skip label and reopen so the actual CI status can be evaluated? I can explain the modulepreload-href diff and respond to review feedback.

@santhiprakash

Copy link
Copy Markdown
Contributor Author

Force-pushed a cleaned branch. The unrelated config.ts commit that had been mixed in from an earlier checkout is now removed, and the branch is rebased onto current main (e79c471). Head is now eed10804e; the diff is back to the intended 4 files, +152/-0. Still needs a maintainer to add the bot: skip label and reopen.

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

Labels

bot: likely Likely a bot, LLM, or agent. Automatically comments and closes the issue or PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Vite does not resolve modulepreload for plugin-resolved IDs

1 participant