diff --git a/.changeset/all-badgers-peel.md b/.changeset/all-badgers-peel.md new file mode 100644 index 000000000000..358c66daf1c5 --- /dev/null +++ b/.changeset/all-badgers-peel.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Fixes `getRelativeLocaleUrl`, `getAbsoluteLocaleUrl`, and `getAbsoluteLocaleUrlList` to strip trailing slashes when `trailingSlash: 'never'` is configured diff --git a/.changeset/itchy-snails-march.md b/.changeset/itchy-snails-march.md new file mode 100644 index 000000000000..5e3f74bdef28 --- /dev/null +++ b/.changeset/itchy-snails-march.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Fixes CSS from `client:only` islands leaking to unrelated pages when Rollup bundles non-CSS-importing modules into the same chunk as CSS-importing modules diff --git a/.changeset/long-cloths-smoke.md b/.changeset/long-cloths-smoke.md new file mode 100644 index 000000000000..d9579ab1f022 --- /dev/null +++ b/.changeset/long-cloths-smoke.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Fixes HMR not triggering for files inside the `src/middleware/` directory during dev diff --git a/packages/astro/src/core/build/plugins/plugin-css.ts b/packages/astro/src/core/build/plugins/plugin-css.ts index 9da995748592..2f1103f7a0a2 100644 --- a/packages/astro/src/core/build/plugins/plugin-css.ts +++ b/packages/astro/src/core/build/plugins/plugin-css.ts @@ -177,6 +177,11 @@ function rollupPluginAstroBuildCSS(options: PluginOptions): VitePlugin[] { // client:only component and if so, add its CSS to the page it belongs to. if (this.environment?.name === ASTRO_VITE_ENVIRONMENT_NAMES.client) { for (const id of Object.keys(chunk.modules)) { + // Only walk from CSS modules to find client:only parents. When Rollup + // merges unrelated modules into the same chunk, walking from every module + // would incorrectly attribute the chunk's CSS to pages reached through + // modules that have no CSS dependency. + if (!isCSSRequest(id)) continue; for (const pageData of getParentClientOnlys(id, this, internals)) { for (const importedCssImport of meta.importedCss) { const cssToInfoRecord = (pagesToCss[pageData.moduleSpecifier] ??= {}); diff --git a/packages/astro/src/core/middleware/vite-plugin.ts b/packages/astro/src/core/middleware/vite-plugin.ts index 2de16d29232b..86848c9111cc 100644 --- a/packages/astro/src/core/middleware/vite-plugin.ts +++ b/packages/astro/src/core/middleware/vite-plugin.ts @@ -20,6 +20,13 @@ export const MIDDLEWARE_MODULE_ID = 'virtual:astro:middleware'; const MIDDLEWARE_RESOLVED_MODULE_ID = '\0' + MIDDLEWARE_MODULE_ID; const NOOP_MIDDLEWARE = '\0noop-middleware'; +export function isMiddlewarePath(relativePath: string): boolean { + return ( + relativePath.startsWith(`${MIDDLEWARE_PATH_SEGMENT_NAME}.`) || + relativePath.startsWith(`${MIDDLEWARE_PATH_SEGMENT_NAME}/`) + ); +} + export function vitePluginMiddleware({ settings }: { settings: AstroSettings }): VitePlugin { let resolvedMiddlewareId: string | undefined = undefined; const hasIntegrationMiddleware = @@ -43,8 +50,7 @@ export function vitePluginMiddleware({ settings }: { settings: AstroSettings }): // Check if the changed file is a middleware file under srcDir if (!normalizedPath.startsWith(normalizedSrcDir)) return; const relativePath = normalizedPath.slice(normalizedSrcDir.length); - // Dot ensures we match "middleware.ts" but not e.g. "middleware-utils.ts" - if (!relativePath.startsWith(`${MIDDLEWARE_PATH_SEGMENT_NAME}.`)) return; + if (!isMiddlewarePath(relativePath)) return; for (const name of [ ASTRO_VITE_ENVIRONMENT_NAMES.ssr, diff --git a/packages/astro/src/i18n/index.ts b/packages/astro/src/i18n/index.ts index 8e963eb4dc33..f9a1c85231fa 100644 --- a/packages/astro/src/i18n/index.ts +++ b/packages/astro/src/i18n/index.ts @@ -1,4 +1,8 @@ -import { appendForwardSlash, joinPaths } from '@astrojs/internal-helpers/path'; +import { + appendForwardSlash, + joinPaths, + removeTrailingForwardSlash, +} from '@astrojs/internal-helpers/path'; import type { RoutingStrategies } from '../core/app/common.js'; import type { SSRManifest } from '../core/app/types.js'; import { shouldAppendForwardSlash } from '../core/build/util.js'; @@ -106,7 +110,7 @@ export function getLocaleRelativeUrl({ if (shouldAppendForwardSlash(trailingSlash, format)) { relativePath = appendForwardSlash(joinPaths(...pathsToJoin)); } else { - relativePath = joinPaths(...pathsToJoin); + relativePath = removeTrailingForwardSlash(joinPaths(...pathsToJoin)); } if (relativePath === '') { diff --git a/packages/astro/test/client-only-css-chunk-leak.test.ts b/packages/astro/test/client-only-css-chunk-leak.test.ts new file mode 100644 index 000000000000..fcffba736893 --- /dev/null +++ b/packages/astro/test/client-only-css-chunk-leak.test.ts @@ -0,0 +1,39 @@ +import assert from 'node:assert/strict'; +import { before, describe, it } from 'node:test'; +import { load as cheerioLoad } from 'cheerio'; +import { type Fixture, loadFixture } from './test-utils.ts'; + +describe('client:only CSS chunk leak', () => { + let fixture: Fixture; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/client-only-css-chunk-leak/', + }); + await fixture.build(); + }); + + it('does not leak CSS to pages that do not use CSS-importing client:only components', async () => { + const html = await fixture.readFile('/about/index.html'); + const $ = cheerioLoad(html); + + const stylesheets = $('link[rel=stylesheet]'); + // The about page only uses CurrentTime (no CSS imports). + // It should have no stylesheets from the shared chunk. + assert.equal(stylesheets.length, 0, 'About page should have no stylesheet links'); + }); + + it('includes CSS on the page that uses the CSS-importing client:only component', async () => { + const html = await fixture.readFile('/index.html'); + const $ = cheerioLoad(html); + + const stylesheets = await Promise.all( + $('link[rel=stylesheet]').map((_, el) => { + return fixture.readFile(el.attribs.href); + }), + ); + const css = stylesheets.join(''); + + assert.match(css, /\.heavy-widget/, 'Home page should include .heavy-widget CSS'); + }); +}); diff --git a/packages/astro/test/fixtures/client-only-css-chunk-leak/astro.config.mjs b/packages/astro/test/fixtures/client-only-css-chunk-leak/astro.config.mjs new file mode 100644 index 000000000000..9c9e4c9115c3 --- /dev/null +++ b/packages/astro/test/fixtures/client-only-css-chunk-leak/astro.config.mjs @@ -0,0 +1,23 @@ +import { defineConfig } from 'astro/config'; +import react from '@astrojs/react'; + +export default defineConfig({ + integrations: [react()], + build: { inlineStylesheets: 'never' }, + vite: { + build: { + rollupOptions: { + output: { + // Force StyledPanel (which imports CSS) and formatLabel (a pure utility) + // into the same chunk. This simulates what Rollup's default heuristics + // do naturally in large apps. + manualChunks(id) { + if (id.includes('StyledPanel') || id.includes('formatLabel')) { + return 'shared-utils'; + } + }, + }, + }, + }, + }, +}); diff --git a/packages/astro/test/fixtures/client-only-css-chunk-leak/package.json b/packages/astro/test/fixtures/client-only-css-chunk-leak/package.json new file mode 100644 index 000000000000..1e0d3ee844cd --- /dev/null +++ b/packages/astro/test/fixtures/client-only-css-chunk-leak/package.json @@ -0,0 +1,11 @@ +{ + "name": "@test/client-only-css-chunk-leak", + "version": "0.0.0", + "private": true, + "dependencies": { + "astro": "workspace:*", + "@astrojs/react": "workspace:*", + "react": "^19.0.0", + "react-dom": "^19.0.0" + } +} diff --git a/packages/astro/test/fixtures/client-only-css-chunk-leak/src/components/CurrentTime.tsx b/packages/astro/test/fixtures/client-only-css-chunk-leak/src/components/CurrentTime.tsx new file mode 100644 index 000000000000..5cd2bef1a6f8 --- /dev/null +++ b/packages/astro/test/fixtures/client-only-css-chunk-leak/src/components/CurrentTime.tsx @@ -0,0 +1,5 @@ +import { formatLabel } from './formatLabel'; + +export default function CurrentTime() { + return {formatLabel('time')}; +} diff --git a/packages/astro/test/fixtures/client-only-css-chunk-leak/src/components/HeavyWidget.tsx b/packages/astro/test/fixtures/client-only-css-chunk-leak/src/components/HeavyWidget.tsx new file mode 100644 index 000000000000..55cf4404c412 --- /dev/null +++ b/packages/astro/test/fixtures/client-only-css-chunk-leak/src/components/HeavyWidget.tsx @@ -0,0 +1,6 @@ +import StyledPanel from './StyledPanel'; +import { formatLabel } from './formatLabel'; + +export default function HeavyWidget() { + return {formatLabel('Widget')}; +} diff --git a/packages/astro/test/fixtures/client-only-css-chunk-leak/src/components/StyledPanel.css b/packages/astro/test/fixtures/client-only-css-chunk-leak/src/components/StyledPanel.css new file mode 100644 index 000000000000..43a37e9ce1b3 --- /dev/null +++ b/packages/astro/test/fixtures/client-only-css-chunk-leak/src/components/StyledPanel.css @@ -0,0 +1 @@ +.heavy-widget { color: red; font-size: 72px; background: limegreen; padding: 2rem; } diff --git a/packages/astro/test/fixtures/client-only-css-chunk-leak/src/components/StyledPanel.tsx b/packages/astro/test/fixtures/client-only-css-chunk-leak/src/components/StyledPanel.tsx new file mode 100644 index 000000000000..a5721e37dd92 --- /dev/null +++ b/packages/astro/test/fixtures/client-only-css-chunk-leak/src/components/StyledPanel.tsx @@ -0,0 +1,5 @@ +import './StyledPanel.css'; + +export default function StyledPanel({ children }: { children: React.ReactNode }) { + return
{children}
; +} diff --git a/packages/astro/test/fixtures/client-only-css-chunk-leak/src/components/formatLabel.ts b/packages/astro/test/fixtures/client-only-css-chunk-leak/src/components/formatLabel.ts new file mode 100644 index 000000000000..c610fdd018a1 --- /dev/null +++ b/packages/astro/test/fixtures/client-only-css-chunk-leak/src/components/formatLabel.ts @@ -0,0 +1,3 @@ +export function formatLabel(text: string): string { + return `[ ${text.toUpperCase()} ]`; +} diff --git a/packages/astro/test/fixtures/client-only-css-chunk-leak/src/layouts/Layout.astro b/packages/astro/test/fixtures/client-only-css-chunk-leak/src/layouts/Layout.astro new file mode 100644 index 000000000000..0baf5ff9bf25 --- /dev/null +++ b/packages/astro/test/fixtures/client-only-css-chunk-leak/src/layouts/Layout.astro @@ -0,0 +1,14 @@ +--- +import CurrentTime from '../components/CurrentTime'; +--- + + + + + CSS Leak Test + + + + + + diff --git a/packages/astro/test/fixtures/client-only-css-chunk-leak/src/pages/about.astro b/packages/astro/test/fixtures/client-only-css-chunk-leak/src/pages/about.astro new file mode 100644 index 000000000000..9b1135ce7a3d --- /dev/null +++ b/packages/astro/test/fixtures/client-only-css-chunk-leak/src/pages/about.astro @@ -0,0 +1,7 @@ +--- +import Layout from '../layouts/Layout.astro'; +--- + + +

