Editorial cover graphic with a blueprint grid motif in gold on cream.

Theme Flip nearly leaked a preview theme onto the anonymous page cache

A session-scoped theme switcher, the cache context it was missing, and how close that came to shipping.

What I was building

Theme Flip does one thing: pick a theme from a dropdown, and your session sees it instead of the site’s active theme. Nobody else’s does. Handy for showing a client three candidate themes on the same URL without three environments, or letting a reviewer preview an unreleased theme on production content without touching the site’s configuration for anyone else.

The negotiator side is almost embarrassingly small:

src/Theme/FlipThemeNegotiator.php
public function determineActiveTheme(RouteMatchInterface $route_match) {
return $this->session->get('theme_flip.active');
}

Set a session value, return it from a theme negotiator, done. I had it working locally in about twenty minutes and thought that was suspicious, because it was.

What broke

Testing on a real environment with internal_page_cache on (which is most Drupal sites, because it should be), I flipped my session to the candidate theme, loaded the homepage, and then opened the same URL in a private window with no session at all. It rendered in the candidate theme too. For a visitor who had never touched the dropdown.

Drupal’s anonymous page cache stores a rendered response keyed by the request, plus whatever cache contexts the response declares it varies by. My negotiator was deciding the theme from session data, but nothing in the render pipeline knew that decision depended on the session. As far as the cache was concerned, the response for that URL was the response for that URL, full stop, and the first anonymous hit after my preview got served my preview back to them.

The fix

A session read buried inside a negotiator wasn’t enough. theme_flip.active needed to be a real cache context:

theme_flip.services.yml
services:
cache_context.theme_flip:
class: Drupal\theme_flip\Cache\ThemeFlipCacheContext
tags:
- { name: cache.context }
src/Cache/ThemeFlipCacheContext.php
public function getContext() {
return $this->session->get('theme_flip.active') ?? 'default';
}

Registering it as a cache.context service and declaring theme_flip in the page render array’s #cache: contexts means Drupal now varies the cached response by whatever theme the session has active. My preview gets its own cache entry, the default theme gets its own, and nobody sees a page they didn’t ask to see.

What I have not fully closed out

The cache context stops the page cache from leaking, but I haven’t yet audited every block plugin that ships with common themes for ones that cache their own render output independently of the page. A block with #cache: max-age: -1 set wrong, or a context list that doesn’t include theme_flip either, would reopen the same hole one block at a time instead of one page at a time. That’s a slower, quieter version of the same bug, and “slower and quieter” is exactly the kind you don’t catch by opening a private window and eyeballing the homepage. It’s on the list before I’d call this one done.

What this came from