Describe the Bug
Switching the content locale in the admin header discards unsaved form changes without any warning. The LeaveWithoutSaving guard that protects every other navigation (sidebar, breadcrumbs, browser back/close) does not fire for the locale switcher.
Cause: usePreventLeave guards beforeunload and clicks on <a> elements only (findClosestAnchor,
|
function findClosestAnchor(element: HTMLElement | null): HTMLAnchorElement | null { |
|
while (element && element.tagName.toLowerCase() !== 'a') { |
|
element = element.parentElement |
|
} |
|
return element as HTMLAnchorElement |
|
} |
|
|
|
function handleClick(event: MouseEvent) { |
|
try { |
|
const target = event.target as HTMLElement |
|
const anchor = findClosestAnchor(target) |
|
if (anchor) { |
|
const currentUrl = window.location.href |
|
const newUrl = anchor.href |
|
const isAnchor = isAnchorOfCurrentUrl(currentUrl, newUrl) |
|
const isDownloadLink = anchor.download !== '' |
|
const isNewTab = anchor.target === '_blank' || event.metaKey || event.ctrlKey |
|
|
|
const isPageLeaving = !(newUrl === currentUrl || isAnchor || isDownloadLink || isNewTab) |
|
|
|
if (isPageLeaving && prevent && (!onPrevent ? !window.confirm(message) : true)) { |
|
// Keep a reference of the href |
|
cancelledURL.current = newUrl |
|
|
|
// Cancel the route change |
|
event.preventDefault() |
|
event.stopPropagation() |
|
|
|
if (typeof onPrevent === 'function') { |
|
onPrevent() |
|
} |
|
} |
|
} |
|
} catch (err) { |
|
// eslint-disable-next-line no-console |
|
console.log('Unexpected exception thrown in LeaveWithoutSaving:usePreventLeave', err) |
|
} |
|
} |
|
|
|
if (prevent) { |
|
// Add the global click event listener |
|
document.addEventListener('click', handleClick, true) |
). The
Localizer renders
PopupList.RadioGroupItem buttons whose
onClick calls
router.push(url) directly (
|
<PopupList.RadioGroupItem |
|
active={locale.code === localeOption.code} |
|
key={localeOption.code} |
|
onClick={() => { |
|
setLocaleIsLoading(true) |
|
close() |
|
|
|
// can't use `useSearchParams` here because it is stale due to `window.history.pushState` in `ListQueryProvider` |
|
const searchParams = new URLSearchParams(window.location.search) |
|
|
|
const url = qs.stringify( |
|
{ |
|
...qs.parse(searchParams.toString(), { |
|
depth: 10, |
|
ignoreQueryPrefix: true, |
|
}), |
|
locale: localeOption.code, |
|
}, |
|
{ addQueryPrefix: true }, |
|
) |
|
|
|
startRouteTransition(() => { |
|
router.push(url) |
), so nothing intercepts the navigation.
This is a regression. #7215 (July 2024) deliberately changed the switcher to href links "so the user will be warned if they have changes and want to switch locales". #11387 (February 2025, "maintains column state in url") replaced the href with startRouteTransition(() => router.push(url)), which the guard cannot see, and the behavior has been that way since.
For editors of a bilingual site this is the most common way to lose work: every translation job involves a locale switch right after editing.
A fix could be either rendering the locale options as links again (as #7215 did) or having the switcher consult the same modified-form state the guard uses (useFormModified) and open the leave-without-saving modal before navigating.
Link to the code that reproduces this issue
|
<PopupList.RadioGroupItem |
|
active={locale.code === localeOption.code} |
|
key={localeOption.code} |
|
onClick={() => { |
|
setLocaleIsLoading(true) |
|
close() |
|
|
|
// can't use `useSearchParams` here because it is stale due to `window.history.pushState` in `ListQueryProvider` |
|
const searchParams = new URLSearchParams(window.location.search) |
|
|
|
const url = qs.stringify( |
|
{ |
|
...qs.parse(searchParams.toString(), { |
|
depth: 10, |
|
ignoreQueryPrefix: true, |
|
}), |
|
locale: localeOption.code, |
|
}, |
|
{ addQueryPrefix: true }, |
|
) |
|
|
|
startRouteTransition(() => { |
|
router.push(url) |
The bug is in the stock Localizer component and needs no project-specific config beyond localization. Any create-payload-app -t blank project with localization: { locales: ['en', 'da'], defaultLocale: 'en' } and one collection with a localized text field reproduces it.
Reproduction Steps
- Enable
localization with two locales and add a collection with a localized text field (drafts not required).
- Create a document and open it in the admin.
- Type into the text field. Do not save.
- Click the locale switcher in the header and choose the other locale.
Expected: the "Leave without saving?" confirmation, as when clicking a sidebar link or the breadcrumb with the same unsaved change.
Actual: the route changes at once, and the edit is gone. Switching back shows the stored value.
Control: with the same unsaved change, clicking the collection's breadcrumb link opens the confirmation modal.
Verified with a Playwright script against 3.90.2: after the locale switch no .confirmation-modal exists and the field holds its old value; after a breadcrumb click the modal count is 1 and the URL is unchanged.
Which area(s) are affected?
area: ui
Environment Info
Binaries:
Node: 26.5.0
npm: 11.17.0
pnpm: 12.8.1
Relevant Packages:
payload: 3.90.2
next: 16.3.7
@payloadcms/db-postgres: 3.90.2
@payloadcms/next/utilities: 3.90.2
@payloadcms/richtext-lexical: 3.90.2
@payloadcms/translations: 3.90.2
@payloadcms/ui/shared: 3.90.2
react: 19.3.0
react-dom: 19.3.0
Operating System:
Platform: linux
Arch: x64
Describe the Bug
Switching the content locale in the admin header discards unsaved form changes without any warning. The
LeaveWithoutSavingguard that protects every other navigation (sidebar, breadcrumbs, browser back/close) does not fire for the locale switcher.Cause:
usePreventLeaveguardsbeforeunloadand clicks on<a>elements only (findClosestAnchor,payload/packages/ui/src/elements/LeaveWithoutSaving/usePreventLeave.tsx
Lines 109 to 150 in 15d051b
LocalizerrendersPopupList.RadioGroupItembuttons whoseonClickcallsrouter.push(url)directly (payload/packages/ui/src/elements/Localizer/index.tsx
Lines 57 to 79 in 15d051b
This is a regression. #7215 (July 2024) deliberately changed the switcher to
hreflinks "so the user will be warned if they have changes and want to switch locales". #11387 (February 2025, "maintains column state in url") replaced thehrefwithstartRouteTransition(() => router.push(url)), which the guard cannot see, and the behavior has been that way since.For editors of a bilingual site this is the most common way to lose work: every translation job involves a locale switch right after editing.
A fix could be either rendering the locale options as links again (as #7215 did) or having the switcher consult the same modified-form state the guard uses (
useFormModified) and open theleave-without-savingmodal before navigating.Link to the code that reproduces this issue
payload/packages/ui/src/elements/Localizer/index.tsx
Lines 57 to 79 in 15d051b
The bug is in the stock
Localizercomponent and needs no project-specific config beyondlocalization. Anycreate-payload-app -t blankproject withlocalization: { locales: ['en', 'da'], defaultLocale: 'en' }and one collection with a localized text field reproduces it.Reproduction Steps
localizationwith two locales and add a collection with a localizedtextfield (drafts not required).Expected: the "Leave without saving?" confirmation, as when clicking a sidebar link or the breadcrumb with the same unsaved change.
Actual: the route changes at once, and the edit is gone. Switching back shows the stored value.
Control: with the same unsaved change, clicking the collection's breadcrumb link opens the confirmation modal.
Verified with a Playwright script against 3.90.2: after the locale switch no
.confirmation-modalexists and the field holds its old value; after a breadcrumb click the modal count is 1 and the URL is unchanged.Which area(s) are affected?
area: ui
Environment Info