fix(ui): stale locale value from useLocale (#9582)

### What?
Fixes issue with stale locale from searchParams

### Why?
Bad use of useEffect/useState inside our useSearchParams provider.

### How?
Memoize the locale instead of relying on the useEffect which was causing
unnecessary renders with stale values.
This commit is contained in:
Jarrod Flesch
2024-12-04 14:00:17 -05:00
committed by GitHub
parent 2321970fcc
commit fa7ed3f621
11 changed files with 68 additions and 82 deletions

View File

@@ -1,4 +1,4 @@
import { withPayload } from "@payloadcms/next/withPayload"; import { withPayload } from '@payloadcms/next/withPayload'
import type { NextConfig } from 'next' import type { NextConfig } from 'next'
const nextConfig: NextConfig = {} const nextConfig: NextConfig = {}

View File

@@ -1,14 +1,14 @@
{ {
"name": "payload-3-custom-server", "name": "payload-3-custom-server",
"type": "module",
"scripts": { "scripts": {
"dev": "nodemon",
"build": "next build && tsc --project tsconfig.server.json", "build": "next build && tsc --project tsconfig.server.json",
"start": "cross-env NODE_ENV=production node dist/server.js", "dev": "nodemon",
"generate:importmap": "cross-env NODE_OPTIONS=--no-deprecation payload generate:importmap", "generate:importmap": "cross-env NODE_OPTIONS=--no-deprecation payload generate:importmap",
"generate:types": "cross-env NODE_OPTIONS=--no-deprecation payload generate:types", "generate:types": "cross-env NODE_OPTIONS=--no-deprecation payload generate:types",
"payload": "cross-env NODE_OPTIONS=--no-deprecation payload" "payload": "cross-env NODE_OPTIONS=--no-deprecation payload",
"start": "cross-env NODE_ENV=production node dist/server.js"
}, },
"type": "module",
"dependencies": { "dependencies": {
"@payloadcms/db-mongodb": "latest", "@payloadcms/db-mongodb": "latest",
"@payloadcms/next": "latest", "@payloadcms/next": "latest",

View File

@@ -1,3 +1,3 @@
export default function B() { export default function B() {
return <div>b</div>; return <div>b</div>
} }

View File

@@ -1,11 +1,7 @@
export default function RootLayout({ export default function RootLayout({ children }: { children: React.ReactNode }) {
children,
}: {
children: React.ReactNode;
}) {
return ( return (
<html lang="en"> <html lang="en">
<body>{children}</body> <body>{children}</body>
</html> </html>
); )
} }

View File

@@ -1,5 +1 @@
export const importMap = {}
export const importMap = {
}

View File

@@ -31,7 +31,7 @@ export const PublishButton: React.FC<{ label?: string }> = ({ label: labelProp }
const { submit } = useForm() const { submit } = useForm()
const modified = useFormModified() const modified = useFormModified()
const editDepth = useEditDepth() const editDepth = useEditDepth()
const { code: locale } = useLocale() const { code: localeCode } = useLocale()
const { const {
localization, localization,
@@ -40,7 +40,6 @@ export const PublishButton: React.FC<{ label?: string }> = ({ label: labelProp }
} = config } = config
const { i18n, t } = useTranslation() const { i18n, t } = useTranslation()
const { code } = useLocale()
const label = labelProp || t('version:publishChanges') const label = labelProp || t('version:publishChanges')
const hasNewerVersions = unpublishedVersionCount > 0 const hasNewerVersions = unpublishedVersionCount > 0
@@ -54,7 +53,7 @@ export const PublishButton: React.FC<{ label?: string }> = ({ label: labelProp }
return return
} }
const search = `?locale=${locale}&depth=0&fallback-locale=null&draft=true` const search = `?locale=${localeCode}&depth=0&fallback-locale=null&draft=true`
let action let action
let method = 'POST' let method = 'POST'
@@ -77,7 +76,7 @@ export const PublishButton: React.FC<{ label?: string }> = ({ label: labelProp }
}, },
skipValidation: true, skipValidation: true,
}) })
}, [submit, collectionSlug, globalSlug, serverURL, api, locale, id, forceDisable]) }, [submit, collectionSlug, globalSlug, serverURL, api, localeCode, id, forceDisable])
useHotkey({ cmdCtrlKey: true, editDepth, keyCodes: ['s'] }, (e) => { useHotkey({ cmdCtrlKey: true, editDepth, keyCodes: ['s'] }, (e) => {
e.preventDefault() e.preventDefault()
@@ -140,7 +139,8 @@ export const PublishButton: React.FC<{ label?: string }> = ({ label: labelProp }
? locale.label ? locale.label
: locale.label && locale.label[i18n?.language] : locale.label && locale.label[i18n?.language]
const isActive = typeof locale === 'string' ? locale === code : locale.code === code const isActive =
typeof locale === 'string' ? locale === localeCode : locale.code === localeCode
if (isActive) { if (isActive) {
return ( return (

View File

@@ -27,11 +27,11 @@ import { useTranslation } from '../../providers/Translation/index.js'
import { mergeFieldStyles } from '../mergeFieldStyles.js' import { mergeFieldStyles } from '../mergeFieldStyles.js'
import { fieldBaseClass } from '../shared/index.js' import { fieldBaseClass } from '../shared/index.js'
import { createRelationMap } from './createRelationMap.js' import { createRelationMap } from './createRelationMap.js'
import './index.scss'
import { findOptionsByValue } from './findOptionsByValue.js' import { findOptionsByValue } from './findOptionsByValue.js'
import { optionsReducer } from './optionsReducer.js' import { optionsReducer } from './optionsReducer.js'
import { MultiValueLabel } from './select-components/MultiValueLabel/index.js' import { MultiValueLabel } from './select-components/MultiValueLabel/index.js'
import { SingleValue } from './select-components/SingleValue/index.js' import { SingleValue } from './select-components/SingleValue/index.js'
import './index.scss'
const maxResultsPerRequest = 10 const maxResultsPerRequest = 10
@@ -310,7 +310,6 @@ const RelationshipFieldComponent: RelationshipFieldClientComponent = (props) =>
// /////////////////////////////////// // ///////////////////////////////////
// Ensure we have an option for each value // Ensure we have an option for each value
// /////////////////////////////////// // ///////////////////////////////////
useIgnoredEffect( useIgnoredEffect(
() => { () => {
const relationMap = createRelationMap({ const relationMap = createRelationMap({

View File

@@ -485,6 +485,7 @@ export const Form: React.FC<FormProps> = (props) => {
docPermissions, docPermissions,
docPreferences, docPreferences,
globalSlug, globalSlug,
locale,
operation, operation,
renderAllFields: true, renderAllFields: true,
schemaPath: collectionSlug ? collectionSlug : globalSlug, schemaPath: collectionSlug ? collectionSlug : globalSlug,
@@ -504,6 +505,7 @@ export const Form: React.FC<FormProps> = (props) => {
getFormState, getFormState,
docPermissions, docPermissions,
getDocPreferences, getDocPreferences,
locale,
], ],
) )

View File

@@ -21,73 +21,64 @@ export const LocaleProvider: React.FC<{ children?: React.ReactNode }> = ({ child
const defaultLocale = const defaultLocale =
localization && localization.defaultLocale ? localization.defaultLocale : 'en' localization && localization.defaultLocale ? localization.defaultLocale : 'en'
const { getPreference, setPreference } = usePreferences()
const searchParams = useSearchParams() const searchParams = useSearchParams()
const localeFromParams = searchParams.get('locale') const localeFromParams = searchParams.get('locale')
const [localeCode, setLocaleCode] = useState<string>(localeFromParams || defaultLocale) const [localeCode, setLocaleCode] = useState<string>(defaultLocale)
const [locale, setLocale] = useState<Locale | null>( const locale: Locale = React.useMemo(() => {
localization && findLocaleFromCode(localization, localeCode), if (!localization) {
) // TODO: return null V4
return {} as Locale
}
const { getPreference, setPreference } = usePreferences() return (
findLocaleFromCode(localization, localeFromParams || localeCode) ||
const switchLocale = React.useCallback( findLocaleFromCode(localization, defaultLocale)
async (newLocale: string) => { )
if (!localization) { }, [localeCode, localeFromParams, localization, defaultLocale])
return
}
const localeToSet =
localization.localeCodes.indexOf(newLocale) > -1 ? newLocale : defaultLocale
if (localeToSet !== localeCode) {
setLocaleCode(localeToSet)
setLocale(findLocaleFromCode(localization, localeToSet))
try {
if (user) {
await setPreference('locale', localeToSet)
}
} catch (error) {
// swallow error
}
}
},
[localization, setPreference, user, defaultLocale, localeCode],
)
useEffect(() => { useEffect(() => {
async function setInitialLocale() { async function setInitialLocale() {
let localeToSet = defaultLocale if (localization && user) {
if (typeof localeFromParams !== 'string') {
if (typeof localeFromParams === 'string') { try {
localeToSet = localeFromParams const localeToSet = await getPreference<string>('locale')
} else if (user) { setLocaleCode(localeToSet)
try { } catch (_) {
localeToSet = await getPreference<string>('locale') setLocaleCode(defaultLocale)
} catch (error) { }
// swallow error } else {
void setPreference(
'locale',
findLocaleFromCode(localization, localeFromParams)?.code || defaultLocale,
)
} }
} }
await switchLocale(localeToSet)
} }
void setInitialLocale() void setInitialLocale()
}, [ }, [defaultLocale, getPreference, localization, localeFromParams, setPreference, user])
defaultLocale,
getPreference,
localization,
localeFromParams,
setPreference,
user,
switchLocale,
])
return <LocaleContext.Provider value={locale}>{children}</LocaleContext.Provider> return <LocaleContext.Provider value={locale}>{children}</LocaleContext.Provider>
} }
/** /**
* A hook that returns the current locale object. * @deprecated A hook that returns the current locale object.
*
* ---
*
* #### 🚨 V4 Breaking Change
* The `useLocale` return type now reflects `null | Locale` instead of `false | Locale`.
*
* **Old (V3):**
* ```ts
* const { code } = useLocale();
* ```
* **New (V4):**
* ```ts
* const locale = useLocale();
* ```
*/ */
export const useLocale = (): Locale => useContext(LocaleContext) export const useLocale = (): Locale => useContext(LocaleContext)

View File

@@ -4,8 +4,6 @@ import { useSearchParams as useNextSearchParams } from 'next/navigation.js'
import * as qs from 'qs-esm' import * as qs from 'qs-esm'
import React, { createContext, useContext } from 'react' import React, { createContext, useContext } from 'react'
import { parseSearchParams } from '../../utilities/parseSearchParams.js'
export type SearchParamsContext = { export type SearchParamsContext = {
searchParams: qs.ParsedQs searchParams: qs.ParsedQs
stringifyParams: ({ params, replace }: { params: qs.ParsedQs; replace?: boolean }) => string stringifyParams: ({ params, replace }: { params: qs.ParsedQs; replace?: boolean }) => string
@@ -28,8 +26,16 @@ const Context = createContext(initialContext)
*/ */
export const SearchParamsProvider: React.FC<{ children?: React.ReactNode }> = ({ children }) => { export const SearchParamsProvider: React.FC<{ children?: React.ReactNode }> = ({ children }) => {
const nextSearchParams = useNextSearchParams() const nextSearchParams = useNextSearchParams()
const searchString = nextSearchParams.toString()
const [searchParams, setSearchParams] = React.useState(() => parseSearchParams(nextSearchParams)) const searchParams = React.useMemo(
() =>
qs.parse(searchString, {
depth: 10,
ignoreQueryPrefix: true,
}),
[searchString],
)
const stringifyParams = React.useCallback( const stringifyParams = React.useCallback(
({ params, replace = false }: { params: qs.ParsedQs; replace?: boolean }) => { ({ params, replace = false }: { params: qs.ParsedQs; replace?: boolean }) => {
@@ -44,10 +50,6 @@ export const SearchParamsProvider: React.FC<{ children?: React.ReactNode }> = ({
[searchParams], [searchParams],
) )
React.useEffect(() => {
setSearchParams(parseSearchParams(nextSearchParams))
}, [nextSearchParams])
return <Context.Provider value={{ searchParams, stringifyParams }}>{children}</Context.Provider> return <Context.Provider value={{ searchParams, stringifyParams }}>{children}</Context.Provider>
} }

View File

@@ -112,7 +112,7 @@ export const generateReleaseNotes = async (args: Args = {}): Promise<ChangelogRe
return sections return sections
}, },
{} as Record<Sections | 'breaking', GitCommit[]>, {} as Record<'breaking' | Sections, GitCommit[]>,
) )
// Sort commits by scope, unscoped first // Sort commits by scope, unscoped first