Problem/Motivation
Drupal's internal (anonymous) page cache keys responses by URL only (PageCache::getCacheId()): it ignores request headers, the response Vary header, and cache contexts, and it serves hits from middleware before routing runs. Header support is a known core limitation, tracked in #2430335: Browser language detection is not cache aware.
This module currently fires page_cache_kill_switch only when a language header is present on the request. That keeps negotiated responses out of the internal page cache, but does nothing for the mirror case: on a site with the internal page cache enabled, an anonymous request without Accept-Language warms the URL with the default-language body, and every later anonymous request to the same URL with Accept-Language is served that cached default-language body before negotiation ever runs. The strict-negotiation contract silently degrades to default-language for anonymous traffic. (langCode is unaffected: it is part of the URL and therefore of the cache key.)
The hole also exists in the other direction on error paths, because core's page cache stores 4xx client-error responses too:
- The config-entity strict
406is thrown before any kill switch fires, unlike the content path. - The
/related/to-one strict miss (404/406) is thrown before that path's kill-switch trigger.
A stored header-triggered error could then be replayed to a selector-free request for the same URL, and vice versa.
Steps to reproduce
- Site with the
page_cachemodule enabled (core default), languagesen(default) andfr, a translatable article with anfrtranslation, anonymous users may view it. - As anonymous:
GET /jsonapi/node/article/{uuid}with noAccept-Languageheader. Response:200, body langcodeen, stored by the internal page cache. - As anonymous: the same URL with
Accept-Language: fr. Expected: thefrtranslation. Actual: the cachedenbody. - As anonymous: the same URL with
Accept-Language: de(no translation). Expected:406 Not Acceptable. Actual: the cachedenbody.
Proposed resolution
Trigger the page-cache kill switch unconditionally for every read of a translatable content resource, a config resource, and every jsonapi_menu_items request, whether or not the request carries a language selector, and before any negotiation or thrown error in each path. No response for a language-capable URL is then ever stored where a URL-keyed cache could mis-serve it, in either direction.
- The dynamic page cache is unaffected: it honours the already-declared
url.query_args:langCodeandheaders:Accept-Languagecache contexts and keeps serving these reads correctly, including for anonymous users. Implementation note: core'spage_cache_kill_switchservice is tagged into both the internal and dynamic page-cache policy chains, so triggering it unconditionally would disable both caches (this is why an unconditional trigger historically broke core'stestMetaEvent). The module therefore registers its own instance of the sameKillSwitchclass tagged only intopage_cache_response_policyand triggers that, denying the URL-keyed cache alone. - The trade-off is deliberate: anonymous internal-page-cache hits are lost for translatable JSON:API reads. Correctness of the contract outweighs that optimization; decoupled clients typically authenticate and bypass the internal page cache anyway.
- The alternative of documenting "disable the internal page cache" as a deployment requirement was rejected: a silently wrong-language response on a default install is a defect, not a footnote.
- Removal condition: when core #2430335 lands header support in the internal page cache, the unconditional triggers can be revisited.
Issue fork jsonapi_multilingual-3613686
Show commands
Start within a Git clone of the project using the version control instructions.
Or, if you do not have SSH keys set up on git.drupalcode.org:
Comments
Comment #2
lauriiiComment #5
lauriii