From 05771cf42e17d67beca3ba5d667a6fef54479c00 Mon Sep 17 00:00:00 2001 From: Rafael Yasuhide Sudo Date: Mon, 9 Mar 2026 19:59:59 +0900 Subject: [PATCH 1/8] fix(cloudflare): resolve build error by adding picomatch to `optimizeDeps.include` (#15798) Co-authored-by: Emanuele Stoppa --- .changeset/twenty-zebras-hammer.md | 5 ++ packages/integrations/cloudflare/src/index.ts | 1 + .../test/fixtures/with-react/astro.config.mjs | 9 +++ .../test/fixtures/with-react/package.json | 12 ++++ .../with-react/src/components/Component.tsx | 1 + .../fixtures/with-react/src/pages/index.astro | 13 ++++ .../cloudflare/test/with-react.test.js | 70 +++++++++++++++++++ pnpm-lock.yaml | 18 +++++ 8 files changed, 129 insertions(+) create mode 100644 .changeset/twenty-zebras-hammer.md create mode 100644 packages/integrations/cloudflare/test/fixtures/with-react/astro.config.mjs create mode 100644 packages/integrations/cloudflare/test/fixtures/with-react/package.json create mode 100644 packages/integrations/cloudflare/test/fixtures/with-react/src/components/Component.tsx create mode 100644 packages/integrations/cloudflare/test/fixtures/with-react/src/pages/index.astro create mode 100644 packages/integrations/cloudflare/test/with-react.test.js diff --git a/.changeset/twenty-zebras-hammer.md b/.changeset/twenty-zebras-hammer.md new file mode 100644 index 000000000000..0fe530d8425b --- /dev/null +++ b/.changeset/twenty-zebras-hammer.md @@ -0,0 +1,5 @@ +--- +'@astrojs/cloudflare': patch +--- + +Fixes a regression where using the adapter would throw an error when using an integration that uses JSX. diff --git a/packages/integrations/cloudflare/src/index.ts b/packages/integrations/cloudflare/src/index.ts index d10c07887801..abd80abe0d2b 100644 --- a/packages/integrations/cloudflare/src/index.ts +++ b/packages/integrations/cloudflare/src/index.ts @@ -188,6 +188,7 @@ export default function createIntegration({ 'astro > unstorage', 'astro > neotraverse/modern', 'astro > piccolore', + 'astro > picomatch', 'astro/app', 'astro/assets', 'astro/compiler-runtime', diff --git a/packages/integrations/cloudflare/test/fixtures/with-react/astro.config.mjs b/packages/integrations/cloudflare/test/fixtures/with-react/astro.config.mjs new file mode 100644 index 000000000000..03d167a75089 --- /dev/null +++ b/packages/integrations/cloudflare/test/fixtures/with-react/astro.config.mjs @@ -0,0 +1,9 @@ +import cloudflare from '@astrojs/cloudflare'; +import react from "@astrojs/react"; +import { defineConfig } from 'astro/config'; + +export default defineConfig({ + integrations: [react()], + adapter: cloudflare(), + output: 'server', +}); diff --git a/packages/integrations/cloudflare/test/fixtures/with-react/package.json b/packages/integrations/cloudflare/test/fixtures/with-react/package.json new file mode 100644 index 000000000000..33177f0c6c87 --- /dev/null +++ b/packages/integrations/cloudflare/test/fixtures/with-react/package.json @@ -0,0 +1,12 @@ +{ + "name": "@test/astro-cloudflare-with-react", + "version": "0.0.0", + "private": true, + "dependencies": { + "@astrojs/cloudflare": "workspace:*", + "@astrojs/react": "workspace:*", + "astro": "workspace:*", + "react": "^19.2.4", + "react-dom": "^19.2.4" + } +} diff --git a/packages/integrations/cloudflare/test/fixtures/with-react/src/components/Component.tsx b/packages/integrations/cloudflare/test/fixtures/with-react/src/components/Component.tsx new file mode 100644 index 000000000000..b69af7516597 --- /dev/null +++ b/packages/integrations/cloudflare/test/fixtures/with-react/src/components/Component.tsx @@ -0,0 +1 @@ +export const Component = () =>
React Content
diff --git a/packages/integrations/cloudflare/test/fixtures/with-react/src/pages/index.astro b/packages/integrations/cloudflare/test/fixtures/with-react/src/pages/index.astro new file mode 100644 index 000000000000..f185a95d35e1 --- /dev/null +++ b/packages/integrations/cloudflare/test/fixtures/with-react/src/pages/index.astro @@ -0,0 +1,13 @@ +--- +import {Component} from "../components/Component"; +--- + + + + Testing + + +

Testing

+ + + diff --git a/packages/integrations/cloudflare/test/with-react.test.js b/packages/integrations/cloudflare/test/with-react.test.js new file mode 100644 index 000000000000..e57388c36897 --- /dev/null +++ b/packages/integrations/cloudflare/test/with-react.test.js @@ -0,0 +1,70 @@ +import * as assert from 'node:assert/strict'; +import { rmSync } from 'node:fs'; +import { Writable } from 'node:stream'; +import { after, before, describe, it } from 'node:test'; +import * as cheerio from 'cheerio'; +import { loadFixture } from './_test-utils.js'; +import { Logger } from '../../../astro/dist/core/logger/core.js'; +import { fileURLToPath } from 'node:url'; + +describe('React', () => { + let fixture; + let previewServer; + const logs = []; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/with-react/', + }); + + // Clear the Vite cache before testing + const viteCacheDir = new URL('./node_modules/.vite/', fixture.config.root); + + rmSync(fileURLToPath(viteCacheDir), { recursive: true, force: true }); + + await fixture.build({ + vite: { logLevel: 'debug' }, + logger: new Logger({ + level: 'debug', + dest: new Writable({ + objectMode: true, + write(event, _, callback) { + logs.push(event); + callback(); + }, + }), + }), + }); + previewServer = await fixture.preview(); + }); + + after(async () => { + await previewServer.stop(); + await fixture.clean(); + }); + + it('renders the react component', async () => { + const res = await fixture.fetch('/'); + assert.equal(res.status, 200); + const html = await res.text(); + const $ = cheerio.load(html); + assert.equal($('.react').text(), 'React Content'); + }); + + // ref: https://github.com/withastro/astro/issues/15796 + // without pre-optimizing picomatch, a build error occurs in standard repositories, but it's not triggered in this monorepo. + // as a workaround, we verify the fix by checking if the "new dependencies optimized" log is output. + it('picomatch should be pre-optimized', async () => { + const picomatchDependenciesOptimizedLog = logs.find( + (log) => + log.message && + log.message.includes('new dependencies optimized') && + log.message.includes('picomatch'), + ); + + assert.ok( + !picomatchDependenciesOptimizedLog, + `Should not see "new dependencies optimized: picomatch" message, but got: ${picomatchDependenciesOptimizedLog?.message}`, + ); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e112665c0604..435fa06b4637 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5159,6 +5159,24 @@ importers: specifier: workspace:* version: link:../../../../../astro + packages/integrations/cloudflare/test/fixtures/with-react: + dependencies: + '@astrojs/cloudflare': + specifier: workspace:* + version: link:../../.. + '@astrojs/react': + specifier: workspace:* + version: link:../../../../react + astro: + specifier: workspace:* + version: link:../../../../../astro + react: + specifier: ^19.2.4 + version: 19.2.4 + react-dom: + specifier: ^19.2.4 + version: 19.2.4(react@19.2.4) + packages/integrations/cloudflare/test/fixtures/with-solid-js: dependencies: '@astrojs/cloudflare': From 074901fe6b599e9ffc740feb1c06d4590e9da43a Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Mon, 9 Mar 2026 11:35:12 +0000 Subject: [PATCH 2/8] fix: race condition in resolvedPathname (#15506) --- .changeset/fix-resolved-pathname-race.md | 5 + packages/astro/src/core/app/base.ts | 7 +- packages/astro/src/core/app/dev/app.ts | 10 -- packages/astro/src/vite-plugin-app/app.ts | 7 +- .../units/routing/resolved-pathname.test.js | 91 +++++++++++++++++++ 5 files changed, 103 insertions(+), 17 deletions(-) create mode 100644 .changeset/fix-resolved-pathname-race.md create mode 100644 packages/astro/test/units/routing/resolved-pathname.test.js diff --git a/.changeset/fix-resolved-pathname-race.md b/.changeset/fix-resolved-pathname-race.md new file mode 100644 index 000000000000..e4e6e50195f9 --- /dev/null +++ b/.changeset/fix-resolved-pathname-race.md @@ -0,0 +1,5 @@ +--- +"astro": patch +--- + +Fixes a race condition where concurrent requests to dynamic routes in the dev server could produce incorrect params. diff --git a/packages/astro/src/core/app/base.ts b/packages/astro/src/core/app/base.ts index 7f04e14f7cef..b66acc71b728 100644 --- a/packages/astro/src/core/app/base.ts +++ b/packages/astro/src/core/app/base.ts @@ -469,7 +469,12 @@ export abstract class BaseApp

{ status: 404, }); } - const pathname = this.getPathnameFromRequest(request); + let pathname = this.getPathnameFromRequest(request); + // In dev, the route may have matched a normalized pathname (after .html stripping). + // Apply the same normalization for correct param extraction. + if (this.isDev()) { + pathname = pathname.replace(/\/index\.html$/, '/').replace(/\.html$/, ''); + } const defaultStatus = this.getDefaultStatusCode(routeData, pathname); let response; diff --git a/packages/astro/src/core/app/dev/app.ts b/packages/astro/src/core/app/dev/app.ts index a65bff2306e2..fa4d4118d55d 100644 --- a/packages/astro/src/core/app/dev/app.ts +++ b/packages/astro/src/core/app/dev/app.ts @@ -2,7 +2,6 @@ import type { RouteData } from '../../../types/public/index.js'; import { MiddlewareNoDataOrNextCalled, MiddlewareNotAResponse } from '../../errors/errors-data.js'; import { type AstroError, isAstroError } from '../../errors/index.js'; import type { Logger } from '../../logger/core.js'; -import type { CreateRenderContext, RenderContext } from '../../render-context.js'; import { BaseApp, type DevMatch, @@ -20,7 +19,6 @@ import { req } from '../../messages/runtime.js'; export class DevApp extends BaseApp { logger: Logger; - resolvedPathname: string | undefined = undefined; constructor(manifest: SSRManifest, streaming = true, logger: Logger) { super(manifest, streaming, logger); this.logger = logger; @@ -60,20 +58,12 @@ export class DevApp extends BaseApp { ); if (!matchedRoute) return undefined; - this.resolvedPathname = matchedRoute.resolvedPathname; return { routeData: matchedRoute.route, resolvedPathname: matchedRoute.resolvedPathname, }; } - async createRenderContext(payload: CreateRenderContext): Promise { - return super.createRenderContext({ - ...payload, - pathname: this.resolvedPathname ?? payload.pathname, - }); - } - async renderError( request: Request, { diff --git a/packages/astro/src/vite-plugin-app/app.ts b/packages/astro/src/vite-plugin-app/app.ts index 41c5ea2c3f5b..87fac3b54501 100644 --- a/packages/astro/src/vite-plugin-app/app.ts +++ b/packages/astro/src/vite-plugin-app/app.ts @@ -31,7 +31,6 @@ export class AstroServerApp extends BaseApp { loader: ModuleLoader; manifestData: RoutesList; currentRenderContext: RenderContext | undefined = undefined; - resolvedPathname: string | undefined = undefined; constructor( manifest: SSRManifest, streaming = true, @@ -119,10 +118,7 @@ export class AstroServerApp extends BaseApp { } async createRenderContext(payload: CreateRenderContext): Promise { - this.currentRenderContext = await super.createRenderContext({ - ...payload, - pathname: this.resolvedPathname ?? payload.pathname, - }); + this.currentRenderContext = await super.createRenderContext(payload); return this.currentRenderContext; } @@ -184,7 +180,6 @@ export class AstroServerApp extends BaseApp { throw new Error('No route matched, and default 404 route was not found.'); } - self.resolvedPathname = matchedRoute.resolvedPathname; const request = createRequest({ url, headers: incomingRequest.headers, diff --git a/packages/astro/test/units/routing/resolved-pathname.test.js b/packages/astro/test/units/routing/resolved-pathname.test.js new file mode 100644 index 000000000000..5f2a31d376b8 --- /dev/null +++ b/packages/astro/test/units/routing/resolved-pathname.test.js @@ -0,0 +1,91 @@ +import * as assert from 'node:assert/strict'; +import { after, before, describe, it } from 'node:test'; + +import { createContainer } from '../../../dist/core/dev/container.js'; +import testAdapter from '../../test-adapter.js'; +import { + createBasicSettings, + createFixture, + createRequestAndResponse, + defaultLogger, +} from '../test-utils.js'; + +const fileSystem = { + '/src/pages/api/[category]/[id].ts': ` + export const prerender = false; + export function GET({ params, url }) { + return Response.json({ params, pathname: url.pathname }); + } + `, + '/src/pages/api/[category]/index.ts': ` + export const prerender = false; + export function GET({ params, url }) { + return Response.json({ params, pathname: url.pathname }); + } + `, +}; + +describe('Resolved pathname in dev server', () => { + let container; + + before(async () => { + const fixture = await createFixture(fileSystem); + const settings = await createBasicSettings({ + root: fixture.path, + output: 'server', + adapter: testAdapter(), + trailingSlash: 'never', + }); + container = await createContainer({ + settings, + logger: defaultLogger, + }); + }); + + after(async () => { + await container.close(); + }); + + it('should resolve params correctly for .html requests to dynamic routes', async () => { + const { req, res, json } = createRequestAndResponse({ + method: 'GET', + url: '/api/books.html', + }); + container.handle(req, res); + const body = await json(); + + assert.equal(body.params.category, 'books'); + assert.equal(body.params.id, undefined); + }); + + it('should resolve params correctly for .html requests to nested dynamic routes', async () => { + const { req, res, json } = createRequestAndResponse({ + method: 'GET', + url: '/api/books/42.html', + }); + container.handle(req, res); + const body = await json(); + + assert.equal(body.params.category, 'books'); + assert.equal(body.params.id, '42'); + }); + + it('should not cross-contaminate resolved pathnames between concurrent requests', async () => { + // Fire both requests before awaiting either response. + // Before the fix, resolvedPathname was stored as shared instance state, + // so the second request could overwrite the first's pathname. + const r1 = createRequestAndResponse({ method: 'GET', url: '/api/books/1.html' }); + const r2 = createRequestAndResponse({ method: 'GET', url: '/api/movies/99' }); + + container.handle(r1.req, r1.res); + container.handle(r2.req, r2.res); + + const [body1, body2] = await Promise.all([r1.json(), r2.json()]); + + assert.equal(body1.params.category, 'books'); + assert.equal(body1.params.id, '1'); + + assert.equal(body2.params.category, 'movies'); + assert.equal(body2.params.id, '99'); + }); +}); From cf6ea6b36b67c7712395ed3f9ca19cb14ba1a013 Mon Sep 17 00:00:00 2001 From: Erika <3019731+Princesseuh@users.noreply.github.com> Date: Mon, 9 Mar 2026 13:03:10 +0100 Subject: [PATCH 3/8] fix(assets): Ensure remotePatterns globs don't match unexpected paths (#15779) --- .changeset/perky-dots-prove.md | 5 +++++ packages/astro/test/units/remote-pattern.test.js | 6 ++++++ packages/internal-helpers/src/remote.ts | 5 ++++- 3 files changed, 15 insertions(+), 1 deletion(-) create mode 100644 .changeset/perky-dots-prove.md diff --git a/.changeset/perky-dots-prove.md b/.changeset/perky-dots-prove.md new file mode 100644 index 000000000000..6892780734e1 --- /dev/null +++ b/.changeset/perky-dots-prove.md @@ -0,0 +1,5 @@ +--- +'@astrojs/internal-helpers': patch +--- + +Fixes glob matching of remote patterns matching more paths than intended in select situations diff --git a/packages/astro/test/units/remote-pattern.test.js b/packages/astro/test/units/remote-pattern.test.js index 83d5c4ebede4..7c9f7c74850a 100644 --- a/packages/astro/test/units/remote-pattern.test.js +++ b/packages/astro/test/units/remote-pattern.test.js @@ -92,6 +92,12 @@ describe('remote-pattern', () => { assert.equal(matchPathname(url2, '/*', true), false); }); + it('does not match pathname when prefix appears mid-path', async () => { + // /en/* should NOT match /evil/en/getting-started + const evilUrl = new URL('https://docs.astro.build/evil/en/getting-started'); + assert.equal(matchPathname(evilUrl, '/en/*', true), false); + }); + it('matches patterns', async () => { assert.equal(matchPattern(url1, {}), true); diff --git a/packages/internal-helpers/src/remote.ts b/packages/internal-helpers/src/remote.ts index d43088ef6809..68a59453653e 100644 --- a/packages/internal-helpers/src/remote.ts +++ b/packages/internal-helpers/src/remote.ts @@ -92,8 +92,11 @@ export function matchPathname(url: URL, pathname?: string, allowWildcard = false return slicedPathname !== url.pathname && url.pathname.startsWith(slicedPathname); } else if (pathname.endsWith('/*')) { const slicedPathname = pathname.slice(0, -1); // * length + if (!url.pathname.startsWith(slicedPathname)) { + return false; + } const additionalPathChunks = url.pathname - .replace(slicedPathname, '') + .slice(slicedPathname.length) .split('/') .filter(Boolean); return additionalPathChunks.length === 1; From 01db4f37ddc14e2148df8390e0c0c600677a2417 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Mon, 9 Mar 2026 13:37:16 +0000 Subject: [PATCH 4/8] fix(cache): make disabled cache no-op instead of throwing (#15801) * fix(cache): make disabled cache no-op instead of throwing Libraries using Astro.cache/context.cache shouldn't need try/catch to handle the case where caching isn't configured. DisabledAstroCache now silently no-ops with a one-time console warning instead of throwing. invalidate() still throws since callers expect purging to work. Adds cache.enabled property to CacheLike so libraries can check whether caching is active. * Use logger * Update .changeset/cache-disabled-no-throw.md Co-authored-by: Sarah Rainsberger <5098874+sarah11918@users.noreply.github.com> --------- Co-authored-by: Sarah Rainsberger <5098874+sarah11918@users.noreply.github.com> --- .changeset/cache-disabled-no-throw.md | 7 +++ .../astro/src/core/cache/runtime/cache.ts | 7 +++ packages/astro/src/core/cache/runtime/noop.ts | 36 ++++++++++--- packages/astro/src/core/render-context.ts | 4 +- packages/astro/test/units/cache/noop.test.js | 52 +++++++++---------- 5 files changed, 71 insertions(+), 35 deletions(-) create mode 100644 .changeset/cache-disabled-no-throw.md diff --git a/.changeset/cache-disabled-no-throw.md b/.changeset/cache-disabled-no-throw.md new file mode 100644 index 000000000000..7f683f267e7c --- /dev/null +++ b/.changeset/cache-disabled-no-throw.md @@ -0,0 +1,7 @@ +--- +'astro': patch +--- + +Improves the experience of working with experimental route caching in dev mode by replacing some errors with silent no-ops, avoiding the need to write conditional logic to handle different modes + +Adds a `cache.enabled` property to `CacheLike` so libraries can check whether caching is active without try/catch. diff --git a/packages/astro/src/core/cache/runtime/cache.ts b/packages/astro/src/core/cache/runtime/cache.ts index 0288ecf78d2c..a1326997652b 100644 --- a/packages/astro/src/core/cache/runtime/cache.ts +++ b/packages/astro/src/core/cache/runtime/cache.ts @@ -13,6 +13,11 @@ const APPLY_HEADERS = Symbol.for('astro:cache:apply'); const IS_ACTIVE = Symbol.for('astro:cache:active'); export interface CacheLike { + /** + * Whether caching is enabled. `false` when no cache provider is configured + * or in dev mode. Libraries can check this before calling cache methods. + */ + readonly enabled: boolean; /** * Set cache options for the current request. Call multiple times to merge options. * Pass `false` to explicitly opt out of caching. @@ -34,6 +39,8 @@ export class AstroCache implements CacheLike { #disabled = false; #provider: CacheProvider | null; + readonly enabled = true; + constructor(provider: CacheProvider | null) { this.#provider = provider; } diff --git a/packages/astro/src/core/cache/runtime/noop.ts b/packages/astro/src/core/cache/runtime/noop.ts index fd06d3626032..fadb1ffb27a4 100644 --- a/packages/astro/src/core/cache/runtime/noop.ts +++ b/packages/astro/src/core/cache/runtime/noop.ts @@ -2,6 +2,7 @@ import { AstroError } from '../../errors/errors.js'; import { CacheNotEnabled } from '../../errors/errors-data.js'; import type { CacheLike } from './cache.js'; import type { CacheOptions } from '../types.js'; +import type { Logger } from '../../logger/core.js'; /** * A no-op cache implementation used in dev mode when cache is configured. @@ -11,6 +12,8 @@ import type { CacheOptions } from '../types.js'; const EMPTY_OPTIONS = Object.freeze({ tags: [] }) as Readonly; export class NoopAstroCache implements CacheLike { + readonly enabled = false; + set(): void {} get tags(): string[] { @@ -24,22 +27,43 @@ export class NoopAstroCache implements CacheLike { async invalidate(): Promise {} } +let hasWarned = false; + /** - * A cache implementation that throws on any method call. - * Used when cache is not configured — provides a clear, actionable error - * instead of silently doing nothing or returning undefined. + * A no-op cache used when cache is not configured. + * Logs a warning on first use instead of throwing, so libraries + * can call cache methods without needing try/catch. + * `invalidate()` still throws since it implies the caller + * expects purging to actually work. */ export class DisabledAstroCache implements CacheLike { + readonly enabled = false; + #logger: Logger | undefined; + + constructor(logger?: Logger) { + this.#logger = logger; + } + + #warn(): void { + if (!hasWarned) { + hasWarned = true; + this.#logger?.warn( + 'cache', + '`cache.set()` was called but caching is not enabled. Configure a cache provider in your Astro config under `experimental.cache` to enable caching.', + ); + } + } + set(): void { - throw new AstroError(CacheNotEnabled); + this.#warn(); } get tags(): string[] { - throw new AstroError(CacheNotEnabled); + return []; } get options(): Readonly { - throw new AstroError(CacheNotEnabled); + return EMPTY_OPTIONS; } async invalidate(): Promise { diff --git a/packages/astro/src/core/render-context.ts b/packages/astro/src/core/render-context.ts index 491c0ec2bc46..9ea3f32e6faa 100644 --- a/packages/astro/src/core/render-context.ts +++ b/packages/astro/src/core/render-context.ts @@ -160,8 +160,8 @@ export class RenderContext { // Create cache instance let cache: CacheLike; if (!pipeline.cacheConfig) { - // Cache not configured — throws on use - cache = new DisabledAstroCache(); + // Cache not configured — no-ops with a one-time warning + cache = new DisabledAstroCache(pipeline.logger); } else if (pipeline.runtimeMode === 'development') { cache = new NoopAstroCache(); } else { diff --git a/packages/astro/test/units/cache/noop.test.js b/packages/astro/test/units/cache/noop.test.js index 73a455771e17..0156fc8ba6d5 100644 --- a/packages/astro/test/units/cache/noop.test.js +++ b/packages/astro/test/units/cache/noop.test.js @@ -2,8 +2,14 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; import { NoopAstroCache, DisabledAstroCache } from '../../../dist/core/cache/runtime/noop.js'; import { applyCacheHeaders, isCacheActive } from '../../../dist/core/cache/runtime/cache.js'; +import { defaultLogger } from '../test-utils.js'; describe('NoopAstroCache', () => { + it('enabled is false', () => { + const cache = new NoopAstroCache(); + assert.ok(!cache.enabled); + }); + it('set() is callable and does nothing', () => { const cache = new NoopAstroCache(); cache.set({ maxAge: 300, tags: ['a'] }); @@ -44,40 +50,32 @@ describe('NoopAstroCache', () => { }); describe('DisabledAstroCache', () => { - it('set() throws AstroError with CacheNotEnabled', () => { - const cache = new DisabledAstroCache(); - assert.throws( - () => cache.set({ maxAge: 300 }), - (err) => err.name === 'CacheNotEnabled', - ); + it('enabled is false', () => { + const cache = new DisabledAstroCache(defaultLogger); + assert.equal(cache.enabled, false); }); - it('set(false) throws AstroError with CacheNotEnabled', () => { - const cache = new DisabledAstroCache(); - assert.throws( - () => cache.set(false), - (err) => err.name === 'CacheNotEnabled', - ); + it('set() does not throw', () => { + const cache = new DisabledAstroCache(defaultLogger); + cache.set({ maxAge: 300 }); + cache.set(false); + // No error thrown }); - it('tags getter throws AstroError with CacheNotEnabled', () => { - const cache = new DisabledAstroCache(); - assert.throws( - () => cache.tags, - (err) => err.name === 'CacheNotEnabled', - ); + it('tags returns empty array', () => { + const cache = new DisabledAstroCache(defaultLogger); + cache.set({ tags: ['x'] }); + assert.deepEqual(cache.tags, []); }); - it('options getter throws AstroError with CacheNotEnabled', () => { - const cache = new DisabledAstroCache(); - assert.throws( - () => cache.options, - (err) => err.name === 'CacheNotEnabled', - ); + it('options returns empty object with empty tags', () => { + const cache = new DisabledAstroCache(defaultLogger); + const options = cache.options; + assert.deepEqual(options.tags, []); }); it('invalidate() throws AstroError with CacheNotEnabled', async () => { - const cache = new DisabledAstroCache(); + const cache = new DisabledAstroCache(defaultLogger); await assert.rejects( () => cache.invalidate({ tags: 'x' }), (err) => err.name === 'CacheNotEnabled', @@ -85,14 +83,14 @@ describe('DisabledAstroCache', () => { }); it('applyCacheHeaders() no-ops for disabled cache', () => { - const cache = new DisabledAstroCache(); + const cache = new DisabledAstroCache(defaultLogger); const response = new Response('test'); applyCacheHeaders(cache, response); assert.equal(response.headers.get('CDN-Cache-Control'), null); }); it('isCacheActive() returns false for disabled cache', () => { - const cache = new DisabledAstroCache(); + const cache = new DisabledAstroCache(defaultLogger); assert.equal(isCacheActive(cache), false); }); }); From 2ba0db5ef78d29c816a358f88487c1e9aa87a2d8 Mon Sep 17 00:00:00 2001 From: Emanuele Stoppa Date: Mon, 9 Mar 2026 14:26:40 +0000 Subject: [PATCH 5/8] fix(dev): inject of scripts with non-runnable pipelines (#15811) Co-authored-by: astrobot-houston --- .changeset/hip-wings-tie.md | 6 ++ .../astro/src/vite-plugin-routes/index.ts | 31 +++++- packages/astro/test/dev-route-scripts.test.js | 102 ++++++++++++++++++ 3 files changed, 137 insertions(+), 2 deletions(-) create mode 100644 .changeset/hip-wings-tie.md create mode 100644 packages/astro/test/dev-route-scripts.test.js diff --git a/.changeset/hip-wings-tie.md b/.changeset/hip-wings-tie.md new file mode 100644 index 000000000000..58ca2699dd21 --- /dev/null +++ b/.changeset/hip-wings-tie.md @@ -0,0 +1,6 @@ +--- +'astro': patch +--- + +Fixes integration-injected scripts (e.g. Alpine.js via `injectScript()`) not being loaded in the dev server when using non-runnable environment adapters like `@astrojs/cloudflare`. + diff --git a/packages/astro/src/vite-plugin-routes/index.ts b/packages/astro/src/vite-plugin-routes/index.ts index 2334b46d7358..b41f779bc749 100644 --- a/packages/astro/src/vite-plugin-routes/index.ts +++ b/packages/astro/src/vite-plugin-routes/index.ts @@ -16,6 +16,7 @@ import { createDefaultAstroMetadata } from '../vite-plugin-astro/metadata.js'; import type { PluginMetadata } from '../vite-plugin-astro/types.js'; import { ASTRO_VITE_ENVIRONMENT_NAMES } from '../core/constants.js'; import { isAstroServerEnvironment } from '../environments.js'; +import { PAGE_SCRIPT_ID } from '../vite-plugin-scripts/index.js'; type Payload = { settings: AstroSettings; @@ -30,6 +31,32 @@ const ASTRO_ROUTES_MODULE_ID_RESOLVED = '\0' + ASTRO_ROUTES_MODULE_ID; const KNOWN_FILE_EXTENSIONS = ['.astro', '.js', '.ts']; +/** + * In dev mode, populate route scripts with integration-injected scripts from settings. + * This ensures non-runnable environments (e.g. Cloudflare's workerd) can access + * scripts injected via `injectScript()` during `astro:config:setup`. + */ +export function getDevRouteScripts( + command: 'dev' | 'build', + scripts: AstroSettings['scripts'], +): SerializedRouteInfo['scripts'] { + if (command !== 'dev') return []; + const result: SerializedRouteInfo['scripts'] = []; + const hasPageScripts = scripts.some((s) => s.stage === 'page'); + if (hasPageScripts) { + result.push({ + type: 'external', + value: `/@id/${PAGE_SCRIPT_ID}`, + }); + } + for (const script of scripts) { + if (script.stage === 'head-inline') { + result.push({ stage: script.stage, children: script.content }); + } + } + return result; +} + export default async function astroPluginRoutes({ settings, logger, @@ -44,7 +71,7 @@ export default async function astroPluginRoutes({ return { file: '', links: [], - scripts: [], + scripts: getDevRouteScripts(command, settings.scripts), styles: [], routeData: serializeRouteData(r, settings.config.trailingSlash), }; @@ -77,7 +104,7 @@ export default async function astroPluginRoutes({ return { file: fileURLToPath(file), links: [], - scripts: [], + scripts: getDevRouteScripts(command, settings.scripts), styles: [], routeData: serializeRouteData(r, settings.config.trailingSlash), }; diff --git a/packages/astro/test/dev-route-scripts.test.js b/packages/astro/test/dev-route-scripts.test.js new file mode 100644 index 000000000000..12962fadf9b0 --- /dev/null +++ b/packages/astro/test/dev-route-scripts.test.js @@ -0,0 +1,102 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { getDevRouteScripts } from '../dist/vite-plugin-routes/index.js'; + +describe('getDevRouteScripts', () => { + it('returns empty array when command is build', () => { + const scripts = [{ stage: 'page', content: 'console.log("page")' }]; + const result = getDevRouteScripts('build', scripts); + assert.deepEqual(result, []); + }); + + it('returns empty array when no scripts are provided in dev mode', () => { + const result = getDevRouteScripts('dev', []); + assert.deepEqual(result, []); + }); + + it('includes external page script entry when page-stage scripts exist', () => { + const scripts = [{ stage: 'page', content: 'import "alpinejs"' }]; + const result = getDevRouteScripts('dev', scripts); + + assert.equal(result.length, 1); + assert.deepEqual(result[0], { + type: 'external', + value: '/@id/astro:scripts/page.js', + }); + }); + + it('collapses multiple page scripts into a single external entry', () => { + const scripts = [ + { stage: 'page', content: 'import "alpinejs"' }, + { stage: 'page', content: 'import "other"' }, + ]; + const result = getDevRouteScripts('dev', scripts); + + const pageEntries = result.filter((s) => 'type' in s && s.type === 'external'); + assert.equal(pageEntries.length, 1); + }); + + it('includes head-inline scripts with their content', () => { + const scripts = [{ stage: 'head-inline', content: 'console.log("inline")' }]; + const result = getDevRouteScripts('dev', scripts); + + assert.equal(result.length, 1); + assert.deepEqual(result[0], { + stage: 'head-inline', + children: 'console.log("inline")', + }); + }); + + it('includes both page and head-inline scripts together', () => { + const scripts = [ + { stage: 'page', content: 'import "alpinejs"' }, + { stage: 'head-inline', content: 'console.log("inline1")' }, + { stage: 'head-inline', content: 'console.log("inline2")' }, + ]; + const result = getDevRouteScripts('dev', scripts); + + assert.equal(result.length, 3); + assert.deepEqual(result[0], { + type: 'external', + value: '/@id/astro:scripts/page.js', + }); + assert.deepEqual(result[1], { + stage: 'head-inline', + children: 'console.log("inline1")', + }); + assert.deepEqual(result[2], { + stage: 'head-inline', + children: 'console.log("inline2")', + }); + }); + + it('ignores before-hydration and page-ssr stage scripts', () => { + const scripts = [ + { stage: 'before-hydration', content: 'console.log("hydration")' }, + { stage: 'page-ssr', content: 'console.log("ssr")' }, + ]; + const result = getDevRouteScripts('dev', scripts); + + assert.deepEqual(result, []); + }); + + it('ignores non-relevant stages while still collecting page and head-inline', () => { + const scripts = [ + { stage: 'before-hydration', content: 'console.log("hydration")' }, + { stage: 'page', content: 'import "alpinejs"' }, + { stage: 'page-ssr', content: 'console.log("ssr")' }, + { stage: 'head-inline', content: 'window.__config = {}' }, + ]; + const result = getDevRouteScripts('dev', scripts); + + assert.equal(result.length, 2); + assert.deepEqual(result[0], { + type: 'external', + value: '/@id/astro:scripts/page.js', + }); + assert.deepEqual(result[1], { + stage: 'head-inline', + children: 'window.__config = {}', + }); + }); +}); From 94b4a465f12e89b018bf120c9b163fc567aa0e84 Mon Sep 17 00:00:00 2001 From: Erika <3019731+Princesseuh@users.noreply.github.com> Date: Mon, 9 Mar 2026 15:45:10 +0100 Subject: [PATCH 6/8] fix(assets): Fixes a few cases where fit and position did not work correctly (#15809) --- .changeset/frank-buttons-glow.md | 5 ++ .changeset/funky-knives-matter.md | 5 ++ .changeset/green-eyes-taste.md | 5 ++ packages/astro/components/Image.astro | 4 +- packages/astro/components/Picture.astro | 4 +- packages/astro/src/assets/services/service.ts | 3 +- packages/astro/src/assets/services/sharp.ts | 3 - packages/astro/src/assets/types.ts | 52 ++++++++--------- .../integrations/netlify/src/image-service.ts | 15 +++++ packages/integrations/netlify/src/index.ts | 6 +- .../test/development/primitives.test.js | 27 +++++++++ .../netlify/test/functions/image-cdn.test.js | 56 +++++++++++++++++++ 12 files changed, 148 insertions(+), 37 deletions(-) create mode 100644 .changeset/frank-buttons-glow.md create mode 100644 .changeset/funky-knives-matter.md create mode 100644 .changeset/green-eyes-taste.md diff --git a/.changeset/frank-buttons-glow.md b/.changeset/frank-buttons-glow.md new file mode 100644 index 000000000000..1fc5b3f47ed5 --- /dev/null +++ b/.changeset/frank-buttons-glow.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Fixes `fit` defaults not being applied unless `layout` was also specified diff --git a/.changeset/funky-knives-matter.md b/.changeset/funky-knives-matter.md new file mode 100644 index 000000000000..b9e58a74b2d6 --- /dev/null +++ b/.changeset/funky-knives-matter.md @@ -0,0 +1,5 @@ +--- +'@astrojs/netlify': patch +--- + +Fixes the image CDN being used in development despite being disabled in certain cases diff --git a/.changeset/green-eyes-taste.md b/.changeset/green-eyes-taste.md new file mode 100644 index 000000000000..7d7b33a4f2e7 --- /dev/null +++ b/.changeset/green-eyes-taste.md @@ -0,0 +1,5 @@ +--- +'@astrojs/netlify': minor +--- + +Adds support for the `fit` option to the image service diff --git a/packages/astro/components/Image.astro b/packages/astro/components/Image.astro index 07ce17b13f00..abc8539e064a 100644 --- a/packages/astro/components/Image.astro +++ b/packages/astro/components/Image.astro @@ -27,10 +27,12 @@ if (typeof props.height === 'string') { const layout = props.layout ?? imageConfig.layout ?? 'none'; if (layout !== 'none') { - // Apply defaults from imageConfig if not provided props.layout ??= imageConfig.layout; props.fit ??= imageConfig.objectFit ?? 'cover'; props.position ??= imageConfig.objectPosition ?? 'center'; +} else if (imageConfig.objectFit || imageConfig.objectPosition) { + props.fit ??= imageConfig.objectFit; + props.position ??= imageConfig.objectPosition; } const image = await getImage(props as UnresolvedImageTransform); diff --git a/packages/astro/components/Picture.astro b/packages/astro/components/Picture.astro index c01af3d52dd3..6b3d8255cf6c 100644 --- a/packages/astro/components/Picture.astro +++ b/packages/astro/components/Picture.astro @@ -46,10 +46,12 @@ const layout = props.layout ?? imageConfig.layout ?? 'none'; const useResponsive = layout !== 'none'; if (useResponsive) { - // Apply defaults from imageConfig if not provided props.layout ??= imageConfig.layout; props.fit ??= imageConfig.objectFit ?? 'cover'; props.position ??= imageConfig.objectPosition ?? 'center'; +} else if (imageConfig.objectFit || imageConfig.objectPosition) { + props.fit ??= imageConfig.objectFit; + props.position ??= imageConfig.objectPosition; } for (const key in props) { diff --git a/packages/astro/src/assets/services/service.ts b/packages/astro/src/assets/services/service.ts index 00733dd603d0..c77017d19ba0 100644 --- a/packages/astro/src/assets/services/service.ts +++ b/packages/astro/src/assets/services/service.ts @@ -239,8 +239,7 @@ export const baseService: Omit = { } if (options.width) options.width = Math.round(options.width); if (options.height) options.height = Math.round(options.height); - if (options.layout && options.width && options.height) { - options.fit ??= 'cover'; + if (options.layout) { delete options.layout; } if (options.fit === 'none') { diff --git a/packages/astro/src/assets/services/sharp.ts b/packages/astro/src/assets/services/sharp.ts index eaa25b0073ec..f08296ba5b37 100644 --- a/packages/astro/src/assets/services/sharp.ts +++ b/packages/astro/src/assets/services/sharp.ts @@ -81,9 +81,6 @@ const sharpService: LocalImageService = { // get some information about the input const { format } = await result.metadata(); - // If `fit` isn't set then use old behavior: - // - Do not use both width and height for resizing, and prioritize width over height - // - Allow enlarging images if (transform.width && transform.height) { const fit: keyof FitEnum | undefined = transform.fit diff --git a/packages/astro/src/assets/types.ts b/packages/astro/src/assets/types.ts index c96befc9e91f..a3af913b542e 100644 --- a/packages/astro/src/assets/types.ts +++ b/packages/astro/src/assets/types.ts @@ -167,6 +167,30 @@ type ImageSharedProps = T & { * ``` */ priority?: boolean; + + /** + * Defines how the image should be cropped if the aspect ratio is changed. + * + * Default is `cover`. Allowed values are `fill`, `contain`, `cover`, `none` or `scale-down`. These behave like the equivalent CSS `object-fit` values. Other values may be passed if supported by the image service. + * + * **Example**: + * ```astro + * ... + * ``` + */ + fit?: ImageFit; + + /** + * Defines the position of the image when cropping. + * + * The value is a string that specifies the position of the image, which matches the CSS `object-position` property. Other values may be passed if supported by the image service. + * + * **Example**: + * ```astro + * ... + * ``` + */ + position?: string; } & ( | { /** @@ -186,32 +210,6 @@ type ImageSharedProps = T & { layout?: ImageLayout; - /** - * Defines how the image should be cropped if the aspect ratio is changed. Requires `layout` to be set. - * - * Default is `cover`. Allowed values are `fill`, `contain`, `cover`, `none` or `scale-down`. These behave like the equivalent CSS `object-fit` values. Other values may be passed if supported by the image service. - * - * **Example**: - * ```astro - * ... - * ``` - */ - - fit?: ImageFit; - - /** - * Defines the position of the image when cropping. Requires `layout` to be set. - * - * The value is a string that specifies the position of the image, which matches the CSS `object-position` property. Other values may be passed if supported by the image service. - * - * **Example**: - * ```astro - * ... - * ``` - */ - - position?: string; - /** * A list of widths to generate images for. The value of this property will be used to assign the `srcset` property on the final `img` element. * @@ -229,8 +227,6 @@ type ImageSharedProps = T & { densities?: (number | `${number}x`)[]; widths?: never; layout?: never; - fit?: never; - position?: never; } ) & Astro.CustomImageProps; diff --git a/packages/integrations/netlify/src/image-service.ts b/packages/integrations/netlify/src/image-service.ts index c030d9571ca5..91c3596ad25c 100644 --- a/packages/integrations/netlify/src/image-service.ts +++ b/packages/integrations/netlify/src/image-service.ts @@ -6,6 +6,17 @@ import { AstroError } from 'astro/errors'; const SUPPORTED_FORMATS = ['avif', 'jpg', 'png', 'webp']; const QUALITY_NAMES: Record = { low: 25, mid: 50, high: 90, max: 100 }; +// Netlify only supports contain (default), cover, and fill +// Astro values not directly supported are mapped to their nearest equivalent +const FIT_MAP: Record = { + contain: 'contain', + cover: 'cover', + fill: 'fill', + inside: 'contain', + outside: 'cover', + 'scale-down': 'contain', +}; + function removeLeadingForwardSlash(path: string) { return path.startsWith('/') ? path.substring(1) : path; } @@ -30,6 +41,10 @@ const service: ExternalImageService = { if (options.width) query.set('w', `${options.width}`); if (options.height) query.set('h', `${options.height}`); if (options.quality) query.set('q', `${options.quality}`); + if (options.fit) { + const netlifyFit = FIT_MAP[options.fit]; + if (netlifyFit) query.set('fit', netlifyFit); + } return `/.netlify/images?${query}`; }, diff --git a/packages/integrations/netlify/src/index.ts b/packages/integrations/netlify/src/index.ts index 947adf62c2a8..019feaa36218 100644 --- a/packages/integrations/netlify/src/index.ts +++ b/packages/integrations/netlify/src/index.ts @@ -678,8 +678,10 @@ export default function netlifyIntegration( // defaults to true, so should only be disabled if the user has // explicitly set false entrypoint: - (command === 'build' && integrationConfig?.imageCDN === false) || - (command === 'dev' && vitePluginOptions?.images?.enabled === false) + integrationConfig?.imageCDN === false || + + // In dev, if the vite plugin's image proxy isn't enabled, don't try to use the Netlify service since it won't work + (command === 'dev' && vitePluginOptions?.images?.enabled === false) ? undefined : '@astrojs/netlify/image-service.js', }, diff --git a/packages/integrations/netlify/test/development/primitives.test.js b/packages/integrations/netlify/test/development/primitives.test.js index 96f9de5dbf86..016305e42cae 100644 --- a/packages/integrations/netlify/test/development/primitives.test.js +++ b/packages/integrations/netlify/test/development/primitives.test.js @@ -77,5 +77,32 @@ describe('Netlify primitives', () => { assert.equal(imageResponse.headers.get('content-type'), 'image/jpeg'); } }); + + it('respects imageCDN: false in development', async () => { + process.env.DISABLE_IMAGE_CDN = 'true'; + const cdnDisabledFixture = await loadFixture({ + root: new URL('./fixtures/primitives/', import.meta.url), + output: 'server', + adapter: netlifyAdapter({ imageCDN: false }), + }); + const cdnDisabledServer = await cdnDisabledFixture.startDevServer(); + try { + const imgResponse = await cdnDisabledFixture.fetch('/astronaut'); + const $img = cheerio.load(await imgResponse.text()); + const images = $img('img').map((_i, el) => { + return $img(el).attr('src'); + }); + + for (const imgSrc of images) { + assert( + !imgSrc.startsWith('/.netlify/images'), + `Expected image src to not use Netlify CDN, got: ${imgSrc}`, + ); + } + } finally { + await cdnDisabledServer.stop(); + process.env.DISABLE_IMAGE_CDN = undefined; + } + }); }); }); diff --git a/packages/integrations/netlify/test/functions/image-cdn.test.js b/packages/integrations/netlify/test/functions/image-cdn.test.js index a9a4186a9707..8d6196817607 100644 --- a/packages/integrations/netlify/test/functions/image-cdn.test.js +++ b/packages/integrations/netlify/test/functions/image-cdn.test.js @@ -2,6 +2,7 @@ import * as assert from 'node:assert/strict'; import { after, before, describe, it } from 'node:test'; import { remotePatternToRegex } from '@astrojs/netlify'; import { loadFixture } from '../../../../astro/test/test-utils.js'; +import imageService from '../../dist/image-service.js'; describe( 'Image CDN', @@ -119,6 +120,61 @@ describe( ); }); }); + + describe('fit parameter', () => { + it('includes fit parameter in image URL', () => { + const url = imageService.getURL({ + src: 'images/astronaut.jpg', + width: 300, + height: 400, + fit: 'cover', + format: 'webp', + }); + assert.ok(url.includes('fit=cover'), `Expected fit=cover in URL, got: ${url}`); + }); + + it('maps Astro fit values to Netlify equivalents', () => { + const cases = [ + ['contain', 'contain'], + ['cover', 'cover'], + ['fill', 'fill'], + ['inside', 'contain'], + ['outside', 'cover'], + ['scale-down', 'contain'], + ]; + for (const [astroFit, netlifyFit] of cases) { + const url = imageService.getURL({ + src: 'img.jpg', + width: 100, + height: 100, + fit: astroFit, + }); + assert.ok( + url.includes(`fit=${netlifyFit}`), + `Expected fit=${netlifyFit} for astro fit="${astroFit}", got: ${url}`, + ); + } + }); + + it('omits fit parameter when fit is none or unset', () => { + const withNone = imageService.getURL({ + src: 'img.jpg', + width: 100, + height: 100, + fit: 'none', + }); + assert.ok( + !withNone.includes('fit='), + `Expected no fit param for fit="none", got: ${withNone}`, + ); + + const withoutFit = imageService.getURL({ src: 'img.jpg', width: 100, height: 100 }); + assert.ok( + !withoutFit.includes('fit='), + `Expected no fit param when unset, got: ${withoutFit}`, + ); + }); + }); }, { timeout: 120000, From fa19c88a84ac9fd4aadb50fb3c0ac8dab027c23f Mon Sep 17 00:00:00 2001 From: Erika Date: Mon, 9 Mar 2026 14:46:11 +0000 Subject: [PATCH 7/8] [ci] format --- packages/astro/src/assets/services/sharp.ts | 1 - packages/integrations/netlify/src/index.ts | 3 +-- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/astro/src/assets/services/sharp.ts b/packages/astro/src/assets/services/sharp.ts index f08296ba5b37..66d64032725d 100644 --- a/packages/astro/src/assets/services/sharp.ts +++ b/packages/astro/src/assets/services/sharp.ts @@ -81,7 +81,6 @@ const sharpService: LocalImageService = { // get some information about the input const { format } = await result.metadata(); - if (transform.width && transform.height) { const fit: keyof FitEnum | undefined = transform.fit ? (fitMap[transform.fit] ?? 'inside') diff --git a/packages/integrations/netlify/src/index.ts b/packages/integrations/netlify/src/index.ts index 019feaa36218..d1397f6f817e 100644 --- a/packages/integrations/netlify/src/index.ts +++ b/packages/integrations/netlify/src/index.ts @@ -679,9 +679,8 @@ export default function netlifyIntegration( // explicitly set false entrypoint: integrationConfig?.imageCDN === false || - // In dev, if the vite plugin's image proxy isn't enabled, don't try to use the Netlify service since it won't work - (command === 'dev' && vitePluginOptions?.images?.enabled === false) + (command === 'dev' && vitePluginOptions?.images?.enabled === false) ? undefined : '@astrojs/netlify/image-service.js', }, From dc175aa560f1ea64988194e698ed7815c6fd763b Mon Sep 17 00:00:00 2001 From: Matthew Phillips Date: Mon, 9 Mar 2026 10:52:29 -0400 Subject: [PATCH 8/8] Exit prerelease mode for Astro 6 release (#15810) --- .changeset/pre.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/pre.json b/.changeset/pre.json index f4059b9cd1fe..9ab6d764ae08 100644 --- a/.changeset/pre.json +++ b/.changeset/pre.json @@ -1,5 +1,5 @@ { - "mode": "pre", + "mode": "exit", "tag": "beta", "initialVersions": { "astro": "5.13.7",