diff --git a/.changeset/lucky-kiwis-swim.md b/.changeset/lucky-kiwis-swim.md
new file mode 100644
index 000000000000..01c615bd6fc8
--- /dev/null
+++ b/.changeset/lucky-kiwis-swim.md
@@ -0,0 +1,5 @@
+---
+'astro': patch
+---
+
+Fixes a bug where page-level CSS could leak between unrelated pages when traversing style parents across top-level route boundaries
diff --git a/packages/astro/src/core/build/plugins/plugin-css.ts b/packages/astro/src/core/build/plugins/plugin-css.ts
index c2c40c9b4d86..d412403ff0df 100644
--- a/packages/astro/src/core/build/plugins/plugin-css.ts
+++ b/packages/astro/src/core/build/plugins/plugin-css.ts
@@ -30,6 +30,12 @@ interface PluginOptions {
buildOptions: StaticBuildOptions;
}
+function isBuildCssBoundary(id: string, ctx: { getModuleInfo: GetModuleInfo }): boolean {
+ if (isPropagatedAssetBoundary(id)) return true;
+ const info = ctx.getModuleInfo(id);
+ return info ? moduleIsTopLevelPage(info) : false;
+}
+
function rollupPluginAstroBuildCSS(options: PluginOptions): VitePlugin[] {
const { internals, buildOptions } = options;
const { settings } = buildOptions;
@@ -158,7 +164,7 @@ function rollupPluginAstroBuildCSS(options: PluginOptions): VitePlugin[] {
const parentModuleInfos = getParentExtendedModuleInfos(
scopedToModule,
this,
- isPropagatedAssetBoundary,
+ (moduleId) => isBuildCssBoundary(moduleId, this),
);
for (const { info: pageInfo, depth, order } of parentModuleInfos) {
if (moduleIsTopLevelPage(pageInfo)) {
@@ -230,7 +236,7 @@ function rollupPluginAstroBuildCSS(options: PluginOptions): VitePlugin[] {
const parentModuleInfos = getParentExtendedModuleInfos(
id,
this,
- isPropagatedAssetBoundary,
+ (importer) => isBuildCssBoundary(importer, this),
);
for (const { info: pageInfo, depth, order } of parentModuleInfos) {
if (isPropagatedAssetBoundary(pageInfo.id)) {
diff --git a/packages/astro/test/fixtures/i18n-css-leak-basic/astro.config.mjs b/packages/astro/test/fixtures/i18n-css-leak-basic/astro.config.mjs
new file mode 100644
index 000000000000..9a4d452f1628
--- /dev/null
+++ b/packages/astro/test/fixtures/i18n-css-leak-basic/astro.config.mjs
@@ -0,0 +1,14 @@
+import { defineConfig } from 'astro/config';
+
+export default defineConfig({
+ build: {
+ inlineStylesheets: 'never',
+ },
+ i18n: {
+ locales: ['en'],
+ defaultLocale: 'en',
+ routing: {
+ redirectToDefaultLocale: false,
+ },
+ },
+});
diff --git a/packages/astro/test/fixtures/i18n-css-leak-basic/package.json b/packages/astro/test/fixtures/i18n-css-leak-basic/package.json
new file mode 100644
index 000000000000..1efbbaff7eba
--- /dev/null
+++ b/packages/astro/test/fixtures/i18n-css-leak-basic/package.json
@@ -0,0 +1,8 @@
+{
+ "name": "@test/i18n-css-leak-basic",
+ "version": "0.0.0",
+ "private": true,
+ "dependencies": {
+ "astro": "workspace:*"
+ }
+}
diff --git a/packages/astro/test/fixtures/i18n-css-leak-basic/src/components/Header.astro b/packages/astro/test/fixtures/i18n-css-leak-basic/src/components/Header.astro
new file mode 100644
index 000000000000..202a09e3f04c
--- /dev/null
+++ b/packages/astro/test/fixtures/i18n-css-leak-basic/src/components/Header.astro
@@ -0,0 +1,9 @@
+---
+import { getRelativeLocaleUrl } from 'astro:i18n';
+
+const docsHref = getRelativeLocaleUrl('en', 'docs');
+---
+
+
diff --git a/packages/astro/test/fixtures/i18n-css-leak-basic/src/layouts/DocsLayout.astro b/packages/astro/test/fixtures/i18n-css-leak-basic/src/layouts/DocsLayout.astro
new file mode 100644
index 000000000000..13a014e3f214
--- /dev/null
+++ b/packages/astro/test/fixtures/i18n-css-leak-basic/src/layouts/DocsLayout.astro
@@ -0,0 +1,12 @@
+---
+import '../styles/docs.css';
+---
+
+
+
+ Docs
+
+
+
+
+
diff --git a/packages/astro/test/fixtures/i18n-css-leak-basic/src/layouts/SiteLayout.astro b/packages/astro/test/fixtures/i18n-css-leak-basic/src/layouts/SiteLayout.astro
new file mode 100644
index 000000000000..c264ec08e0c9
--- /dev/null
+++ b/packages/astro/test/fixtures/i18n-css-leak-basic/src/layouts/SiteLayout.astro
@@ -0,0 +1,14 @@
+---
+import Header from '../components/Header.astro';
+import '../styles/site.css';
+---
+
+
+
+ Site
+
+
+
+
+
+
diff --git a/packages/astro/test/fixtures/i18n-css-leak-basic/src/pages/docs/index.astro b/packages/astro/test/fixtures/i18n-css-leak-basic/src/pages/docs/index.astro
new file mode 100644
index 000000000000..997686a93b13
--- /dev/null
+++ b/packages/astro/test/fixtures/i18n-css-leak-basic/src/pages/docs/index.astro
@@ -0,0 +1,7 @@
+---
+import DocsLayout from '../../layouts/DocsLayout.astro';
+---
+
+
+ Docs
+
diff --git a/packages/astro/test/fixtures/i18n-css-leak-basic/src/pages/index.astro b/packages/astro/test/fixtures/i18n-css-leak-basic/src/pages/index.astro
new file mode 100644
index 000000000000..d8703482bd48
--- /dev/null
+++ b/packages/astro/test/fixtures/i18n-css-leak-basic/src/pages/index.astro
@@ -0,0 +1,7 @@
+---
+import SiteLayout from '../layouts/SiteLayout.astro';
+---
+
+
+ Home
+
diff --git a/packages/astro/test/fixtures/i18n-css-leak-basic/src/styles/docs.css b/packages/astro/test/fixtures/i18n-css-leak-basic/src/styles/docs.css
new file mode 100644
index 000000000000..d6295bd006d6
--- /dev/null
+++ b/packages/astro/test/fixtures/i18n-css-leak-basic/src/styles/docs.css
@@ -0,0 +1,7 @@
+body {
+ background: black;
+}
+
+h1 {
+ color: red;
+}
diff --git a/packages/astro/test/fixtures/i18n-css-leak-basic/src/styles/site.css b/packages/astro/test/fixtures/i18n-css-leak-basic/src/styles/site.css
new file mode 100644
index 000000000000..5b6976fbff55
--- /dev/null
+++ b/packages/astro/test/fixtures/i18n-css-leak-basic/src/styles/site.css
@@ -0,0 +1,3 @@
+body {
+ background: white;
+}
diff --git a/packages/astro/test/i18n-css-leak.test.js b/packages/astro/test/i18n-css-leak.test.js
new file mode 100644
index 000000000000..839d9b946140
--- /dev/null
+++ b/packages/astro/test/i18n-css-leak.test.js
@@ -0,0 +1,40 @@
+import assert from 'node:assert/strict';
+import { before, describe, it } from 'node:test';
+import { load as cheerioLoad } from 'cheerio';
+import { loadFixture } from './test-utils.js';
+
+describe('CSS graph boundaries with astro:i18n', () => {
+ let fixture;
+
+ before(async () => {
+ fixture = await loadFixture({
+ root: './fixtures/i18n-css-leak-basic/',
+ build: { inlineStylesheets: 'never' },
+ });
+ await fixture.build();
+ });
+
+ async function getPageCss(pathname) {
+ const html = await fixture.readFile(pathname);
+ const $ = cheerioLoad(html);
+ const hrefs = $('link[rel=stylesheet]')
+ .map((_index, el) => $(el).attr('href'))
+ .get();
+ const stylesheets = await Promise.all(hrefs.map((href) => fixture.readFile(href)));
+ return stylesheets.join('\n');
+ }
+
+ it('does not attach docs-only CSS to unrelated pages', async () => {
+ const css = await getPageCss('/index.html');
+ assert.match(css, /background:#fff/);
+ assert.doesNotMatch(css, /background:#000/);
+ assert.doesNotMatch(css, /color:red/);
+ });
+
+ it('keeps docs-only CSS on the docs page', async () => {
+ const css = await getPageCss('/docs/index.html');
+ assert.match(css, /background:#000/);
+ assert.match(css, /color:red/);
+ assert.doesNotMatch(css, /background:#fff/);
+ });
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 127f98557456..498e9f6e178b 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -3424,6 +3424,12 @@ importers:
specifier: ^10.29.0
version: 10.29.0
+ packages/astro/test/fixtures/i18n-css-leak-basic:
+ dependencies:
+ astro:
+ specifier: workspace:*
+ version: link:../../..
+
packages/astro/test/fixtures/i18n-routing-base:
dependencies:
astro: