The Content-Language response header is lost on every dynamic page cache hit
The header was set from a request attribute only the controller populates, so a dynamic page cache hit, where the controller never runs, returned no Content-Language at all. A translated read was labelled on the first request and unlabelled on every identical one after it, making the header depend on cache warmth. Two shipped requirements rely on it: translation-endpoints requires the response to state the returned translation, and language-fallback-reads requires a fallback read to be self-describing. Fallback mode is where it hurts most, because the served language is routinely not the requested one, so the header is the only signal of what was actually returned.
Stamping the header before the dynamic page cache stores the response does not work, and the reason is worth recording: core's FinishResponseSubscriber::onRespond() sets Content-Language from the negotiated interface language unconditionally at its default priority 0 (Symfony's HeaderBag::set() replaces by default), and HttpKernel re-dispatches the response event for a served cache hit exactly as for a fresh response. Anything stamped above priority 0 is overwritten on every request, warm or cold, so only a listener below 0 can win.
The existing -10 listener therefore keeps its priority and gains a fallback: it prefers the controller-set attribute and otherwise derives the langcode from the served document's own data.attributes.langcode. The document is used rather than the request's langCode parameter because in fallback mode the requested language is not the served one, and the header must state what was served.
That fallback is scoped to the module's own surfaces (the JSON:API route flag, or the jsonapi_menu_items route name, which is required in addition because that route carries jsonapi_resources' _jsonapi_resource flag rather than core's _is_jsonapi). Without the guard every cacheable 200 on the site, HTML pages included, would pay a full-body json_decode() looking for a key that cannot be there: the same scoping lesson as #3613670.
Also guards both setContent(Json::encode(...)) call sites with is_string(). Json::encode() returns FALSE on failure, and under strict_types passing that to setContent() is a TypeError, turning a degraded response into a 500.
Test asserts X-Drupal-Dynamic-Cache MISS then HIT with the header present on both, in strict mode and in fallback mode where the served language differs from the requested one. Asserting the cache status is what makes the test meaningful: a plain repeated request would pass without proving the second response came from the cache.
Co-Authored-By: Claude Fable 5 noreply@anthropic.com
Closes #3614383