Repository: payloadcms/payload
Pull request #11725: fix(ui): switching languages does not update cached client config
Author: login: maximseshuk
Created: 2025-03-15T21:01:28Z | Merged: 2025-03-28T21:49:28Z

### What?
Fixed client config caching to properly update when switching languages in the admin UI.

### Why?
Currently, switching languages doesn't fully update the UI because client config stays cached with previous language translations.

### How?
Created a language-aware caching system that stores separate configs for each language and only uses cached config when it matches the active language.

Before:
```typescript
let cachedClientConfig: ClientConfig | null = global._payload_clientConfig

if (!cachedClientConfig) {
  cachedClientConfig = global._payload_clientConfig = null
}

export const getClientConfig = cache(
  (args: { config: SanitizedConfig; i18n: I18nClient; importMap: ImportMap }): ClientConfig => {
    if (cachedClientConfig && !global._payload_doNotCacheClientConfig) {
      return cachedClientConfig
    }
    // ... create new config ...
  }
);
```

After:
```typescript
let cachedClientConfigs: Record<string, ClientConfig> = global._payload_localizedClientConfigs

if (!cachedClientConfigs) {
  cachedClientConfigs = global._payload_localizedClientConfigs = {}
}

export const getClientConfig = cache(
  (args: { config: SanitizedConfig; i18n: I18nClient; importMap: ImportMap }): ClientConfig => {
    const { config, i18n, importMap } = args
    const currentLocale = i18n.language

    if (!global._payload_doNotCacheClientConfig && cachedClientConfigs[currentLocale]) {
      return cachedClientConfigs[currentLocale]
    }
    // ... create new config with correct translations ...
  }
);
```

Also added handling for cache clearing during HMR to ensure compatibility with the existing system.

Fixes #11406

Linked issue #11406: Admin panel localization issue: t() function labels stuck in first loaded language
### Describe the Bug

In Payload v3.24.0, when using multiple languages in the admin panel, there's a critical issue with localization persistence. After the first user accesses the admin panel with their preferred language, any subsequent language changes only partially work. 

Labels defined using the `t()` function (like `label: ({t}) => t('authentication:apiKey')`) remain in the first-loaded language and don't update when switching languages. However, labels defined using direct object notation (like `label: { en: 'Test', es: 'Prueba' }`) correctly switch between languages.

The issue appears to be that collection configs are rewritten after initial admin panel load, where function-based translations are evaluated once and replaced with their string values based on the first user's language. After this conversion, the original translation functions no longer exist to be re-evaluated when changing languages.

### Link to the code that reproduces this issue

https://github.com/maximseshuk/payload-core-i18n-bug-demo

### Reproduction Steps

1. Create a collection with both types of localization as shown in the example below:
```javascript
import type { CollectionConfig } from 'payload'

export const Test: CollectionConfig = {
  slug: 'test',
  labels: {
    singular: ({t}) => t('general:copy'),
    plural: ({t}) => t('general:copy'),
  },
  fields: [
   {
    name: 'test_select',
    type: 'select',
    label: ({t}) => t('authentication:apiKey'),
    options: [
        {
            label: ({t}) => t('authentication:account'),
            value: 'account',
        },
        {
            label: ({t}) => t('authentication:accountOfCurrentUser'),
            value: 'account2',
        },
    ],
   },
   {
    name: 'test_select_2',
    type: 'select',
    options: [
        {
            label: {
                en: 'Test 1',
                es: 'Prueba 1',
            },
            value: 'test1',
        },
        {
            label: {
                en: 'Test 2',
                es: 'Prueba 2',
            },
            value: 'test2',
        },
    ],
   }
  ],
}
```

2. Build and run your Payload project
3. Access the admin panel with a user whose language preferences are set to English
4. Observe that all UI elements display in English
5. Change the language setting to Spanish
6. Observe that elements using direct object notation (`label: { en, es }`) correctly update to Spanish
7. Observe that elements using the t() function (`label: ({t}) => t('...')`) remain in English and do not update

### Which area(s) are affected? (Select all that apply)

area: core

### Environment Info

```text
Binaries:
  Node: 22.12.0
  npm: 11.0.0
  Yarn: 1.22.22
  pnpm: 9.15.3
Relevant Packages:
  payload: 3.24.0
  next: 15.1.5
  @payloadcms/email-nodemailer: 3.24.0
  @payloadcms/graphql: 3.24.0
  @payloadcms/next/utilities: 3.24.0
  @payloadcms/payload-cloud: 3.24.0
  @payloadcms/richtext-lexical: 3.24.0
  @payloadcms/translations: 3.24.0
  @payloadcms/ui/shared: 3.24.0
  react: 19.0.0
  react-dom: 19.0.0
Operating System:
  Platform: darwin
  Arch: arm64
  Version: Darwin Kernel Version 24.3.0: Thu Jan  2 20:24:23 PST 2025; root:xnu-11215.81.4~3/RELEASE_ARM64_T6020
  Available memory (MB): 32768
  Available CPU cores: 12
```
Linked issue comments: user: login: maximseshuk
created_at: 2025-03-05T07:32:14Z
body: Any updates on this? thx

upd: the issue is in the latest update.
author_association: CONTRIBUTOR
user: login: DenisNikonov
created_at: 2025-03-11T12:20:27Z
body: +1
author_association: NONE
user: login: maximseshuk
created_at: 2025-03-11T12:22:00Z
body: @JessChowdhury can you please check this problem? thx
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-04-01T18:35:11Z
body: 🚀 This is included in version [v3.32.0](https://github.com/payloadcms/payload/releases/tag/v3.32.0)
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-04-09T05:04:52Z
body: This issue has been automatically locked.
Please open a new issue if this issue persists with any additional detail.
author_association: CONTRIBUTOR

Diff:
diff --git a/packages/payload/src/index.ts b/packages/payload/src/index.ts
index ad401c8cbb5..39139fbf891 100644
--- a/packages/payload/src/index.ts
+++ b/packages/payload/src/index.ts
@@ -65,8 +65,11 @@ import type {
 } from './types/index.js'
 import type { TraverseFieldsCallback } from './utilities/traverseFields.js'
 export type * from './admin/types.js'
+import type { SupportedLanguages } from '@payloadcms/translations'
+
 import { Cron } from 'croner'
 
+import type { ClientConfig } from './config/client.js'
 import type { TypeWithVersion } from './versions/types.js'
 
 import { decrypt, encrypt } from './auth/crypto.js'
@@ -865,10 +868,12 @@ export const reload = async (
   }
 
   await payload.db.init()
+
   if (payload.db.connect) {
     await payload.db.connect({ hotReload: true })
   }
-  global._payload_clientConfig = null
+
+  global._payload_clientConfigs = {} as Record<keyof SupportedLanguages, ClientConfig>
   global._payload_schemaMap = null
   global._payload_clientSchemaMap = null
   global._payload_doNotCacheClientConfig = true // This will help refreshing the client config cache more reliably. If you remove this, please test HMR + client config refreshing (do new fields appear in the document?)
diff --git a/packages/ui/src/utilities/getClientConfig.ts b/packages/ui/src/utilities/getClientConfig.ts
index ae5629a8688..4bf4c1b54b0 100644
--- a/packages/ui/src/utilities/getClientConfig.ts
+++ b/packages/ui/src/utilities/getClientConfig.ts
@@ -1,31 +1,38 @@
-import type { I18nClient } from '@payloadcms/translations'
+import type { I18nClient, SupportedLanguages } from '@payloadcms/translations'
 import type { ClientConfig, ImportMap, SanitizedConfig } from 'payload'
 
 import { createClientConfig } from 'payload'
 import { cache } from 'react'
 
-let cachedClientConfig: ClientConfig | null = global._payload_clientConfig
+let cachedClientConfigs = global._payload_clientConfigs as Record<
+  keyof SupportedLanguages,
+  ClientConfig
+>
 
-if (!cachedClientConfig) {
-  cachedClientConfig = global._payload_clientConfig = null
+if (!cachedClientConfigs) {
+  cachedClientConfigs = global._payload_clientConfigs = {} as Record<
+    keyof SupportedLanguages,
+    ClientConfig
+  >
 }
 
 export const getClientConfig = cache(
   (args: { config: SanitizedConfig; i18n: I18nClient; importMap: ImportMap }): ClientConfig => {
-    if (cachedClientConfig && !global._payload_doNotCacheClientConfig) {
-      return cachedClientConfig
-    }
-
     const { config, i18n, importMap } = args
+    const currentLanguage = i18n.language
 
-    cachedClientConfig = createClientConfig({
+    if (cachedClientConfigs[currentLanguage] && !global._payload_doNotCacheClientConfig) {
+      return cachedClientConfigs[currentLanguage]
+    }
+
+    const cachedClientConfig = createClientConfig({
       config,
       i18n,
       importMap,
     })
 
-    global._payload_clientConfig = cachedClientConfig
-
+    cachedClientConfigs[currentLanguage] = cachedClientConfig
+    global._payload_clientConfigs = cachedClientConfigs
     global._payload_doNotCacheClientConfig = false
 
     return cachedClientConfig
diff --git a/test/i18n/e2e.spec.ts b/test/i18n/e2e.spec.ts
index 8880a8ec05c..a1b7587c522 100644
--- a/test/i18n/e2e.spec.ts
+++ b/test/i18n/e2e.spec.ts
@@ -82,4 +82,27 @@ describe('i18n', () => {
       page.locator('.componentWithCustomI18n .componentWithCustomI18nCustomValidI18nT'),
     ).toHaveText('My custom translation')
   })
+
+  test('ensure translations update correctly when switching language', async () => {
+    await page.goto(serverURL + '/admin/account')
+
+    await page.locator('div.rs__control').click()
+    await page.locator('div.rs__option').filter({ hasText: 'English' }).click()
+    await expect(page.locator('div.payload-settings h3')).toHaveText('Payload Settings')
+
+    await page.goto(serverURL + '/admin/collections/collection1/create')
+    await expect(page.locator('label[for="field-fieldDefaultI18nValid"]')).toHaveText(
+      'Add {{label}}',
+    )
+
+    await page.goto(serverURL + '/admin/account')
+    await page.locator('div.rs__control').click()
+    await page.locator('div.rs__option').filter({ hasText: 'Español' }).click()
+    await expect(page.locator('div.payload-settings h3')).toHaveText('Configuración de la carga')
+
+    await page.goto(serverURL + '/admin/collections/collection1/create')
+    await expect(page.locator('label[for="field-fieldDefaultI18nValid"]')).toHaveText(
+      'Añadir {{label}}',
+    )
+  })
 })


Reviews:
user: login: jacobsfletch
body: Instead of caching a config for each language, which could grow exponentially in size, we might want to instead refresh the cache every time the language changes. To do this we can cache the language on the global scope, and when it changes, regenerate the client config and replace the cache.

Update: I could either way here. There is certainly a benefit to caching each language once on a single object as you have it, e.g. when changing _back_ to a previously selected language it would already be cached. But in reality it's not that often that a user changes languages and generating client configs is not a significant bottleneck in this scenario.
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-03-26T03:30:00Z
user: login: jacobsfletch
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-26T03:40:07Z
user: login: jacobsfletch
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-28T21:45:03Z

Review comments:
diff_hunk: @@ -4,28 +4,29 @@ import type { ClientConfig, ImportMap, SanitizedConfig } from 'payload'
 import { createClientConfig } from 'payload'
 import { cache } from 'react'
 
-let cachedClientConfig: ClientConfig | null = global._payload_clientConfig
+let cachedClientConfigs: Record<string, ClientConfig> = global._payload_localizedClientConfigs
 
-if (!cachedClientConfig) {
-  cachedClientConfig = global._payload_clientConfig = null
+if (!cachedClientConfigs) {
+  cachedClientConfigs = global._payload_localizedClientConfigs = {}
 }
 
 export const getClientConfig = cache(
   (args: { config: SanitizedConfig; i18n: I18nClient; importMap: ImportMap }): ClientConfig => {
-    if (cachedClientConfig && !global._payload_doNotCacheClientConfig) {
-      return cachedClientConfig
-    }
-
     const { config, i18n, importMap } = args
+    const currentLocale = i18n.language
path: packages/ui/src/utilities/getClientConfig.ts
user: login: jacobsfletch
body: The `locale` keyword here is a bit misleading as this is a reference to localization, not i18n.
created_at: 2025-03-26T03:40:07Z
author_association: MEMBER

PR comments:
user: login: maximseshuk
created_at: 2025-03-26T09:16:42Z
body: > Instead of caching a config for each language, which could grow exponentially in size, we might want to instead refresh the cache every time the language changes. To do this we can cache the language on the global scope, and when it changes, regenerate the client config and replace the cache.
> 
> Update: I could either way here. There is certainly a benefit to caching each language once on a single object as you have it, e.g. when changing _back_ to a previously selected language it would already be cached. But in reality it's not that often that a user changes languages and generating client configs is not a significant bottleneck in this scenario.

I thought about your idea, but there's a problem: what happens when multiple admins use different languages at the same time?

If we update the cache when language changes, one admin would overwrite another admin's cache since it's stored in a global variable.

Maybe we should just remove caching completely? This could work better because:
1. As you said, creating configs isn't slow
2. Everyone would always get the right language
3. No cache conflicts between users

What do you think? Or we could add an LRU cache to keep only the most frequently used languages in memory instead of storing all of them.
author_association: CONTRIBUTOR
user: login: jacobsfletch
created_at: 2025-03-26T19:33:32Z
body: > I thought about your idea, but there's a problem: what happens when multiple admins use different languages at the same time?
>
> If we update the cache when language changes, one admin would overwrite another admin's cache since it's stored in a global variable.

Yep, this is accurate. The fact that it's on the global scope would affect all users. Good thinking. The underlying sentiment behind my suggestion is simply to limit memory usage as the config grows, especially considering there are no (or very few) object references to effectively dedupe parts the config. In reality, though, this is probably not a _huge_ concern considering that even the largest apps would likely not use more than a handful of languages, and even if so, those apps would have more resources available to them.

I think long term what we might want to do is remove `i18n` from the client config generation altogether. This way there is no need to store all language variations in the first place. The only reason this is necessary is to translate field labels that are defined as functions, i.e. this is not a substantial touchpoint. We could instead resolve the functions into strings and interpolate the translations client-side, where we'd have an instance of `i18n`. We could theoretically establish some pattern like `Translate this {{string}}`.

Let's get your fix in for now at least, and maybe we can reevaluate this in the future.

> Maybe we should just remove caching completely?

Caching is still a requirement here as this function runs within the root layout, as well on all pages. We need to dedupe multiple invocations within the same request, as well as across page routes. When you have a very large config, this could get expensive and measurably slower to navigate the app. I think this is a necessary optimization.

I have a couple bits of minor feedback and then I think one is good to go, pending tests pass.
author_association: MEMBER
user: login: maximseshuk
created_at: 2025-03-27T06:24:12Z
body: > > I thought about your idea, but there's a problem: what happens when multiple admins use different languages at the same time?
> > If we update the cache when language changes, one admin would overwrite another admin's cache since it's stored in a global variable.
> 
> Yep, this is accurate. The fact that it's on the global scope would affect all users. Good thinking. The underlying sentiment behind my suggestion is simply to limit memory usage as the config grows, especially considering there are no (or very few) object references to effectively dedupe parts the config. In reality, though, this is probably not a _huge_ concern considering that even the largest apps would likely not use more than a handful of languages, and even if so, those apps would have more resources available to them.
> 
> I think long term what we might want to do is remove `i18n` from the client config generation altogether. This way there is no need to store all language variations in the first place. The only reason this is necessary is to translate field labels that are defined as functions, i.e. this is not a substantial touchpoint. We could instead resolve the functions into strings and interpolate the translations client-side, where we'd have an instance of `i18n`. We could theoretically establish some pattern like `Translate this {{string}}`.
> 
> Let's get your fix in for now at least, and maybe we can reevaluate this in the future.
> 
> > Maybe we should just remove caching completely?
> 
> Caching is still a requirement here as this function runs within the root layout, as well on all pages. We need to dedupe multiple invocations within the same request, as well as across page routes. When you have a very large config, this could get expensive and measurably slower to navigate the app. I think this is a necessary optimization.
> 
> I have a couple bits of minor feedback and then I think one is good to go, pending tests pass.

I've already made the fix based on your comment :) It looks like we can merge this solution now as a temporary solution. Going forward, I completely agree that it would make more sense to resolve translations on the client side while only storing the keys.
author_association: CONTRIBUTOR
user: login: jacobsfletch
created_at: 2025-03-28T21:43:45Z
body: > I've already made the fix based on your comment :)

Woops I meant to leave another review but ended up going on a side quest instead. I went ahead and just made these changes directly in b16cfa4. This is good to go from my perspective. Really appreciate your work on this.
author_association: MEMBER
user: login: github-actions[bot]
created_at: 2025-04-01T18:35:11Z
body: 🚀 This is included in version [v3.32.0](https://github.com/payloadcms/payload/releases/tag/v3.32.0)
author_association: CONTRIBUTOR