diff --git a/.changeset/astro-cache-disabled-shim.md b/.changeset/astro-cache-disabled-shim.md new file mode 100644 index 000000000000..7cdab25004b5 --- /dev/null +++ b/.changeset/astro-cache-disabled-shim.md @@ -0,0 +1,7 @@ +--- +'astro': patch +--- + +Fixes a regression where `Astro.cache` was `undefined` when `experimental.cache` was not configured. + +The previous documented behavior is for `Astro.cache` to always be defined as a no-op shim: `cache.set()` warns once, `cache.invalidate()` throws and `cache.enabled` can be used to gate. This allows library and user code can call cache methods without conditional checks. The cache provider registration was being gated at the call site on `experimental.cache` being configured, which meant the disabled shim branch inside the provider was unreachable and the `Astro.cache` getter was never attached to the context. diff --git a/packages/astro/src/core/routing/handler.ts b/packages/astro/src/core/routing/handler.ts index cd0acaeec6a6..dd8a5a3e2e41 100644 --- a/packages/astro/src/core/routing/handler.ts +++ b/packages/astro/src/core/routing/handler.ts @@ -105,14 +105,13 @@ export class AstroHandler { let response; try { - // Only call provider functions when the feature is configured. - // Each call does property lookups + state.provide() which - // allocates a Map on the hot path when nothing is configured. - if (this.#hasSession || this.#app.pipeline.cacheConfig) { - const sessionP = this.#hasSession ? provideSession(state) : undefined; - const cacheP = this.#app.pipeline.cacheConfig ? provideCache(state) : undefined; - if (sessionP || cacheP) await Promise.all([sessionP, cacheP]); - } + // `provideCache` always runs so `Astro.cache` is defined even + // when caching is disabled — it registers a no-op shim that + // warns once on use. `provideSession` is gated because there + // is no equivalent disabled-shim contract for sessions. + const sessionP = this.#hasSession ? provideSession(state) : undefined; + const cacheP = provideCache(state); + if (sessionP || cacheP) await Promise.all([sessionP, cacheP]); // Track feature usage even when skipped. state.pipeline.usedFeatures |= PipelineFeatures.sessions; diff --git a/packages/astro/test/cache-route.test.ts b/packages/astro/test/cache-route.test.ts index 34006c5438c0..93d60b87e7a5 100644 --- a/packages/astro/test/cache-route.test.ts +++ b/packages/astro/test/cache-route.test.ts @@ -155,4 +155,44 @@ describe('context.cache', () => { assert.deepEqual(body, { ok: true }); }); }); + + describe('Disabled (no cache provider configured)', () => { + let fixture: Fixture; + let app: App; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/cache-route/', + output: 'server', + adapter: testAdapter(), + outDir: './dist/cache-route-disabled/', + }); + await fixture.build({}); + app = await fixture.loadTestAdapterApp(); + }); + + async function fetchResponse(path: string) { + const request = new Request('http://example.com' + path); + return app.render(request); + } + + // Regression: Astro.cache must always be defined as a no-op shim + // even when experimental.cache is not configured, so that + // `Astro.cache.set(...)` calls do not crash. + it('Astro.cache.set() is a no-op on .astro pages', async () => { + const response = await fetchResponse('/'); + assert.equal(response.status, 200); + assert.equal(response.headers.get('CDN-Cache-Control'), null); + assert.equal(response.headers.get('Cache-Tag'), null); + }); + + it('context.cache.set() is a no-op in API routes', async () => { + const response = await fetchResponse('/api'); + assert.equal(response.status, 200); + assert.equal(response.headers.get('CDN-Cache-Control'), null); + assert.equal(response.headers.get('Cache-Tag'), null); + const body = await response.json(); + assert.deepEqual(body, { ok: true }); + }); + }); });