You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
vite build can silently invert the cascade order of stylesheet links authored in HTML.
vite:build-html converts a resolvable <link rel="stylesheet"> into a JS import and removes the node; the resulting stylesheet is appended at the end of <head> in generateBundle. Links Vite leaves in place — publicDir files, external URLs, media/disabled, unresolvable ones — keep their authored position. So any stylesheet link authored after a bundled one ends up before it, and the page is styled differently than in dev. Nothing fails; only the rendered result differs.
This is #8739 (open since 2022, p3-minor-bug + inconsistency, six reporters).
This PR does not fix the ordering — it makes the failure visible. I built the obvious fix (put the generated <link> back where the authored one was) and measured that it trades one inversion for another: in build, the CSS from HTML-authored links and the CSS imported from the entry's JS are the same output file, so the single generated <link> has one slot, while dev establishes two orderings (authored < retained < fromjs, the last because the dev client appends a <style> to <head> at runtime). Computed colours measured in Chromium:
dev
build (main)
build (move the link back)
<link> + publicDir link
pub wins
bundle wins ❌
pub wins ✅
the above + CSS imported from JS
fromjs wins
fromjs wins ✅
pub wins ❌
Details and the proposed real fix (emitting the CSS reachable from HTML-authored links as its own chunk) are in #8739 (comment) — that one needs a maintainer decision, so this PR is deliberately limited to the warning, which is useful either way.
Implementation
Records every <link rel="stylesheet"> that is a direct child of <head> in document order, tagged with whether Vite bundles it away, and warns when a retained one is authored after a bundled one.
The head-child restriction is load-bearing, not defensive. traverseHtml descends into <template>, and parse5 runs with scriptingEnabled: false, so a stylesheet link inside <noscript> is a real element in the tree. Those don't participate in the cascade the built page applies, and counting them produced false positives.
Only rel="stylesheet" is considered — preload/icon/manifest links don't take part in the cascade.
The bundled flag mirrors the existing "CSS references, convert to import" condition rather than restating it; links that later fail to resolve are downgraded to retained in the loop that already handles that case, because they keep their authored position.
warnOnce, so a repeated build of the same page doesn't repeat it.
Notes for review
The warning names the two conflicting hrefs and the file. I went back and forth on whether to point at the first bundled link or the last; the first is what determines the boundary, so that's what it reports.
Interleaved links are reported once, not per pair. A document alternating bundled and retained stylesheet links has several inverted pairs; the warning reports the first. Reporting all of them seemed noisier than useful, but I'll change it if you'd rather.
I deliberately did not touch the media/disabled path. Those links are also left in place and so can be displaced, and they're counted here, but CSS with media and disabled prop not processed #9402 tracks the fact that they aren't processed at all — I didn't want to widen this.
No behaviour change. Output is byte-identical; this only adds a log line. I verified that by building playground/html on this branch and on main and diffing every emitted file.
One adjacent bug I found while investigating and did not fix here: a <link rel="stylesheet"> inside <noscript> in <head> is converted to an import and its CSS is emitted unconditionally, so a no-JS-only stylesheet applies to everyone. That's current main behaviour and independent of this change; happy to open a separate issue.
Test
playground/html/link-order/ adds two complete documents: one with a publicDir sheet authored after a bundled one (must warn) and one with the same two sheets in the order that survives building (must not warn). Both assert against serverLogs in build mode.
They are complete documents rather than fragments on purpose: playground/html's own pre-transform plugin wraps any fixture without a <!doctype html> in a synthesized <html><head>…</head><body>, which pushes authored links into <body> — a fragment fixture cannot exercise this path at all. My first attempt at a fixture did exactly that and passed without the implementation.
Verified discriminating: reverting html.ts and rerunning fails the positive test and leaves the negative one passing.
The Build&Test: node-20, ubuntu-latest failure is unrelated to this PR.
It failed on playground/legacy/__tests__/watch/legacy-styles-only-entry-watch.spec.ts > rebuilds styles only entry on change with ENOENT: ... dist/watch/.vite/manifest.json — a watch-mode race reading the manifest before it is written, not an assertion failure.
The same test fails on main itself: run 33833431519 (2026-09-04). Across the last 15 main CI runs, 4 failed, each on a different watch/timing-sensitive spec (this one, css/lightningcss, hmr-ssr ×2).
Locally on this branch, VITE_TEST_BUILD=1 vitest run -c vitest.config.e2e.ts playground/legacy is 30/30 green, and the full build suite is 944 passed / 0 failed — the same 944 total the CI job reports. This PR only adds a warnOnce call, so it cannot affect whether a manifest file is written. I don't have permission to re-run the job.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
vite buildcan silently invert the cascade order of stylesheet links authored in HTML.vite:build-htmlconverts a resolvable<link rel="stylesheet">into a JS import and removes the node; the resulting stylesheet is appended at the end of<head>ingenerateBundle. Links Vite leaves in place —publicDirfiles, external URLs,media/disabled, unresolvable ones — keep their authored position. So any stylesheet link authored after a bundled one ends up before it, and the page is styled differently than in dev. Nothing fails; only the rendered result differs.This is #8739 (open since 2022,
p3-minor-bug+inconsistency, six reporters).This PR does not fix the ordering — it makes the failure visible. I built the obvious fix (put the generated
<link>back where the authored one was) and measured that it trades one inversion for another: in build, the CSS from HTML-authored links and the CSS imported from the entry's JS are the same output file, so the single generated<link>has one slot, while dev establishes two orderings (authored < retained < fromjs, the last because the dev client appends a<style>to<head>at runtime). Computed colours measured in Chromium:main)<link>+publicDirlinkpubwinspubwins ✅fromjswinsfromjswins ✅pubwins ❌Details and the proposed real fix (emitting the CSS reachable from HTML-authored links as its own chunk) are in #8739 (comment) — that one needs a maintainer decision, so this PR is deliberately limited to the warning, which is useful either way.
Implementation
Records every
<link rel="stylesheet">that is a direct child of<head>in document order, tagged with whether Vite bundles it away, and warns when a retained one is authored after a bundled one.traverseHtmldescends into<template>, and parse5 runs withscriptingEnabled: false, so a stylesheet link inside<noscript>is a real element in the tree. Those don't participate in the cascade the built page applies, and counting them produced false positives.rel="stylesheet"is considered —preload/icon/manifestlinks don't take part in the cascade.bundledflag mirrors the existing "CSS references, convert to import" condition rather than restating it; links that later fail to resolve are downgraded to retained in the loop that already handles that case, because they keep their authored position.warnOnce, so a repeated build of the same page doesn't repeat it.Notes for review
media/disabledpath. Those links are also left in place and so can be displaced, and they're counted here, but CSS with media and disabled prop not processed #9402 tracks the fact that they aren't processed at all — I didn't want to widen this.playground/htmlon this branch and onmainand diffing every emitted file.<link rel="stylesheet">inside<noscript>in<head>is converted to an import and its CSS is emitted unconditionally, so a no-JS-only stylesheet applies to everyone. That's currentmainbehaviour and independent of this change; happy to open a separate issue.Test
playground/html/link-order/adds two complete documents: one with apublicDirsheet authored after a bundled one (must warn) and one with the same two sheets in the order that survives building (must not warn). Both assert againstserverLogsin build mode.They are complete documents rather than fragments on purpose:
playground/html's ownpre-transformplugin wraps any fixture without a<!doctype html>in a synthesized<html><head>…</head><body>, which pushes authored links into<body>— a fragment fixture cannot exercise this path at all. My first attempt at a fixture did exactly that and passed without the implementation.Verified discriminating: reverting
html.tsand rerunning fails the positive test and leaves the negative one passing.