Skip to content

fix: auto-inject experimental.cache provider so emdash routes do not crash#963

Closed
royachiron wants to merge 2 commits into
emdash-cms:mainfrom
royachiron:fix/cache-undefined-safety
Closed

fix: auto-inject experimental.cache provider so emdash routes do not crash#963
royachiron wants to merge 2 commits into
emdash-cms:mainfrom
royachiron:fix/cache-undefined-safety

Conversation

@royachiron
Copy link
Copy Markdown

@royachiron royachiron commented May 8, 2026

What does this PR do?

Auto-injects a default experimental.cache.provider (Astro's built-in memoryCache()) inside the emdash integration so that the route handlers and templates that emdash itself ships do not crash on hosts that haven't opted into Astro 6's experimental response cache.

emdash code assumes the cache exists in two places:

  • API routes (publish, unpublish, schedule, restore, discard-draft, duplicate, permanent, [collection]/[id], [collection]/index) call cache.invalidate({ tags: [...] }) after writes, guarded only by cache.enabled.
  • Templates and the canonical "Querying & Rendering" docs tell users to call Astro.cache.set(cacheHint) on every content page.

Both depend on the route context having a cache object. In Astro 6, experimental.cache is opt-in and undefined by default. Hosts that don't enable it hit:

TypeError: Cannot read properties of undefined (reading 'enabled')   // publish/unpublish/schedule
TypeError: Cannot read properties of undefined (reading 'set')       // Astro.cache.set on the homepage

This is the silent failure paths that bit users in #959, #945, and the publish-flow segment of #962.

The fix is one focused change in packages/core/src/astro/integration/index.ts: extend the existing updateConfig({...}) call so emdash inserts experimental.cache.provider: memoryCache() whenever the host config doesn't already have a provider. A host that has configured one keeps theirs. Two new unit tests cover both branches.

Closes #959
Closes #945
Addresses point 1 of #962

Type of change

  • Bug fix
  • Feature (requires maintainer-approved Discussion)
  • Refactor (no behavior change)
  • Translation
  • Documentation
  • Performance improvement
  • Tests
  • Chore (dependencies, CI, tooling)

Checklist

  • I have read CONTRIBUTING.md
  • pnpm typecheck passes (no new errors in touched files; pre-existing 8445 errors on main are unaffected)
  • pnpm lint passes (no new lint errors in touched files; pre-existing 30 errors on main are unaffected)
  • pnpm test passes (the 2 new tests in cache-config.test.ts pass; the 9 pre-existing test failures on main are unrelated and unchanged)
  • pnpm format has been run
  • I have added/updated tests for my changes (if applicable)
  • User-visible strings in the admin UI are wrapped for translation — N/A, no UI strings touched.
  • I have added a changeset (emdash patch)
  • New features link to an approved Discussion — N/A, this is a bug fix.

AI-generated code disclosure

  • This PR includes AI-generated code — model/tool: Claude Opus 4.7

Screenshots / test output

$ pnpm --filter emdash test -- tests/unit/astro/integration/cache-config.test.ts
 Test Files  67 failed | 137 passed (204)
      Tests  9 failed | 2148 passed | 20 skipped (2177)

(67 file failures and 9 test failures are pre-existing on main; not introduced by this PR.
 Test counts: 136 passed / 2146 passed before this PR; 137 / 2148 after — i.e. +1 file,
 +2 tests, all passing.)

The two new tests in cache-config.test.ts:

  • auto-injects a default cache provider when the host has not opted in — asserts that calling the emdash integration's astro:config:setup hook with an empty host config produces an updateConfig call containing experimental.cache.provider.
  • preserves the host's existing cache provider when one is configured — asserts that a host-supplied provider is passed through unchanged.

…rash

Multiple call sites in emdash assume Astro's experimental response cache is
configured:

- API routes (publish, unpublish, schedule, restore, discard-draft, duplicate,
  permanent, [collection]/[id], [collection]/index) call cache.invalidate({...})
  with a guard on cache.enabled.
- Templates and the docs' "Always call Astro.cache.set(cacheHint)" pattern
  rely on Astro.cache.set(...) being a callable method.

experimental.cache is opt-in in Astro 6 and undefined by default. Hosts that
don't enable it (graft path, current starter templates, the Deploy-to-Cloudflare
button output) hit a runtime TypeError on the first publish or homepage render:

    TypeError: Cannot read properties of undefined (reading 'enabled')
    TypeError: Cannot read properties of undefined (reading 'set')

This patch makes the emdash integration auto-inject memoryCache() as the default
provider. Hosts that already configured a provider keep theirs.

Closes emdash-cms#959, emdash-cms#945. Addresses point 1 of emdash-cms#962.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 8, 2026

🦋 Changeset detected

