Skip to content

Locale switcher bypasses LeaveWithoutSaving: unsaved changes are discarded on a locale switch (regression of #7215 by #11387) #18476

Description

@RasmusBetterDevelopers

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

  1. Enable localization with two locales and add a collection with a localized text field (drafts not required).
  2. Create a document and open it in the admin.
  3. Type into the text field. Do not save.
  4. 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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Bugarea: uiRelated to the admin panel.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions