fix(next): properly instantiates locale context (#10451)

Currently, unless a locale is present in the URL search params, the
locale context is instantiated using the default locale until prefs load
in client-side. This causes the locale selector to briefly render in
with the incorrect (default) locale before being replaced by the proper
locale of the request. For example, if the default locale is `en`, and
the page is requested in `es`, the locale selector will flash with
English before changing to the correct locale, even though the page data
itself is properly loaded in Spanish. This is especially evident within
slow networks.

The fix is to query the user's locale preference server-side and thread
it into the locale provider to initialize state. Because search params
are not available within server layouts, we cannot pass the locale param
in the same way, so we rely on the provider itself to read them from the
`useSearchParams` hook. If present, this takes precedence over the
user's preference if it exists.

Since the root page also queries the user's locale preference to
determine the proper locale across navigation, we use React's cache
function to dedupe these function calls and ensure only a single query
is made to the db for each request.
This commit is contained in:
Jacob Fletcher
2025-01-08 23:57:42 -05:00
committed by GitHub
parent 36e50dd6a6
commit a78bc6c65e
13 changed files with 186 additions and 139 deletions

View File

@@ -10,6 +10,7 @@ import React from 'react'
import { getNavPrefs } from '../../elements/Nav/getNavPrefs.js'
import { getRequestLanguage } from '../../utilities/getRequestLanguage.js'
import { getRequestLocale } from '../../utilities/getRequestLocale.js'
import { getRequestTheme } from '../../utilities/getRequestTheme.js'
import { initReq } from '../../utilities/initReq.js'
import { checkDependencies } from './checkDependencies.js'
@@ -92,6 +93,11 @@ export const RootLayout = async ({
importMap,
})
const locale = await getRequestLocale({
payload,
user,
})
return (
<html
data-theme={theme}
@@ -110,6 +116,7 @@ export const RootLayout = async ({
isNavOpen={navPrefs?.open ?? true}
languageCode={languageCode}
languageOptions={languageOptions}
locale={locale?.code}
permissions={permissions}
serverFunction={serverFunction}
switchLanguageServerAction={switchLanguageServerAction}

View File

@@ -0,0 +1,41 @@
import type { Payload, User } from 'payload'
import { cache } from 'react'
export const getPreference = cache(
async <T>(key: string, payload: Payload, user: User): Promise<T> => {
let result: T = null
try {
result = await payload
.find({
collection: 'payload-preferences',
depth: 0,
limit: 1,
user,
where: {
and: [
{
'user.relationTo': {
equals: payload.config.admin.user,
},
},
{
'user.value': {
equals: user.id,
},
},
{
key: {
equals: key,
},
},
],
},
})
?.then((res) => res.docs?.[0]?.value as T)
} catch (_err) {} // eslint-disable-line no-empty
return result
},
)

View File

@@ -0,0 +1,32 @@
import type { Locale, Payload, User } from 'payload'
import { findLocaleFromCode } from '@payloadcms/ui/shared'
import { getPreference } from './getPreference.js'
type GetRequestLocalesArgs = {
localeFromParams?: string
payload: Payload
user: User
}
export async function getRequestLocale({
localeFromParams,
payload,
user,
}: GetRequestLocalesArgs): Promise<Locale> {
if (payload.config.localization) {
return (
findLocaleFromCode(
payload.config.localization,
localeFromParams || (await getPreference<Locale['code']>('locale', payload, user)),
) ||
findLocaleFromCode(
payload.config.localization,
payload.config.localization.defaultLocale || 'en',
)
)
}
return undefined
}

View File

@@ -1,45 +0,0 @@
import type { Payload } from 'payload'
import { sanitizeFallbackLocale } from 'payload'
type GetRequestLocalesArgs = {
data?: Record<string, any>
localization: Exclude<Payload['config']['localization'], false>
searchParams?: URLSearchParams
}
export function getRequestLocales({ data, localization, searchParams }: GetRequestLocalesArgs): {
fallbackLocale: string
locale: string
} {
let locale = searchParams.get('locale')
let fallbackLocale = searchParams.get('fallback-locale') || searchParams.get('fallbackLocale')
if (data) {
if (data?.locale) {
locale = data.locale
}
if (data?.['fallback-locale']) {
fallbackLocale = data['fallback-locale']
}
if (data?.['fallbackLocale']) {
fallbackLocale = data['fallbackLocale']
}
}
fallbackLocale = sanitizeFallbackLocale({
fallbackLocale,
locale,
localization,
})
if (locale === '*') {
locale = 'all'
} else if (!localization.localeCodes.includes(locale)) {
locale = localization.defaultLocale
}
return {
fallbackLocale,
locale,
}
}

View File

@@ -1,7 +1,6 @@
import type { I18n } from '@payloadcms/translations'
import type { InitPageResult, Locale, VisibleEntities } from 'payload'
import type { InitPageResult, VisibleEntities } from 'payload'
import { findLocaleFromCode } from '@payloadcms/ui/shared'
import { headers as getHeaders } from 'next/headers.js'
import { notFound } from 'next/navigation.js'
import { createLocalReq, getPayload, isEntityHidden, parseCookies } from 'payload'
@@ -9,6 +8,7 @@ import * as qs from 'qs-esm'
import type { Args } from './types.js'
import { getRequestLocale } from '../getRequestLocale.js'
import { initReq } from '../initReq.js'
import { getRouteInfo } from './handleAdminPage.js'
import { handleAuthRedirect } from './handleAuthRedirect.js'
@@ -28,7 +28,6 @@ export const initPage = async ({
const {
collections,
globals,
localization,
routes: { admin: adminRoute },
} = payload.config
@@ -74,52 +73,13 @@ export const initPage = async ({
req.user = user
const localeParam = searchParams?.locale as string
let locale: Locale
const locale = await getRequestLocale({
localeFromParams: req.query.locale as string,
payload,
user,
})
if (localization) {
const defaultLocaleCode = localization.defaultLocale ? localization.defaultLocale : 'en'
let localeCode: string = localeParam
if (!localeCode) {
try {
localeCode = await payload
.find({
collection: 'payload-preferences',
depth: 0,
limit: 1,
user,
where: {
and: [
{
'user.relationTo': {
equals: payload.config.admin.user,
},
},
{
'user.value': {
equals: user.id,
},
},
{
key: {
equals: 'locale',
},
},
],
},
})
?.then((res) => res.docs?.[0]?.value as string)
} catch (_err) {} // eslint-disable-line no-empty
}
locale = findLocaleFromCode(localization, localeCode)
if (!locale) {
locale = findLocaleFromCode(localization, defaultLocaleCode)
}
req.locale = locale.code
}
req.locale = locale?.code
const visibleEntities: VisibleEntities = {
collections: collections

View File

@@ -48,6 +48,7 @@ export const RootPage = async ({
} = config
const params = await paramsPromise
const currentRoute = formatAdminURL({
adminRoute,
path: `${Array.isArray(params.segments) ? `/${params.segments.join('/')}` : ''}`,

View File

@@ -12,7 +12,7 @@ export const updateHandler: PayloadHandler = async (incomingReq) => {
try {
data = await incomingReq.json()
} catch (error) {
} catch (_err) {
data = {}
}

View File

@@ -12,20 +12,24 @@ import { usePreferences } from '../Preferences/index.js'
const LocaleContext = createContext({} as Locale)
export const LocaleProvider: React.FC<{ children?: React.ReactNode }> = ({ children }) => {
export const LocaleProvider: React.FC<{ children?: React.ReactNode; locale?: Locale['code'] }> = ({
children,
locale: localeFromProps,
}) => {
const {
config: { localization = false },
} = useConfig()
const { user } = useAuth()
const defaultLocale =
localization && localization.defaultLocale ? localization.defaultLocale : 'en'
const { getPreference, setPreference } = usePreferences()
const searchParams = useSearchParams()
const localeFromParams = searchParams.get('locale')
const localeFromParams = useSearchParams().get('locale')
const [localeCode, setLocaleCode] = useState<string>(defaultLocale)
// `localeFromProps` originates from the root layout, which does not have access to search params
const [localeCode, setLocaleCode] = useState<string>(localeFromProps || localeFromParams)
const locale: Locale = React.useMemo(() => {
if (!localization) {

View File

@@ -3,6 +3,7 @@ import type { I18nClient, Language } from '@payloadcms/translations'
import type {
ClientConfig,
LanguageOptions,
Locale,
SanitizedPermissions,
ServerFunctionClient,
User,
@@ -41,6 +42,7 @@ type Props = {
readonly isNavOpen?: boolean
readonly languageCode: string
readonly languageOptions: LanguageOptions
readonly locale?: Locale['code']
readonly permissions: SanitizedPermissions
readonly serverFunction: ServerFunctionClient
readonly switchLanguageServerAction?: (lang: string) => Promise<void>
@@ -57,6 +59,7 @@ export const RootProvider: React.FC<Props> = ({
isNavOpen,
languageCode,
languageOptions,
locale,
permissions,
serverFunction,
switchLanguageServerAction,
@@ -96,7 +99,7 @@ export const RootProvider: React.FC<Props> = ({
<PreferencesProvider>
<ThemeProvider theme={theme}>
<ParamsProvider>
<LocaleProvider>
<LocaleProvider locale={locale}>
<StepNavProvider>
<LoadingOverlayProvider>
<DocumentEventsProvider>

View File

@@ -168,6 +168,7 @@ describe('Text', () => {
await upsertPrefs<Config, GeneratedTypes<any>>({
payload,
user: client.user,
key: 'text-fields-list',
value: {
columns: [
{

View File

@@ -9,51 +9,65 @@ export const upsertPrefs = async <
payload,
user,
value,
key,
}: {
key: string
payload: PayloadTestSDK<TConfig>
user: TypedUser
value: Record<string, any>
value: any
}): Promise<TGeneratedTypes['collections']['payload-preferences']> => {
let prefs = await payload
.find({
collection: 'payload-preferences',
depth: 0,
limit: 1,
where: {
and: [
{ key: { equals: 'text-fields-list' } },
{ 'user.value': { equals: user.id } },
{ 'user.relationTo': { equals: user.collection } },
],
},
})
?.then((res) => res.docs?.[0])
try {
let prefs = await payload
.find({
collection: 'payload-preferences',
depth: 0,
limit: 1,
where: {
and: [
{ key: { equals: key } },
{ 'user.value': { equals: user.id } },
{ 'user.relationTo': { equals: user.collection } },
],
},
})
?.then((res) => res.docs?.[0])
if (!prefs) {
prefs = await payload.create({
collection: 'payload-preferences',
depth: 0,
data: {
key: 'text-fields-list',
user: {
relationTo: user.collection,
value: user.id,
if (!prefs) {
prefs = await payload.create({
collection: 'payload-preferences',
depth: 0,
data: {
key,
user: {
relationTo: user.collection,
value: user.id,
},
value,
},
value,
},
})
} else {
prefs = await payload.update({
collection: 'payload-preferences',
id: prefs.id,
data: {
value: {
...(prefs?.value ?? {}),
...value,
})
} else {
const newValue = typeof value === 'object' ? { ...(prefs?.value || {}), ...value } : value
prefs = await payload.update({
collection: 'payload-preferences',
id: prefs.id,
data: {
key,
user: {
collection: user.collection,
value: user.id,
},
value: newValue,
},
},
})
})
if (prefs?.status >= 400) {
throw new Error(prefs.data?.errors?.[0]?.message)
}
return prefs
}
} catch (e) {
console.error('Error upserting prefs', e)
}
return prefs
}

View File

@@ -30,6 +30,9 @@ import {
withRequiredLocalizedFields,
} from './shared.js'
import { navigateToDoc } from 'helpers/e2e/navigateToDoc.js'
import { upsertPrefs } from 'helpers/e2e/upsertPrefs.js'
import { RESTClient } from 'helpers/rest.js'
import { GeneratedTypes } from 'helpers/sdk/types.js'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
@@ -54,6 +57,7 @@ const description = 'description'
let page: Page
let payload: PayloadTestSDK<Config>
let client: RESTClient
let serverURL: string
let richTextURL: AdminUrlUtil
let context: BrowserContext
@@ -73,6 +77,9 @@ describe('Localization', () => {
initPageConsoleErrorCatch(page)
client = new RESTClient(null, { defaultSlug: 'users', serverURL })
await client.login()
await ensureCompilationIsDone({ page, serverURL })
})
@@ -84,8 +91,30 @@ describe('Localization', () => {
// })
})
describe('localizer', async () => {
test('should not render default locale in locale selector when prefs are not default', async () => {
// change prefs to spanish, then load page and check that the localizer label does not say English
await upsertPrefs<Config, GeneratedTypes<any>>({
payload,
user: client.user,
key: 'locale',
value: 'es',
})
await page.goto(url.list)
const localeLabel = await page
.locator('.localizer.app-header__localizer .localizer-button__current-label')
.innerText()
expect(localeLabel).not.toEqual('English')
})
})
describe('localized text', () => {
test('create english post, switch to spanish', async () => {
await changeLocale(page, defaultLocale)
await page.goto(url.create)
await fillValues({ description, title })

View File

@@ -28,7 +28,7 @@
}
],
"paths": {
"@payload-config": ["./test/admin/config.ts"],
"@payload-config": ["./test/localization/config.ts"],
"@payloadcms/live-preview": ["./packages/live-preview/src"],
"@payloadcms/live-preview-react": ["./packages/live-preview-react/src/index.ts"],
"@payloadcms/live-preview-vue": ["./packages/live-preview-vue/src/index.ts"],