Latest commit: 6d67fae

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 13 packages
Name Type
emdash Patch
@emdash-cms/cloudflare Patch
@emdash-cms/fixture-perf-site Patch
@emdash-cms/perf-demo-site Patch
@emdash-cms/cache-demo-site Patch
@emdash-cms/admin Patch
@emdash-cms/auth Patch
@emdash-cms/blocks Patch
@emdash-cms/gutenberg-to-portable-text Patch
@emdash-cms/x402 Patch
create-emdash Patch
@emdash-cms/auth-atproto Patch
@emdash-cms/plugin-embeds Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 8, 2026

All contributors have signed the CLA ✍️ ✅
Posted by the CLA Assistant Lite bot.

@royachiron
Copy link
Copy Markdown
Author

I have read the CLA Document and I hereby sign the CLA

github-actions Bot added a commit that referenced this pull request May 8, 2026
@royachiron
Copy link
Copy Markdown
Author

royachiron commented May 8, 2026

Putting it out there as clearly as possible - I vibe code simple things. My site, it helps me with QA work, helps with w/e. It's useful. I decided to add a cool blog to my already nifty site. My friend, the code master, tells me of emdash. I like the idea - I don't really dig WP bulkiness. So I ask Claude to add it to my Astro 4 website. It goes through some process, and I end up with a 404, and can't publish anything. He managed to solve it - but I figure - it's a very capable tool - and this issue might have project importance. Documentation? I even asked - But I figure it would be quicker and easier to just act - report the issues - and since I figure it out - I politely asked the AI if it might be able to actually submit a PR.

As far as I know - his fix worked on my production environment. Is it the most proper fix? IDK. Is it worth as a PR? No clue, most of it is out of the scope of my capabilities. But I like this project - and I always dreamed of helping, really helping, some OSS. Hope this is a proper PR, and that my contribution is valuable - and not just more slop.

@ascorbic
Copy link
Copy Markdown
Collaborator

/review

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 10, 2026

Open in StackBlitz

@emdash-cms/admin

npm i https://pkg.pr.new/@emdash-cms/admin@963

@emdash-cms/auth

npm i https://pkg.pr.new/@emdash-cms/auth@963

@emdash-cms/blocks

npm i https://pkg.pr.new/@emdash-cms/blocks@963

@emdash-cms/cloudflare

npm i https://pkg.pr.new/@emdash-cms/cloudflare@963

emdash

npm i https://pkg.pr.new/emdash@963

create-emdash

npm i https://pkg.pr.new/create-emdash@963

@emdash-cms/gutenberg-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/gutenberg-to-portable-text@963

@emdash-cms/x402

npm i https://pkg.pr.new/@emdash-cms/x402@963

@emdash-cms/plugin-ai-moderation

npm i https://pkg.pr.new/@emdash-cms/plugin-ai-moderation@963

@emdash-cms/plugin-atproto

npm i https://pkg.pr.new/@emdash-cms/plugin-atproto@963

@emdash-cms/plugin-audit-log

npm i https://pkg.pr.new/@emdash-cms/plugin-audit-log@963

@emdash-cms/plugin-color

npm i https://pkg.pr.new/@emdash-cms/plugin-color@963

@emdash-cms/plugin-embeds

npm i https://pkg.pr.new/@emdash-cms/plugin-embeds@963

@emdash-cms/plugin-forms

npm i https://pkg.pr.new/@emdash-cms/plugin-forms@963

@emdash-cms/plugin-webhook-notifier

npm i https://pkg.pr.new/@emdash-cms/plugin-webhook-notifier@963

commit: 6d67fae

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice, focused fix. The diagnosis is right: every API route does if (cache.enabled) await cache.invalidate(...), and cache is undefined on hosts that haven't opted into experimental.cache.provider, so even the .enabled access throws. Auto-injecting memoryCache() as a default fixes it without breaking hosts that already configured a provider — Astro's updateConfig uses a recursive mergeConfig, so spreading { experimental: { cache: { provider } } } here deep-merges and preserves any other experimental.* flags the host set (verified in astro/dist/core/config/merge.js).

