Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fine-terms-admire.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'astro': patch
---

Fixes CSS module scoped-name hash mismatch in `astro dev` when using `vite.css.transformer: 'lightningcss'` with content collections. Previously, a component importing a CSS module and rendered via content collection `render()` would get different class name hashes in the element and the injected `<style>` tag, causing styles not to apply.
5 changes: 5 additions & 0 deletions .changeset/odd-socks-flow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'astro': patch
---

Fixes a bug where CSS `@import` rules could end up mid-stylesheet after inline CSS chunks were merged during build, causing browsers to silently ignore them
7 changes: 1 addition & 6 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,12 +58,7 @@
"engines": {
"node": ">=22.12.0"
},
"devEngines": {
"packageManager": {
"name": "pnpm",
"version": "11.10.0"
}
},
"packageManager": "pnpm@11.5.0",
"dependencies": {
"astro-benchmark": "workspace:*"
},
Expand Down
15 changes: 14 additions & 1 deletion packages/astro/src/content/vite-plugin-content-assets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,13 @@ import { isAstroServerEnvironment } from '../environments.js';

export function astroContentAssetPropagationPlugin({
settings,
cssContentCache,
}: {
settings: AstroSettings;
/** Shared cache of CSS content populated by the dev-css plugin's transform hook.
* Used to retrieve already-processed CSS for CSS modules without re-importing
* via `?inline`, which would produce different scoped-name hashes with Lightning CSS. */
cssContentCache?: Map<string, string>;
}): Plugin {
let environment: RunnableDevEnvironment | undefined = undefined;
return {
Expand Down Expand Up @@ -115,7 +120,7 @@ export function astroContentAssetPropagationPlugin({
styles,
urls,
crawledFiles: styleCrawledFiles,
} = await getStylesForURL(basePath, environment);
} = await getStylesForURL(basePath, environment, cssContentCache);

// Register files we crawled to be able to retrieve the rendered styles and scripts,
// as when they get updated, we need to re-transform ourselves.
Expand Down Expand Up @@ -167,6 +172,7 @@ const INLINE_QUERY_REGEX = /(?:\?|&)inline(?:$|&)/;
async function getStylesForURL(
filePath: string,
environment: RunnableDevEnvironment,
cssContentCache?: Map<string, string>,
): Promise<{ urls: Set<string>; styles: ImportedDevStyle[]; crawledFiles: Set<string> }> {
const importedCssUrls = new Set<string>();
// Map of url to injected style object. Use a `url` key to deduplicate styles
Expand All @@ -184,6 +190,13 @@ async function getStylesForURL(
if (typeof importedModule.ssrModule?.default === 'string') {
css = importedModule.ssrModule.default;
}
// Check the shared CSS content cache (populated by the dev-css plugin's
// transform hook). This avoids re-importing with `?inline`, which causes
// Lightning CSS to compute a different scoped-name hash because the
// `?inline` suffix changes the filename passed to the hasher.
else if (importedModule.id && cssContentCache?.has(importedModule.id)) {
css = cssContentCache.get(importedModule.id)!;
}
// Else try to load it
else {
let modId = importedModule.url;
Expand Down
17 changes: 14 additions & 3 deletions packages/astro/src/core/build/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,11 @@ export function cssOrder(a: OrderInfo, b: OrderInfo) {
/**
* Merges inline CSS into as few stylesheets as possible,
* preserving ordering when there are non-inlined in between.
*
* CSS chunks containing `@import` are never merged with other chunks.
* Per CSS spec, `@import` rules must appear at the top of a stylesheet
* or browsers silently ignore them. Keeping these chunks as separate
* `<style>` tags ensures the `@import` stays at the top of its own stylesheet.
*/
export function mergeInlineCss(
acc: Array<StylesheetAsset>,
Expand All @@ -68,9 +73,15 @@ export function mergeInlineCss(
const lastWasInline = lastAdded?.type === 'inline';
const currentIsInline = current?.type === 'inline';
if (lastWasInline && currentIsInline) {
const merged = { type: 'inline' as const, content: lastAdded.content + current.content };
acc[acc.length - 1] = merged;
return acc;
// Don't merge chunks that contain @import rules — they must be at the
// top of their stylesheet to be valid CSS.
const currentHasImport = current.content.includes('@import');
const lastHasImport = lastAdded.content.includes('@import');
if (!currentHasImport && !lastHasImport) {
const merged = { type: 'inline' as const, content: lastAdded.content + current.content };
acc[acc.length - 1] = merged;
return acc;
}
}
acc.push(current);
return acc;
Expand Down
9 changes: 7 additions & 2 deletions packages/astro/src/core/create-vite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,11 @@ export async function createVite(
config: settings.config,
});
const serverIslandsState = new ServerIslandsState();
// Shared cache of CSS content by module ID. Populated by the dev-css plugin's
// transform hook and consumed by the content asset propagation plugin to avoid
// re-processing CSS modules with `?inline` (which produces different
// scoped-name hashes with Lightning CSS).
const cssContentCache = new Map<string, string>();

// Validate that envPrefix doesn't conflict with secret env schema variables
validateEnvPrefixAgainstSchema(settings.config);
Expand Down Expand Up @@ -203,7 +208,7 @@ export async function createVite(
vitePluginFetchable({ settings }),
command === 'dev' && vitePluginAstroServer({ settings, logger }),
command === 'dev' && vitePluginAstroServerClient(),
astroDevCssPlugin({ routesList, command }),
astroDevCssPlugin({ routesList, command, cssContentCache }),
importMetaEnv({ envLoader }),
astroEnv({ settings, sync, envLoader }),
vitePluginAdapterConfig(settings),
Expand All @@ -214,7 +219,7 @@ export async function createVite(
astroHeadPlugin(),
astroContentVirtualModPlugin({ fs, settings }),
astroContentImportPlugin({ fs, settings, logger }),
astroContentAssetPropagationPlugin({ settings }),
astroContentAssetPropagationPlugin({ settings, cssContentCache }),
vitePluginMiddleware({ settings }),
astroAssetsPlugin({ fs, settings, sync, logger }),
astroPrefetch({ settings }),
Expand Down
13 changes: 10 additions & 3 deletions packages/astro/src/vite-plugin-css/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ import {
interface AstroVitePluginOptions {
routesList: RoutesList;
command: 'dev' | 'build';
/** Shared cache of CSS content by module ID. Populated by the transform hook and
* consumed by the content asset propagation plugin to avoid re-processing CSS
* modules with `?inline` (which can produce different scoped-name hashes with
* Lightning CSS). */
cssContentCache: Map<string, string>;
}

/**
Expand Down Expand Up @@ -139,10 +144,12 @@ function* collectCSSWithOrder(
*
* @param routesList
*/
export function astroDevCssPlugin({ routesList, command }: AstroVitePluginOptions): Plugin[] {
export function astroDevCssPlugin({
routesList,
command,
cssContentCache,
}: AstroVitePluginOptions): Plugin[] {
let server: vite.ViteDevServer | undefined;
// Cache CSS content by module ID to avoid re-reading
const cssContentCache = new Map<string, string>();

function getCurrentEnvironment(pluginEnv?: DevEnvironment): DevEnvironment | undefined {
return (
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { defineConfig } from 'astro/config';
import react from '@astrojs/react';
import mdx from '@astrojs/mdx';

export default defineConfig({
integrations: [react(), mdx()],
vite: {
css: {
transformer: 'lightningcss',
},
},
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"name": "@test/lightningcss-css-modules-content",
"version": "0.0.0",
"private": true,
"dependencies": {
"astro": "workspace:*",
"@astrojs/mdx": "workspace:*",
"@astrojs/react": "workspace:*",
"lightningcss": "^1.32.0",
"react": "^18.0.0",
"react-dom": "^18.0.0"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import styles from './styles.module.css';

export default function Box() {
return <div className={styles.box}><span>content</span></div>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
.box {
display: grid;
grid-template-columns: repeat(3, 1fr);
background: rebeccapurple;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { defineCollection } from 'astro:content';
import { glob } from 'astro/loaders';

const docs = defineCollection({
loader: glob({ pattern: '**/*.mdx', base: './src/content/docs' }),
});

export const collections = { docs };
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import Box from '../../components/Box.tsx';

# Test page

<Box />
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
const { title } = Astro.props;
---
<html lang="en">
<head><title>{title}</title></head>
<body><slot /></body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
import { getCollection, render } from 'astro:content';
import Layout from '../layouts/Layout.astro';

export async function getStaticPaths() {
const docs = await getCollection('docs');
return docs.map((entry) => ({ params: { slug: entry.id }, props: { entry } }));
}

const { entry } = Astro.props;
const { Content } = await render(entry);
---
<Layout title={entry.id}>
<Content />
</Layout>
67 changes: 67 additions & 0 deletions packages/astro/test/lightningcss-css-modules-content.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import assert from 'node:assert/strict';
import { after, before, describe, it } from 'node:test';
import * as cheerio from 'cheerio';
import { type DevServer, type Fixture, loadFixture } from './test-utils.ts';

// Regression test for https://github.com/withastro/astro/issues/17312.
//
// With `vite.css.transformer: 'lightningcss'`, a React component that imports
// a CSS module and is rendered through the content-collection render path
// (`getCollection` + `render()` → `<Content />`) gets mismatched scoped class
// name hashes in dev: the element class and the injected `<style>` selector
// use different Lightning CSS hashes, so no rule matches and the component
// renders unstyled.
describe('lightningcss + CSS modules via content collection render path', () => {
let fixture: Fixture;
let devServer: DevServer;
let $: cheerio.CheerioAPI;

function getClassAndStyles(doc: cheerio.CheerioAPI) {
const el = doc('div[class]').first();
const className = el.attr('class')!;
const styles = doc('style')
.map((_, s) => doc(s).html())
.get()
.join('\n');

return { className, styles };
}

before(async () => {
fixture = await loadFixture({
root: './fixtures/lightningcss-css-modules-content/',
});
devServer = await fixture.startDevServer();
const html = await fixture.fetch('/test').then((res) => res.text());
$ = cheerio.load(html);
});

after(async () => {
await devServer.stop();
});

it('CSS module class name in element matches the selector in the injected style', () => {
const { className, styles } = getClassAndStyles($);
assert.ok(className, 'expected element to have a class attribute');

assert.ok(
styles.includes(`.${className}`),
`expected injected <style> to contain selector ".${className}" but got:\n${styles}`,
);
});

it('updates injected CSS when the CSS module changes', async () => {
await fixture.editFile('/src/components/styles.module.css', (content) =>
content.replace('display: grid;', 'display: block;'),
);

const html = await fixture.fetch('/test').then((res) => res.text());
const { className, styles } = getClassAndStyles(cheerio.load(html));

assert.ok(
styles.includes(`.${className}`),
`expected injected <style> to contain selector ".${className}" but got:\n${styles}`,
);
assert.match(styles, /display:\s*block/);
});
});
68 changes: 68 additions & 0 deletions packages/astro/test/units/build/css-order.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { mergeInlineCss } from '../../../dist/core/build/runtime.js';

describe('mergeInlineCss', () => {
it('merges consecutive inline CSS chunks', () => {
const result = [
{ type: 'inline' as const, content: '.a{color:red}' },
{ type: 'inline' as const, content: '.b{color:blue}' },
].reduce(mergeInlineCss, []);
assert.equal(result.length, 1);
assert.equal(result[0].type, 'inline');
assert.equal((result[0] as any).content, '.a{color:red}.b{color:blue}');
});

it('does not merge when current chunk contains @import', () => {
const result = [
{ type: 'inline' as const, content: '.a{color:red}' },
{ type: 'inline' as const, content: '@import "https://example.com/font.css";.b{color:blue}' },
].reduce(mergeInlineCss, []);
assert.equal(result.length, 2);
assert.equal((result[0] as any).content, '.a{color:red}');
assert.equal(
(result[1] as any).content,
'@import "https://example.com/font.css";.b{color:blue}',
);
});

it('does not merge when previous chunk contains @import', () => {
const result = [
{ type: 'inline' as const, content: '@import "https://example.com/font.css";.a{color:red}' },
{ type: 'inline' as const, content: '.b{color:blue}' },
].reduce(mergeInlineCss, []);
assert.equal(result.length, 2);
});

it('does not merge when both chunks contain @import', () => {
const result = [
{ type: 'inline' as const, content: '@import "a.css";.a{}' },
{ type: 'inline' as const, content: '@import "b.css";.b{}' },
].reduce(mergeInlineCss, []);
assert.equal(result.length, 2);
});

it('preserves external stylesheets as boundaries', () => {
const result = [
{ type: 'inline' as const, content: '.a{}' },
{ type: 'external' as const, src: '/style.css' },
{ type: 'inline' as const, content: '.b{}' },
].reduce(mergeInlineCss, []);
assert.equal(result.length, 3);
});

it('merges non-import chunks around an import chunk', () => {
const result = [
{ type: 'inline' as const, content: '.a{}' },
{ type: 'inline' as const, content: '.b{}' },
{ type: 'inline' as const, content: '@import "font.css";.c{}' },
{ type: 'inline' as const, content: '.d{}' },
{ type: 'inline' as const, content: '.e{}' },
].reduce(mergeInlineCss, []);
// .a and .b merge, @import stays alone, .d and .e merge
assert.equal(result.length, 3);
assert.equal((result[0] as any).content, '.a{}.b{}');
assert.equal((result[1] as any).content, '@import "font.css";.c{}');
assert.equal((result[2] as any).content, '.d{}.e{}');
});
});
Loading
Loading