diff --git a/.changeset/auto-inject-experimental-cache.md b/.changeset/auto-inject-experimental-cache.md new file mode 100644 index 0000000000..244a1ade17 --- /dev/null +++ b/.changeset/auto-inject-experimental-cache.md @@ -0,0 +1,5 @@ +--- +"emdash": patch +--- + +Fixes a runtime crash where API routes (publish, unpublish, schedule) and templates (`Astro.cache.set`) hit `Cannot read properties of undefined` when the host project hadn't opted into Astro's `experimental.cache`. The integration now auto-injects `memoryCache()` as the default provider; hosts that already configured a provider keep theirs. diff --git a/packages/core/src/astro/integration/index.ts b/packages/core/src/astro/integration/index.ts index 98a69f5d10..c43a36e8b5 100644 --- a/packages/core/src/astro/integration/index.ts +++ b/packages/core/src/astro/integration/index.ts @@ -11,6 +11,7 @@ */ import type { AstroIntegration, AstroIntegrationLogger } from "astro"; +import { memoryCache } from "astro/config"; import { validateAllowedOrigins, validateOriginShape } from "../../auth/allowed-origins.js"; import type { ResolvedPlugin } from "../../plugins/types.js"; @@ -271,11 +272,34 @@ export function emdash(config: EmDashConfig = {}): AstroIntegration { }, ]; + // EmDash relies on Astro's experimental response cache: + // - API routes (publish, unpublish, schedule, etc.) call `cache.invalidate()` + // to bust tagged entries when content changes. + // - Templates and user pages call `Astro.cache.set(cacheHint)` so list/detail + // routes are cacheable per-tag. + // When the host project does not opt into `experimental.cache`, both call sites + // hit `undefined` at runtime and crash with `Cannot read properties of undefined`. + // Auto-inject `memoryCache()` as the default provider so the contract holds for + // every host (graft path, starter templates, Deploy-to-Cloudflare button, etc.). + // Hosts that already configured a provider keep theirs. + const existingCacheProvider = ( + astroConfig.experimental as { cache?: { provider?: unknown } } | undefined + )?.cache?.provider; + const cacheConfig = { + cache: { + provider: existingCacheProvider ?? memoryCache(), + }, + }; + updateConfig({ security: securityConfig, // fonts is a valid AstroConfig key but may not be in the // type definition for the minimum supported Astro version ...({ fonts: emdashFonts } as Record), + // experimental.cache is opt-in in Astro 6 and undefined by default; + // cast through unknown because the experimental typing varies across + // Astro minor versions. + ...({ experimental: cacheConfig } as Record), vite: createViteConfig( { serializableConfig, diff --git a/packages/core/tests/unit/astro/integration/cache-config.test.ts b/packages/core/tests/unit/astro/integration/cache-config.test.ts new file mode 100644 index 0000000000..5c74f453af --- /dev/null +++ b/packages/core/tests/unit/astro/integration/cache-config.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it, vi } from "vitest"; + +// `createViteConfig` resolves `@emdash-cms/admin/dist` via require.resolve, which +// fails in a fresh checkout where the admin package hasn't been built yet. Stub +// it: this test only cares about the experimental.cache injection in +// `updateConfig({...})`, not the vite config payload. +vi.mock("../../../../src/astro/integration/vite-config.js", () => ({ + createViteConfig: () => ({}), +})); + +const { emdash } = await import("../../../../src/astro/integration/index.js"); + +/** + * Regression tests for the experimental.cache provider auto-injection. + * + * Without this, hosts that haven't opted into `experimental.cache` get an + * undefined `cache` parameter in the API route context, and any call to + * `cache.enabled` / `cache.invalidate()` (publish, unpublish, schedule, etc.) + * or `Astro.cache.set(cacheHint)` (templates) crashes with + * `TypeError: Cannot read properties of undefined`. + * + * Tracked in: + * - https://github.com/emdash-cms/emdash/issues/962 (DX umbrella) + * - https://github.com/emdash-cms/emdash/issues/959 (Deploy-to-Cloudflare 500) + * - https://github.com/emdash-cms/emdash/issues/945 (Astro.cache.set undefined) + */ +describe("emdash integration: experimental.cache injection", () => { + function runConfigSetup(hostConfig: Record) { + const integration = emdash({}); + const setup = integration.hooks["astro:config:setup"]; + if (!setup) throw new Error("astro:config:setup hook missing"); + + const updates: Array> = []; + void setup({ + injectRoute: vi.fn(), + injectScript: vi.fn(), + addMiddleware: vi.fn(), + addClientDirective: vi.fn(), + addDevToolbarApp: vi.fn(), + addRenderer: vi.fn(), + addWatchFile: vi.fn(), + updateConfig: (cfg: Record) => { + updates.push(cfg); + return cfg; + }, + isRestart: false, + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + fork: vi.fn(), + } as never, + config: hostConfig as never, + command: "build", + } as never); + + return updates; + } + + it("auto-injects a default cache provider when the host has not opted in", () => { + const updates = runConfigSetup({}); + const cacheUpdate = updates.find((u) => "experimental" in u); + expect(cacheUpdate, "expected an updateConfig call with experimental.cache").toBeDefined(); + const experimental = (cacheUpdate as { experimental: { cache: { provider: unknown } } }) + .experimental; + expect(experimental.cache.provider).toBeDefined(); + expect(experimental.cache.provider).not.toBeNull(); + }); + + it("preserves the host's existing cache provider when one is configured", () => { + const hostProvider = { kind: "host-supplied" }; + const updates = runConfigSetup({ experimental: { cache: { provider: hostProvider } } }); + const cacheUpdate = updates.find((u) => "experimental" in u); + const experimental = (cacheUpdate as { experimental: { cache: { provider: unknown } } }) + .experimental; + expect(experimental.cache.provider).toBe(hostProvider); + }); +});