Problem/Motivation

Follow-up for #3569172: Weird language negotiation behavior inside getLanguageSwitchLinks leading to incorrect languages being used

We should avoid the global state change in \Drupal\language\ConfigurableLanguageManager::getLanguageSwitchLinks().

Rendering a node for a given language is a perfectly valid use case and done plenty of times without this workaround (for example in a view of teasers that displays a specific language). I wonder if this global state is even necessary. I think I'd start with removing it and seeing what happens. Maybe this is legacy code that isn't even needed anymore.

Steps to reproduce

Proposed resolution

MR 16451

  • The Url object already supports a language option. Extend its use so that on routed Url objects, route parameters are set, keyed by each supported language type (with a '_' suffix to avoid collisions), to the value of the specified language
  • In AccessManager::checkNamedRoute(), set the matching passed in parameters (e.g. 'language_type_', or 'language_content_') as route defaults
  • In EntityConverter and EntityRevisionParamConverted, check whether the matching language route default is set, and get the entity translation for the language for the upcast parameter value
  • In AccessArgumentsResolveFactory, make language values available to access callback arguments that are typed LanguageInterface and have names matching language types (e.g. LanguageInterface $languageContent, or LanguageInterface $language_interface). Name matching supports both camel and snake case

Remaining tasks

User interface changes

Introduced terminology

API changes

Data model changes

Release notes snippet

Issue fork drupal-3576381

Command icon 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

berdir created an issue. See original summary.

godotislate’s picture

velmir_taky’s picture

Problem/Motivation

getLanguageSwitchLinks() in ConfigurableLanguageManager temporarily overwrites $this->negotiatedLanguages to set the "current language" before running $url->access() for each language link, then restores it after the loop.