A few small things below — none are blocking. The first test could be a little stricter, and it's worth a thought about the few edge cases where a host might have explicitly opted out.

)?.cache?.provider;
const cacheConfig = {
cache: {
provider: existingCacheProvider ?? memoryCache(),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Edge case worth thinking about: the ?? falls back to memoryCache() for any nullish provider, but it also silently overrides cases where a host has set experimental.cache.provider to something falsy on purpose (e.g. they read a stale tutorial that suggested provider: false to disable, or they spread an env-driven config that left provider undefined while keeping other cache.* options).

Not a real issue today — Astro's typing rejects boolean providers — but if you wanted to be slightly more conservative, you could check "provider" in (cache ?? {}) instead of relying on ??. Up to you.

// 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<string, unknown>),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The cast comment says "experimental typing varies across Astro minor versions". One concrete thing to note for future maintainers: this works because updateConfig calls Astro's mergeConfig (in astro/dist/core/config/merge.js), which recurses on nested objects. So pushing { experimental: { cache: { provider } } } here deep-merges with any host-set experimental.session, experimental.routeRules, etc. rather than clobbering them. Worth one sentence in the comment so a future contributor doesn't "simplify" this to a shallow assignment.

const experimental = (cacheUpdate as { experimental: { cache: { provider: unknown } } })
.experimental;
expect(experimental.cache.provider).toBeDefined();
expect(experimental.cache.provider).not.toBeNull();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This assertion is a bit weak — provider will be defined and non-null in any branch (the host case puts the host's provider there, the no-op case puts memoryCache() there). To actually verify the auto-inject branch, check the shape of the returned descriptor:

Suggested change
expect(experimental.cache.provider).not.toBeNull();
expect(experimental.cache.provider).toMatchObject({
name: "memory",
entrypoint: "astro/cache/memory",
});

This pins it to the actual memoryCache() descriptor ({ name: "memory", entrypoint: "astro/cache/memory", config: {} }) and would catch a regression where someone accidentally swaps in a different default.

const experimental = (cacheUpdate as { experimental: { cache: { provider: unknown } } })
.experimental;
expect(experimental.cache.provider).toBe(hostProvider);
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider one more test for the case the PR comment specifically mentions: a host that has set other experimental.* flags but not experimental.cache. The current code relies on Astro's mergeConfig doing a deep merge on the experimental key — a future refactor that turned the spread into something shallower would silently drop the host's experimental.session/experimental.routeRules/etc. without breaking these tests.

A test like:

it("does not clobber other experimental flags the host set", () => {
	const hostExperimental = { routeRules: { "/": { maxAge: 60 } } };
	const updates = runConfigSetup({ experimental: hostExperimental });
	const cacheUpdate = updates.find((u) => "experimental" in u);
	const experimental = (cacheUpdate as { experimental: Record<string, unknown> }).experimental;
	// We only assert what this PR sends; merging is Astro's job.
	expect(experimental).toHaveProperty("cache.provider");
});

would lock the contract on the emdash side.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 10, 2026

Review posted successfully. The four inline comments cover:

  1. index.ts:290 — Edge case discussion about the ?? fallback potentially overriding intentionally-falsy provider values.
  2. index.ts:302 — Suggestion to document the deep-merge dependency in the comment so a future contributor doesn't break it by "simplifying" the spread.
  3. cache-config.test.ts:68 — Tighten the weak toBeDefined() assertion to actually check the memoryCache() descriptor shape ({ name: "memory", entrypoint: "astro/cache/memory" }).
  4. cache-config.test.ts:78 — Suggested an additional test to lock in the contract that other experimental.* flags survive the injection.

Overall verdict: solid focused bug fix, good comments, reasonable tests — non-blocking suggestions only.

github run

@ascorbic
Copy link
Copy Markdown
Collaborator

Hey @royachiron – first off, thank you for this. Showing up with a working repro, a changeset, tests, and a complete PR template on your first PR is more than a lot of seasoned contributors manage, and the fact that you described your process honestly in the comment above is exactly the right way to do open source. Don't read what follows as "this is slop" — it isn't.

I am going to close this one, but I want to walk through the reasoning carefully because the bug you spotted is real, and the next step is small.

The crash stack traces in #945 / #959 (Cannot read properties of undefined (reading 'set')) are actually because of a bug introduced in Astro 6.3. It's supposed to just warn if you use the cache without defining a provider. I have a PR to fix it, so hopefully that should solve that soon: withastro/astro#16675

The workaround for now is a single character: change cache.set to cache?.set

The reasob auto-injecting memoryCache() is risky as a default us because memoryCache() lives in the running process's memory. That's fine for a single Node process. On Cloudflare Workers (which is the most common deploy target for EmDash, it's actively misleading: every isolate gets its own copy, cold starts wipe it, and cache.invalidate() from a publish handler only flushes the one isolate that handled the request. Users would see "cache works locally, sort of works in prod, but invalidation is broken" – which is a worse failure mode than the warning, because the warning is the signal telling them to go pick a real provider.

There's a Cloudflare-aware provider in @emdash-cms/cloudflare (cloudflareCache()) but it currently uses globalThis.caches rather than the proper CDN cache so suffers from similar issues of invalidation being unreliable which is why it's not enabled by default. Astro has a CDN-cache feature landing soon that will be the right answer. So we're deliberately holding off on a default until that lands.

The cache?.set workaround fixes the crash, but going forward, the pattern the API routes already use — if (cache.enabled) await cache.invalidate(...) — is exactly what cache.enabled is for: "is a real cache configured?".

In the meantime, thanks for flagging this. I will land a fix for the immediate bug, and you can update your own site in the same way.

@ascorbic ascorbic closed this May 10, 2026
@royachiron
Copy link
Copy Markdown
Author

Thank you very much for the honest and detailed review. You could've just wrote something short that details how it's not perfect - instead you delivered in depth analysis of the root cause, so I can understand it even better and learn from the mistakes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

HTTP ERROR 500 on front end Astro.cache.set is undefined

2 participants