diff --git a/.changeset/fix-css-double-bundle.md b/.changeset/fix-css-double-bundle.md new file mode 100644 index 000000000000..4aff18a65e61 --- /dev/null +++ b/.changeset/fix-css-double-bundle.md @@ -0,0 +1,5 @@ +--- +"astro": patch +--- + +Fixes CSS double-bundling when the same CSS file is imported in both a page's frontmatter and a component's script tag diff --git a/packages/astro/src/core/build/plugins/plugin-css.ts b/packages/astro/src/core/build/plugins/plugin-css.ts index c8854e408f18..b1bb8873c30c 100644 --- a/packages/astro/src/core/build/plugins/plugin-css.ts +++ b/packages/astro/src/core/build/plugins/plugin-css.ts @@ -185,6 +185,7 @@ function rollupPluginAstroBuildCSS(options: PluginOptions): VitePlugin[] { }, async generateBundle(_outputOptions, bundle) { const inlineConfig = settings.config.build.inlineStylesheets; + Object.entries(bundle).forEach(([id, stylesheet]) => { if ( stylesheet.type !== 'asset' || @@ -211,7 +212,23 @@ function rollupPluginAstroBuildCSS(options: PluginOptions): VitePlugin[] { internals.pagesByKeys.forEach((pageData) => { const orderingInfo = pagesToCss[pageData.moduleSpecifier]?.[stylesheet.fileName]; if (orderingInfo !== undefined) { - pageData.styles.push({ ...orderingInfo, sheet }); + // Check if this stylesheet was already added to this page. + // We check both inline (by content) and external (by src) styles to prevent + // duplicates that can occur when CSS is imported from both a page's frontmatter + // and a component's script tag, or when the same plugin runs in both SSR and client builds. + const alreadyAdded = pageData.styles.some((s) => { + if (s.sheet.type === 'external' && sheet.type === 'external') { + return s.sheet.src === sheet.src; + } + if (s.sheet.type === 'inline' && sheet.type === 'inline') { + return s.sheet.content === sheet.content; + } + return false; + }); + + if (!alreadyAdded) { + pageData.styles.push({ ...orderingInfo, sheet }); + } sheetAddedToPage = true; } }); diff --git a/packages/astro/test/css-double-bundle.test.js b/packages/astro/test/css-double-bundle.test.js new file mode 100644 index 000000000000..f03ba17fe51c --- /dev/null +++ b/packages/astro/test/css-double-bundle.test.js @@ -0,0 +1,60 @@ +import assert from 'node:assert/strict'; +import { before, describe, it } from 'node:test'; +import * as cheerio from 'cheerio'; +import { loadFixture } from './test-utils.js'; + +describe('CSS Double Bundling Prevention', function () { + /** @type {import('./test-utils').Fixture} */ + let fixture; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/css-double-bundle/', + }); + await fixture.build(); + }); + + it('CSS imported in both page frontmatter and component script should only be bundled once', async () => { + const html = await fixture.readFile('/index.html'); + const $ = cheerio.load(html); + + // Get all CSS content (both inline and linked) + let allCss = ''; + + // Check inline styles + $('style').each((_, el) => { + allCss += $(el).html(); + }); + + // Check linked stylesheets + const cssLinks = $('link[rel="stylesheet"][href^="/_astro/"]'); + for (let i = 0; i < cssLinks.length; i++) { + const href = cssLinks.eq(i).attr('href'); + const cssContent = await fixture.readFile(href.replace(/^\//, '/')); + allCss += cssContent; + } + + // Count occurrences of the CSS rule - should appear exactly once + const matches = allCss.match(/button\s*\{[^}]*background:\s*purple/g) || []; + + assert.equal( + matches.length, + 1, + `Expected CSS rule "button{background:purple}" to appear exactly once, but found ${matches.length} occurrences. CSS should not be double-bundled when imported from both page frontmatter and component script.`, + ); + }); + + it('CSS should still be present in the build output', async () => { + const html = await fixture.readFile('/index.html'); + const $ = cheerio.load(html); + + // Should have either inline styles or linked stylesheet + const hasInlineStyle = $('style').length > 0; + const hasLinkedStylesheet = $('link[rel="stylesheet"]').length > 0; + + assert.ok( + hasInlineStyle || hasLinkedStylesheet, + 'Expected CSS to be present in the build output (either inline or as a linked stylesheet)', + ); + }); +}); diff --git a/packages/astro/test/fixtures/css-double-bundle/astro.config.mjs b/packages/astro/test/fixtures/css-double-bundle/astro.config.mjs new file mode 100644 index 000000000000..86dbfb924824 --- /dev/null +++ b/packages/astro/test/fixtures/css-double-bundle/astro.config.mjs @@ -0,0 +1,3 @@ +import { defineConfig } from 'astro/config'; + +export default defineConfig({}); diff --git a/packages/astro/test/fixtures/css-double-bundle/package.json b/packages/astro/test/fixtures/css-double-bundle/package.json new file mode 100644 index 000000000000..35f81d86aaf4 --- /dev/null +++ b/packages/astro/test/fixtures/css-double-bundle/package.json @@ -0,0 +1,8 @@ +{ + "name": "@test/css-double-bundle", + "version": "0.0.0", + "private": true, + "dependencies": { + "astro": "workspace:*" + } +} diff --git a/packages/astro/test/fixtures/css-double-bundle/src/components/Button.astro b/packages/astro/test/fixtures/css-double-bundle/src/components/Button.astro new file mode 100644 index 000000000000..ff563a1fba0a --- /dev/null +++ b/packages/astro/test/fixtures/css-double-bundle/src/components/Button.astro @@ -0,0 +1,5 @@ + + + diff --git a/packages/astro/test/fixtures/css-double-bundle/src/pages/index.astro b/packages/astro/test/fixtures/css-double-bundle/src/pages/index.astro new file mode 100644 index 000000000000..9bdf33a027dc --- /dev/null +++ b/packages/astro/test/fixtures/css-double-bundle/src/pages/index.astro @@ -0,0 +1,13 @@ +--- +import "../styles/base.css"; +import Button from "../components/Button.astro"; +--- + + + + + + +