This is a problem with Fibers (#3569172) — if a Fiber yields during the access check, other Fibers see a wrong language in negotiatedLanguages. Leads to random wrong translations in menus/blocks depending on timing.

Proposed resolution

Pass the language through the access chain as data instead of mutating global state.

EntityRepository::getContentLanguageFromContexts() already supports a langcode key in $contexts — it skips getCurrentLanguage() when that key is present. So we just need to get the language from the URL down to that point:

- getLanguageSwitchLinks() — stop mutating negotiatedLanguages, just set the language option on the Url (same option already used for URL generation)
- Url::access() — if language option is set, add _content_langcode to route parameters before calling checkNamedRoute()
- EntityConverter::convert() and EntityRevisionParamConverter::convert() — read _content_langcode from defaults and pass as langcode in contexts to EntityRepository

Without _content_langcode (all normal requests) behavior is unchanged — EntityRepository falls back to getCurrentLanguage() as before.

No API/BC breaks — no interface signatures change.

velmir_taky’s picture

Status: Active » Needs review
godotislate’s picture

Status: Needs review » Needs work

Nice work, @velmir_taky! I think this is a clever approach that covers the vast majority of use cases, but here are couple that aren't covered that I think are worth bringing up:

  • It's possible that a route based on a content entity, whether it's the canonical route or otherwise, has additional (language-based) access control on top of access to the entity itself
  • For non-content-entity routes, it's possible that there is language-based access control. For example, someone could implement a route access checker for a Views page (or perhaps a better example from contrib would be a Webform page) where if the View or Webform config entity did not have config translation for a certain language, access would be denied, so that the link to the webform page in that language would not appear in the switch links

It's possible that these are pretty edge-casey, but it would be a change in behavior here regardless.

The other comment on the MR is that the test does not confirm that the negotiated languages did not mutate during the access test. It only show that the negotiated language has not changed upon exit of the method. I've confirmed this by running the test only job and seeing that it passes: https://git.drupalcode.org/issue/drupal-3576381/-/jobs/8702779

velmir_taky’s picture

Status: Needs work » Needs review

@godotislate thanks for the review!

Test fix: Rewrote the test. The old one only checked state before/after getLanguageSwitchLinks() - confirmed it passes on unpatched code as you noted. The new approach registers a custom access checker (LanguageStateCapturingAccessCheck) in language_test that snapshots negotiatedLanguages langcodes into State during each Url::access() call. The test then asserts:

- the checker was actually invoked
- it saw TYPE_CONTENT = 'fr' when checking the French link
- full state is restored after the method returns

Coverage gap: Agreed, _content_langcode alone isn't enough — it's filtered by RouteMatch and never reaches access checkers. Fixed by adding save/restore of negotiatedLanguages in getLanguageSwitchLinks(): before the array_filter loop we snapshot the state, temporarily set negotiatedLanguages[TYPE_CONTENT] to the link's language before each access check, and restore right after. So getCurrentLanguage(TYPE_CONTENT) inside any access checker now returns the correct language per link — handles both your examples (entity routes with extra language-based access, and non-entity routes like Views/Webform pages checking config translation availability). _content_langcode is kept for entity param converters.

needs-review-queue-bot’s picture

Status: Needs review » Needs work
StatusFileSize
new91 bytes

The Needs Review Queue Bot tested this issue. It no longer applies to Drupal core. Therefore, this issue status is now "Needs work".

This does not mean that the patch necessarily needs to be re-rolled or the MR rebased. Read the Issue Summary, the issue tags and the latest discussion here to determine what needs to be done.

Consult the Drupal Contributor Guide to find step-by-step guides for working with issues.

velmir_taky’s picture

Thanks @godotislate for picking this up, and for the new approach here.

Carrying the language through the access stack as data — instead of temporarily changing the negotiated language state — is clearly the better direction, and it lines up with what @berdir outlined in the summary. My !14950 still leans on swapping the negotiated languages, so it doesn't really get us to that goal.

So I'll close !14950 in favour of !16451 and we can consolidate on the cleaner approach. Happy to help review or test it whenever that's useful — just say the word.

Thanks again for moving this forward.

godotislate changed the visibility of the branch 3576381-ensure-that-url to hidden.

godotislate’s picture

Have a draft MR up: https://git.drupalcode.org/project/drupal/-/merge_requests/16451

Took inspiration from @velmir_taky's work and expanded on it a little so that route access callbacks can have the language objects passed to them for any language-based logic.

Tests pass for now, but I think I might need to tweak how the languages are passed from the Url Object to the Route object to the param converters and argument resolver. I'll have to think that over for later because I'm tired now, so leaving as NW.

This will also need a CR and IS update, so tagging for those.

And thanks to @velmir_taky for the original work

godotislate’s picture

Issue summary: View changes
Status: Needs work » Needs review
Issue tags: -Needs change record, -Needs issue summary update

Looking at the MR again after a day, I think it's ready for review at least.

IS updated with proposed solution.
CR created: https://www.drupal.org/node/3613488

needs-review-queue-bot’s picture

Status: Needs review » Needs work
StatusFileSize
new98 bytes

The Needs Review Queue Bot tested this issue. The merge request has merge conflicts and cannot be merged. Therefore, this issue status is now "Needs work".

This does not mean that the patch necessarily needs to be re-rolled or the MR rebased. Read the Issue Summary, the issue tags and the latest discussion here to determine what needs to be done.

Consult the Drupal Contributor Guide to find step-by-step guides for working with issues.

godotislate’s picture

Status: Needs work » Needs review

Rebased for performance test class move.

needs-review-queue-bot’s picture

Status: Needs review » Needs work
StatusFileSize
new1.37 KB

The Needs Review Queue Bot tested this issue. It fails the Drupal core commit checks. Therefore, this issue status is now "Needs work".

This does not mean that the patch necessarily needs to be re-rolled or the MR rebased. Read the Issue Summary, the issue tags and the latest discussion here to determine what needs to be done.

Consult the Drupal Contributor Guide to find step-by-step guides for working with issues.

godotislate’s picture

Status: Needs work » Needs review

Switched to using LanguageManagerInterface::getDefinedLanguageTypesInfo instead of getLanguageTypes, because with the ConfigurableLanguageManager, TYPE_CONTENT might not be returned if it's not configurable.