diff --git a/.changeset/kind-squids-warn.md b/.changeset/kind-squids-warn.md new file mode 100644 index 000000000000..31e747cb284f --- /dev/null +++ b/.changeset/kind-squids-warn.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Reintroduces css deduplication for hydrated client components. Ensures assets already added to a client chunk are not flagged as orphaned diff --git a/packages/astro/src/core/build/plugins/plugin-css.ts b/packages/astro/src/core/build/plugins/plugin-css.ts index c8854e408f18..f20540be63ad 100644 --- a/packages/astro/src/core/build/plugins/plugin-css.ts +++ b/packages/astro/src/core/build/plugins/plugin-css.ts @@ -80,7 +80,10 @@ function rollupPluginAstroBuildCSS(options: PluginOptions): VitePlugin[] { // and that's okay. We can use Rollup's default chunk strategy instead as these CSS // are outside of the SSR build scope, which no dedupe is needed. if (options.target === 'client') { - return internals.cssModuleToChunkIdMap.get(id)!; + // Find the chunkId for this CSS module in the server build. + // If it exists, we can use it to ensure the client build matches the server + // build and doesn't create a duplicate chunk. + return internals.cssModuleToChunkIdMap.get(id); } const ctx = { getModuleInfo: meta.getModuleInfo }; @@ -93,6 +96,7 @@ function rollupPluginAstroBuildCSS(options: PluginOptions): VitePlugin[] { return chunkId; } } + const chunkId = createNameForParentPages(id, meta); internals.cssModuleToChunkIdMap.set(id, chunkId); return chunkId; @@ -230,8 +234,19 @@ function rollupPluginAstroBuildCSS(options: PluginOptions): VitePlugin[] { sheetAddedToPage = true; } - if (toBeInlined && sheetAddedToPage) { - // CSS is already added to all used pages, we can delete it from the bundle + const wasInlined = toBeInlined && sheetAddedToPage; + // stylesheets already referenced as an asset by a chunk will not be inlined by + // this plugin, but should not be considered orphaned + const wasAddedToChunk = Object.values(bundle).some((chunk) => + chunk.type === 'chunk' && + chunk.viteMetadata?.importedAssets?.has(id) + ); + const isOrphaned = !sheetAddedToPage && !wasAddedToChunk; + + if (wasInlined || isOrphaned) { + // wasInlined : CSS is already added to all used pages + // isOrphaned : CSS is already used in a merged chunk + // we can delete it from the bundle // and make sure no chunks reference it via `importedCss` (for Vite preloading) // to avoid duplicate CSS. delete bundle[id]; diff --git a/packages/astro/test/css-deduplication.test.js b/packages/astro/test/css-deduplication.test.js new file mode 100644 index 000000000000..829d6790cfc6 --- /dev/null +++ b/packages/astro/test/css-deduplication.test.js @@ -0,0 +1,81 @@ +import * as 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 deduplication for hydrated components', () => { + describe('inlineStylesheets: never', () => { + let fixture; + before(async () => { + fixture = await loadFixture({ + site: 'https://test.dev/', + root: './fixtures/css-deduplication/', + build: { inlineStylesheets: 'never' }, + outDir: './dist/inline-stylesheets-never', + }); + await fixture.build(); + }); + + it('should not duplicate CSS for hydrated components', async () => { + const assets = await fixture.readdir('/_astro'); + + // Generated file for Counter.css + const COUNTER_CSS_PATH = '/_astro/index.DbgLc3FE.css'; + let file = await fixture.readFile(COUNTER_CSS_PATH); + file = file.replace(/\s+/g, ''); + + for (const fileName of assets) { + const filePath = `/_astro/${fileName}`; + if (filePath === COUNTER_CSS_PATH || !fileName.endsWith('.css')) { + continue; + } + + let r = await fixture.readFile(filePath); + r = r.replace(/\s+/g, ''); + if (file.includes(r)) { + assert.fail(`Duplicate CSS file: ${fileName}`); + } + } + assert.ok(true); + }); + }); + + describe('inlineStylesheets: always', () => { + let fixture; + before(async () => { + fixture = await loadFixture({ + site: 'https://test.dev/', + root: './fixtures/css-deduplication/', + build: { inlineStylesheets: 'always' }, + outDir: './dist/inline-stylesheets-always', + }); + await fixture.build(); + }); + + it('should not emit any .css file when inlineStylesheets is always', async () => { + const assets = await fixture.readdir('/_astro'); + assert.ok(!assets.some((f) => f.endsWith('.css'))); + }); + + it('should not duplicate CSS for hydrated components', async () => { + const html = await fixture.readFile('/index.html'); + const $ = cheerio.load(html); + + // Get all + +
+