From f1e923cfd26b5c46dca326c1ad4eabbed3a7f5b9 Mon Sep 17 00:00:00 2001 From: Osei Fortune Date: Thu, 15 Oct 2026 08:10:31 -0400 Subject: [PATCH 1/2] fix(vite): keep the rules after @layer, @container and other at-rules css can't parse The build turns app.css and the bundled component css into an AST with `css` (reworkcss), which reads an at-rule it doesn't know as the start of a selector and gives up there. One `@layer`, `@container`, `@starting-style` or `@property` was enough to lose every rule after it. Bootstrap 6 wraps its whole stylesheet in `@layer`s and Tailwind 4 emits `@layer` and `@property`, so apps lost almost all of their styles in non-HMR builds and on the first HMR load. The stylesheet is now rewritten before it is parsed. `@layer` blocks are replaced by their rules: top-level layers move ahead of the unlayered rules in layer order (first mention, including `@layer a, b;`), as the cascade orders them, and layers inside another at-rule are unwrapped in place. Every other at-rule `css` doesn't know is removed with its block; core only reads rules, `@media`, `@keyframes` and `@import`, so nothing it uses is lost. Stylesheets without such at-rules are passed through unchanged. --- packages/vite/helpers/css-ast.spec.ts | 54 +++++++- packages/vite/helpers/css-ast.ts | 178 +++++++++++++++++++++++++- 2 files changed, 229 insertions(+), 3 deletions(-) diff --git a/packages/vite/helpers/css-ast.spec.ts b/packages/vite/helpers/css-ast.spec.ts index d18132a30e..677cfea060 100644 --- a/packages/vite/helpers/css-ast.spec.ts +++ b/packages/vite/helpers/css-ast.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { joinEscapedSelectorCommas, parseCssAst } from './css-ast.js'; +import { joinEscapedSelectorCommas, normalizeCssAtRules, parseCssAst } from './css-ast.js'; describe('parseCssAst', () => { it('keeps an escaped comma inside a class name', () => { @@ -19,6 +19,58 @@ describe('parseCssAst', () => { }); }); +describe('parseCssAst with at-rules css cannot read', () => { + const selectors = (code: string) => { + const ast = parseCssAst(code, { silent: true }); + expect(ast.stylesheet.parsingErrors).toEqual([]); + return ast.stylesheet.rules.map((rule: any) => (rule.type === 'rule' ? rule.selectors.join(',') : `@${rule.type} ${rule.media ?? ''}`.trim())); + }; + + it.each([ + ['@layer', '@layer base { .a { color: red; } } .b { color: blue; }', ['.a', '.b']], + ['@container', '@container card (min-width: 400px) { .a { color: red; } } .b { color: blue; }', ['.b']], + ['@starting-style', '@starting-style { .a { opacity: 0; } } .b { color: blue; }', ['.b']], + ['@property', '@property --x { syntax: ""; inherits: false; initial-value: 0px; } .b { color: blue; }', ['.b']], + ['@scope', '@scope (.card) { .title { color: red; } } .b { color: blue; }', ['.b']], + ])('keeps the rules after %s', (_name, code, expected) => { + expect(selectors(code)).toEqual(expected); + }); + + it('orders layered rules the way the cascade does: layers in declaration order, then unlayered rules', () => { + const code = ['@charset "utf-8";', '@layer reboot, utilities;', '.app { color: green; }', '@layer utilities { .u { margin: 0; } }', '@layer reboot { .r { padding: 0; } }', '@layer { .anon { color: red; } }', '@layer utilities { .u2 { margin: 1px; } }'].join('\n'); + const ast = parseCssAst(code, { silent: true }); + expect(ast.stylesheet.rules[0].type).toBe('charset'); + expect(selectors(code).slice(1)).toEqual(['.r', '.u', '.u2', '.anon', '.app']); + }); + + it('unwraps layers inside other at-rules and drops unknown at-rules at any depth', () => { + const code = '@media (min-width: 1px) { @layer x { .a { color: red; } } @container (width > 1px) { .c { color: red; } } } .b { color: blue; @starting-style { opacity: 0; } }'; + const ast = parseCssAst(code, { silent: true }); + expect(ast.stylesheet.parsingErrors).toEqual([]); + expect(ast.stylesheet.rules[0].media).toBe('(min-width: 1px)'); + expect(ast.stylesheet.rules[0].rules.map((rule: any) => rule.selectors)).toEqual([['.a']]); + expect(ast.stylesheet.rules[1].declarations.map((decl: any) => `${decl.property}: ${decl.value}`)).toEqual(['color: blue']); + }); + + it('keeps layered @media and @keyframes for core', () => { + const code = '@layer components { @media (min-width: 576px) { .c { width: 1px; } } @keyframes spin { to { opacity: 1; } } }'; + const rules = parseCssAst(code, { silent: true }).stylesheet.rules; + expect(rules.map((rule: any) => rule.type)).toEqual(['media', 'keyframes']); + }); +}); + +describe('normalizeCssAtRules', () => { + it('returns a stylesheet without such at-rules unchanged', () => { + const code = '@import "a.css";\n@media (min-width: 1px) { .a { background: url(a@2x.png); } }\n.b::after { content: "@layer {"; }\n@-webkit-keyframes k { to { opacity: 1 } }'; + expect(normalizeCssAtRules(code)).toBe(code); + }); + + it('leaves text outside the rewritten at-rules as written', () => { + const code = '.a { background: url(data:image/png;base64,AA==); /* } */ }\n@property --x { inherits: false; }\n.b { content: "}"; }'; + expect(normalizeCssAtRules(code)).toBe('.a { background: url(data:image/png;base64,AA==); /* } */ }\n.b { content: "}"; }'); + }); +}); + describe('joinEscapedSelectorCommas', () => { it('does not join after an escaped backslash', () => { // `.a\\` ends in an escaped backslash, so the comma after it is a real separator. diff --git a/packages/vite/helpers/css-ast.ts b/packages/vite/helpers/css-ast.ts index b7606e96a2..528c5ed3e3 100644 --- a/packages/vite/helpers/css-ast.ts +++ b/packages/vite/helpers/css-ast.ts @@ -35,9 +35,183 @@ function repairRules(rules: any[] | undefined): void { } } -/** `css`'s parse, with selector lists repaired (see joinEscapedSelectorCommas). */ +// The at-rules `css` knows how to parse (its lib/parse `atrule()`). +const PARSEABLE_AT_RULE = /^(?:(?:-[\w]+-)?(?:keyframes|document)|media|custom-media|supports|import|charset|namespace|page|host|font-face)$/i; +// Top-level statements that have to stay ahead of every rule. +const LEADING_AT_RULE = /^(?:charset|import|namespace|layer)$/i; + +interface CssItem { + start: number; + end: number; + /** `{` and its matching `}` (or the end of the input), for a block. */ + open?: number; + close?: number; + children?: CssItem[]; +} + +/** + * Splits `css[from..]` into statements (`…;`) and blocks (`… { … }`) up to the + * first unmatched `}`, skipping comments, strings and escapes, and `;` inside + * parentheses (`url(data:…;base64,…)`). Every character lands in exactly one + * item, so the items rejoin to the original text. + */ +function splitCssItems(css: string, from: number, nested: boolean): { items: CssItem[]; end: number } { + const items: CssItem[] = []; + let start = from; + let parens = 0; + let i = from; + while (i < css.length) { + const ch = css[i]; + if (ch === '/' && css[i + 1] === '*') { + const close = css.indexOf('*/', i + 2); + i = close === -1 ? css.length : close + 2; + continue; + } + if (ch === '"' || ch === "'") { + i++; + while (i < css.length && css[i] !== ch && css[i] !== '\n') { + i += css[i] === '\\' ? 2 : 1; + } + i++; + continue; + } + if (ch === '\\') { + i += 2; + continue; + } + if (ch === '(') { + parens++; + } else if (ch === ')') { + parens = Math.max(0, parens - 1); + } else if (ch === ';' && parens === 0) { + items.push({ start, end: i + 1 }); + start = i + 1; + } else if (ch === '{') { + const inner = splitCssItems(css, i + 1, true); + const end = Math.min(inner.end + 1, css.length); + items.push({ start, end, open: i, close: inner.end, children: inner.items }); + start = i = end; + parens = 0; + continue; + } else if (ch === '}' && nested) { + break; + } + i++; + } + i = Math.min(i, css.length); + if (start < i) { + items.push({ start, end: i }); + } + return { items, end: i }; +} + +/** The at-rule an item starts with, past any whitespace and comments. */ +function cssItemAtRule(css: string, item: CssItem): { name: string; prelude: string } | null { + const prelude = css + .slice(item.start, item.open ?? item.end) + .replace(/\/\*[\s\S]*?(?:\*\/|$)/g, ' ') + .trim(); + const match = /^@([-\w]+)/.exec(prelude); + return match ? { name: match[1].toLowerCase(), prelude: prelude.slice(match[0].length).replace(/;$/, '').trim() } : null; +} + +/** + * Rewrites the at-rules `css` can't parse into CSS it can. + * + * `css` reads an at-rule it doesn't know (`@layer`, `@container`, + * `@starting-style`, `@property`, ...) as the start of a selector, and the + * first one ends the parse: every rule after it is lost. Bootstrap 6 wraps its + * whole stylesheet in `@layer`s, and Tailwind 4 emits `@layer` and `@property`. + * Core's styling only reads rules, `@media` and `@keyframes` (plus `@import`), + * so nothing it uses is in these at-rules except the rules inside `@layer`: + * + * - `@layer` blocks are replaced with their rules. Top-level layers are moved + * ahead of the unlayered rules in layer order (first mention, including + * `@layer a, b;`), which is how the cascade orders them: unlayered rules + * beat every layer, and a later layer beats an earlier one. A layer nested + * in another at-rule is unwrapped where it is. `@layer a, b;` is removed. + * - Every other at-rule `css` doesn't know is removed, with its block. + * + * Text outside those at-rules is kept as written, and a stylesheet without + * them is returned unchanged. + */ +export function normalizeCssAtRules(css: string): string { + let foreign = false; + for (const match of css.matchAll(/@([-\w]+)/g)) { + if (!PARSEABLE_AT_RULE.test(match[1])) { + foreign = true; + break; + } + } + if (!foreign) return css; + + const { items } = splitCssItems(css, 0, false); + + // Layer order is the order of first mention, in `@layer a, b;` or a block. + const layerOrder: (string | symbol)[] = []; + const layers = new Map(); + const blockLayer = new Map(); + const declareLayer = (name: string | symbol) => { + if (!layers.has(name)) { + layerOrder.push(name); + layers.set(name, []); + } + }; + for (const item of items) { + const atRule = cssItemAtRule(css, item); + if (atRule?.name !== 'layer') continue; + const names = atRule.prelude + .split(',') + .map((name) => name.trim()) + .filter(Boolean); + if (item.children) { + // A nameless block is a layer of its own. + const name = names[0] ?? Symbol('anonymous layer'); + blockLayer.set(item, name); + declareLayer(name); + } else { + names.forEach(declareLayer); + } + } + + const write = (items: CssItem[], topLevel: boolean): string => { + let out = ''; + for (const item of items) { + const atRule = cssItemAtRule(css, item); + if (atRule && !PARSEABLE_AT_RULE.test(atRule.name)) { + if (atRule.name !== 'layer' || !item.children) continue; + const content = write(item.children, false); + if (!topLevel) { + out += content; + continue; + } + layers.get(blockLayer.get(item)!)!.push(content); + continue; + } + out += item.children ? css.slice(item.start, item.open! + 1) + write(item.children, false) + css.slice(item.close!, item.end) : css.slice(item.start, item.end); + } + return out; + }; + + // `@charset`/`@import`/`@namespace` stay first; layered rules follow. + let head = 0; + while (head < items.length && !items[head].children && LEADING_AT_RULE.test(cssItemAtRule(css, items[head])?.name ?? '')) { + head++; + } + const leading = write(items.slice(0, head), true); + const rest = write(items.slice(head), true); + const layered = layerOrder.flatMap((name) => layers.get(name)!); + if (!layered.length) return leading + rest; + return leading + '\n' + layered.join('\n') + '\n' + rest; +} + +/** + * `css`'s parse, with at-rules it can't read rewritten first (see + * normalizeCssAtRules) and selector lists repaired (see + * joinEscapedSelectorCommas). + */ export function parseCssAst(code: string, options?: { silent?: boolean; source?: string }): any { - const ast: any = parse(code, options); + const ast: any = parse(normalizeCssAtRules(code), options); repairRules(ast?.stylesheet?.rules); return ast; } From 09ea542df44f1e77fa37fecc8b6ec374158c5377 Mon Sep 17 00:00:00 2001 From: Osei Fortune Date: Thu, 15 Oct 2026 16:26:04 -0400 Subject: [PATCH 2/2] chore(vite): drop comments --- packages/vite/helpers/css-ast.ts | 39 +------------------------------- 1 file changed, 1 insertion(+), 38 deletions(-) diff --git a/packages/vite/helpers/css-ast.ts b/packages/vite/helpers/css-ast.ts index 528c5ed3e3..afdc993bcc 100644 --- a/packages/vite/helpers/css-ast.ts +++ b/packages/vite/helpers/css-ast.ts @@ -35,26 +35,17 @@ function repairRules(rules: any[] | undefined): void { } } -// The at-rules `css` knows how to parse (its lib/parse `atrule()`). const PARSEABLE_AT_RULE = /^(?:(?:-[\w]+-)?(?:keyframes|document)|media|custom-media|supports|import|charset|namespace|page|host|font-face)$/i; -// Top-level statements that have to stay ahead of every rule. const LEADING_AT_RULE = /^(?:charset|import|namespace|layer)$/i; interface CssItem { start: number; end: number; - /** `{` and its matching `}` (or the end of the input), for a block. */ open?: number; close?: number; children?: CssItem[]; } -/** - * Splits `css[from..]` into statements (`…;`) and blocks (`… { … }`) up to the - * first unmatched `}`, skipping comments, strings and escapes, and `;` inside - * parentheses (`url(data:…;base64,…)`). Every character lands in exactly one - * item, so the items rejoin to the original text. - */ function splitCssItems(css: string, from: number, nested: boolean): { items: CssItem[]; end: number } { const items: CssItem[] = []; let start = from; @@ -105,7 +96,6 @@ function splitCssItems(css: string, from: number, nested: boolean): { items: Css return { items, end: i }; } -/** The at-rule an item starts with, past any whitespace and comments. */ function cssItemAtRule(css: string, item: CssItem): { name: string; prelude: string } | null { const prelude = css .slice(item.start, item.open ?? item.end) @@ -115,26 +105,6 @@ function cssItemAtRule(css: string, item: CssItem): { name: string; prelude: str return match ? { name: match[1].toLowerCase(), prelude: prelude.slice(match[0].length).replace(/;$/, '').trim() } : null; } -/** - * Rewrites the at-rules `css` can't parse into CSS it can. - * - * `css` reads an at-rule it doesn't know (`@layer`, `@container`, - * `@starting-style`, `@property`, ...) as the start of a selector, and the - * first one ends the parse: every rule after it is lost. Bootstrap 6 wraps its - * whole stylesheet in `@layer`s, and Tailwind 4 emits `@layer` and `@property`. - * Core's styling only reads rules, `@media` and `@keyframes` (plus `@import`), - * so nothing it uses is in these at-rules except the rules inside `@layer`: - * - * - `@layer` blocks are replaced with their rules. Top-level layers are moved - * ahead of the unlayered rules in layer order (first mention, including - * `@layer a, b;`), which is how the cascade orders them: unlayered rules - * beat every layer, and a later layer beats an earlier one. A layer nested - * in another at-rule is unwrapped where it is. `@layer a, b;` is removed. - * - Every other at-rule `css` doesn't know is removed, with its block. - * - * Text outside those at-rules is kept as written, and a stylesheet without - * them is returned unchanged. - */ export function normalizeCssAtRules(css: string): string { let foreign = false; for (const match of css.matchAll(/@([-\w]+)/g)) { @@ -147,7 +117,6 @@ export function normalizeCssAtRules(css: string): string { const { items } = splitCssItems(css, 0, false); - // Layer order is the order of first mention, in `@layer a, b;` or a block. const layerOrder: (string | symbol)[] = []; const layers = new Map(); const blockLayer = new Map(); @@ -165,7 +134,6 @@ export function normalizeCssAtRules(css: string): string { .map((name) => name.trim()) .filter(Boolean); if (item.children) { - // A nameless block is a layer of its own. const name = names[0] ?? Symbol('anonymous layer'); blockLayer.set(item, name); declareLayer(name); @@ -193,7 +161,6 @@ export function normalizeCssAtRules(css: string): string { return out; }; - // `@charset`/`@import`/`@namespace` stay first; layered rules follow. let head = 0; while (head < items.length && !items[head].children && LEADING_AT_RULE.test(cssItemAtRule(css, items[head])?.name ?? '')) { head++; @@ -205,11 +172,7 @@ export function normalizeCssAtRules(css: string): string { return leading + '\n' + layered.join('\n') + '\n' + rest; } -/** - * `css`'s parse, with at-rules it can't read rewritten first (see - * normalizeCssAtRules) and selector lists repaired (see - * joinEscapedSelectorCommas). - */ +/** `css`'s parse, with selector lists repaired (see joinEscapedSelectorCommas). */ export function parseCssAst(code: string, options?: { silent?: boolean; source?: string }): any { const ast: any = parse(normalizeCssAtRules(code), options); repairRules(ast?.stylesheet?.rules);