About

+
diff --git a/packages/astro/test/fixtures/client-only-css-chunk-leak/src/pages/index.astro b/packages/astro/test/fixtures/client-only-css-chunk-leak/src/pages/index.astro new file mode 100644 index 000000000000..75f2d8aa594b --- /dev/null +++ b/packages/astro/test/fixtures/client-only-css-chunk-leak/src/pages/index.astro @@ -0,0 +1,9 @@ +--- +import Layout from '../layouts/Layout.astro'; +import HeavyWidget from '../components/HeavyWidget'; +--- + + +

Home

+ +
diff --git a/packages/astro/test/units/i18n/astro_i18n.test.ts b/packages/astro/test/units/i18n/astro_i18n.test.ts index fd051bf04f83..3a4f92767244 100644 --- a/packages/astro/test/units/i18n/astro_i18n.test.ts +++ b/packages/astro/test/units/i18n/astro_i18n.test.ts @@ -263,6 +263,88 @@ describe('getLocaleRelativeUrl', () => { ); }); + it('should not add trailing slash when trailingSlash is "never" and path is empty or "/" (#17034)', () => { + const config = { + i18n: { + defaultLocale: 'en', + locales: ['en', 'pl'], + }, + }; + + // path omitted vs path='' should produce the same result + assert.equal( + relativeUrl({ + locale: 'pl', + base: '/', + ...config.i18n, + trailingSlash: 'never', + format: 'directory', + }), + '/pl', + ); + assert.equal( + relativeUrl({ + locale: 'pl', + base: '/', + ...config.i18n, + trailingSlash: 'never', + format: 'directory', + path: '', + }), + '/pl', + ); + assert.equal( + relativeUrl({ + locale: 'pl', + base: '/', + ...config.i18n, + trailingSlash: 'never', + format: 'directory', + path: '/', + }), + '/pl', + ); + + // prependWith + trailing slash in path + assert.equal( + relativeUrl({ + locale: 'pl', + base: '/', + ...config.i18n, + trailingSlash: 'never', + format: 'directory', + path: 'docs/setup/', + prependWith: 'blog', + }), + '/blog/pl/docs/setup', + ); + + // absolute URL should also strip trailing slash + assert.equal( + absoluteUrl({ + locale: 'pl', + base: '/', + ...config.i18n, + trailingSlash: 'never', + format: 'directory', + path: '', + site: 'https://example.com', + }), + 'https://example.com/pl', + ); + + // absoluteUrlList should be consistent across all locales + const list = absoluteUrlList({ + base: '/', + ...config.i18n, + trailingSlash: 'never', + format: 'directory', + path: '', + site: 'https://example.com', + }); + assert.deepEqual(list, ['https://example.com', 'https://example.com/pl']); + }); + it('should normalize locales by default', () => { const config = { base: '/blog', diff --git a/packages/astro/test/units/middleware/middleware-hmr.test.ts b/packages/astro/test/units/middleware/middleware-hmr.test.ts new file mode 100644 index 000000000000..113d73be88af --- /dev/null +++ b/packages/astro/test/units/middleware/middleware-hmr.test.ts @@ -0,0 +1,37 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { isMiddlewarePath } from '../../../dist/core/middleware/vite-plugin.js'; + +describe('middleware HMR path matching', () => { + it('matches middleware.ts (single-file pattern)', () => { + assert.ok(isMiddlewarePath('middleware.ts')); + }); + + it('matches middleware.js', () => { + assert.ok(isMiddlewarePath('middleware.js')); + }); + + it('matches middleware/index.ts (directory pattern)', () => { + assert.ok(isMiddlewarePath('middleware/index.ts')); + }); + + it('matches middleware/test.ts (file inside middleware directory)', () => { + assert.ok(isMiddlewarePath('middleware/test.ts')); + }); + + it('matches middleware/nested/deep.ts (nested file)', () => { + assert.ok(isMiddlewarePath('middleware/nested/deep.ts')); + }); + + it('does not match middleware-utils.ts (similarly named file)', () => { + assert.ok(!isMiddlewarePath('middleware-utils.ts')); + }); + + it('does not match pages/middleware.ts (wrong directory)', () => { + assert.ok(!isMiddlewarePath('pages/middleware.ts')); + }); + + it('does not match other unrelated files', () => { + assert.ok(!isMiddlewarePath('components/Header.astro')); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2ac5b4394611..52c1c353ed67 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2515,6 +2515,21 @@ importers: specifier: workspace:* version: link:../../.. + packages/astro/test/fixtures/client-only-css-chunk-leak: + dependencies: + '@astrojs/react': + specifier: workspace:* + version: link:../../../../integrations/react + astro: + specifier: workspace:* + version: link:../../.. + react: + specifier: ^19.0.0 + version: 19.2.7 + react-dom: + specifier: ^19.0.0 + version: 19.2.7(react@19.2.7) + packages/astro/test/fixtures/code-component: dependencies: astro: @@ -4839,7 +4854,7 @@ importers: version: link:../../astro-prism '@markdoc/markdoc': specifier: ^0.5.4 - version: 0.5.4(@types/react@18.3.28)(react@19.2.4) + version: 0.5.4(@types/react@19.2.17)(react@19.2.7) esbuild: specifier: ^0.27.3 version: 0.27.3 @@ -6020,7 +6035,7 @@ importers: version: link:../../internal-helpers '@vercel/analytics': specifier: ^1.6.1 - version: 1.6.1(react@19.2.4)(svelte@5.55.3)(vue@3.5.30) + version: 1.6.1(react@19.2.7)(svelte@5.55.3)(vue@3.5.30) '@vercel/functions': specifier: ^3.4.3 version: 3.4.3(@aws-sdk/credential-provider-web-identity@3.972.48) @@ -6854,6 +6869,30 @@ importers: specifier: ^4.21.0 version: 4.21.0 + triage/gh-17043: + dependencies: + '@astrojs/node': + specifier: workspace:* + version: link:../../packages/integrations/node + '@astrojs/react': + specifier: workspace:* + version: link:../../packages/integrations/react + '@types/react': + specifier: ^19.2.14 + version: 19.2.17 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.17) + astro: + specifier: workspace:* + version: link:../../packages/astro + react: + specifier: ^19.2.5 + version: 19.2.7 + react-dom: + specifier: ^19.2.5 + version: 19.2.7(react@19.2.7) + packages: '@anthropic-ai/sdk@0.91.1': @@ -10336,9 +10375,17 @@ packages: peerDependencies: '@types/react': ^18.0.0 + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + '@types/react@18.3.28': resolution: {integrity: sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==} + '@types/react@19.2.17': + resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} + '@types/retry@0.12.0': resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==} @@ -14568,6 +14615,11 @@ packages: peerDependencies: react: ^19.2.4 + react-dom@19.2.7: + resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==} + peerDependencies: + react: ^19.2.7 + react-is@17.0.2: resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} @@ -14583,6 +14635,10 @@ packages: resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} engines: {node: '>=0.10.0'} + react@19.2.7: + resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==} + engines: {node: '>=0.10.0'} + read-package-up@11.0.0: resolution: {integrity: sha512-MbgfoNPANMdb4oRBNg5eqLbB2t2r+o5Ua1pNt8BqGp4I0FJZhuVSOj3PaBPni4azWuSzEdNn2evevzVmEk1ohQ==} engines: {node: '>=18'} @@ -18492,12 +18548,12 @@ snapshots: - encoding - supports-color - '@markdoc/markdoc@0.5.4(@types/react@18.3.28)(react@19.2.4)': + '@markdoc/markdoc@0.5.4(@types/react@19.2.17)(react@19.2.7)': optionalDependencies: '@types/linkify-it': 3.0.5 '@types/markdown-it': 12.2.3 - '@types/react': 18.3.28 - react: 19.2.4 + '@types/react': 19.2.17 + react: 19.2.7 '@mdx-js/mdx@3.1.1': dependencies: @@ -19982,11 +20038,19 @@ snapshots: dependencies: '@types/react': 18.3.28 + '@types/react-dom@19.2.3(@types/react@19.2.17)': + dependencies: + '@types/react': 19.2.17 + '@types/react@18.3.28': dependencies: '@types/prop-types': 15.7.15 csstype: 3.2.3 + '@types/react@19.2.17': + dependencies: + csstype: 3.2.3 + '@types/retry@0.12.0': {} '@types/retry@0.12.2': {} @@ -20183,9 +20247,9 @@ snapshots: dependencies: valibot: 1.2.0(typescript@6.0.3) - '@vercel/analytics@1.6.1(react@19.2.4)(svelte@5.55.3)(vue@3.5.30)': + '@vercel/analytics@1.6.1(react@19.2.7)(svelte@5.55.3)(vue@3.5.30)': optionalDependencies: - react: 19.2.4 + react: 19.2.7 svelte: 5.55.3 vue: 3.5.30(typescript@6.0.3) @@ -25005,6 +25069,11 @@ snapshots: react: 19.2.4 scheduler: 0.27.0 + react-dom@19.2.7(react@19.2.7): + dependencies: + react: 19.2.7 + scheduler: 0.27.0 + react-is@17.0.2: {} react-refresh@0.18.0: {} @@ -25015,6 +25084,8 @@ snapshots: react@19.2.4: {} + react@19.2.7: {} + read-package-up@11.0.0: dependencies: find-up-simple: 1.0.1