diff --git a/packages/app/src/renderer/features/settings/SettingsPage.test.tsx b/packages/app/src/renderer/features/settings/SettingsPage.test.tsx
index 93cd4d52e5..c62f531d41 100644
--- a/packages/app/src/renderer/features/settings/SettingsPage.test.tsx
+++ b/packages/app/src/renderer/features/settings/SettingsPage.test.tsx
@@ -66,6 +66,7 @@ vi.mock('../../shell/theme-snapshot', () => ({
currentThemeSnapshot: () => ({ snapshot: true }),
}));
+import { toastQueue } from '../../../ui';
import { openSettingsSection } from './sections';
import { SettingsPage } from './SettingsPage';
@@ -269,6 +270,23 @@ describe('General settings', () => {
expect(mocks.showToast).toHaveBeenCalledTimes(1);
});
+ it('replaces the language toast instead of stacking another', async () => {
+ const close = vi.spyOn(toastQueue, 'close');
+ mocks.showToast.mockReturnValueOnce('de-toast').mockReturnValueOnce('en-toast');
+ render();
+ pickLanguage();
+ await act(async () => {});
+ act(() => mocks.i18n.switchTo('de'));
+ fireEvent.click(screen.getByRole('button', { name: /^(?!reset).*locale\.title/ }));
+ fireEvent.click(screen.getByRole('option', { name: 'English' }));
+ await act(async () => {});
+ act(() => mocks.i18n.switchTo('en'));
+ expect(mocks.showToast).toHaveBeenCalledTimes(2);
+ expect(close).toHaveBeenCalledWith('de-toast');
+ expect(close).not.toHaveBeenCalledWith('en-toast');
+ close.mockRestore();
+ });
+
it('does not offer the relaunch when main rejects the language', async () => {
mocks.settingsApi.SetSetting.mockRejectedValue(new Error('nope'));
render();
diff --git a/packages/app/src/renderer/features/settings/SettingsPage.tsx b/packages/app/src/renderer/features/settings/SettingsPage.tsx
index aaba0725f7..5b294a5a49 100644
--- a/packages/app/src/renderer/features/settings/SettingsPage.tsx
+++ b/packages/app/src/renderer/features/settings/SettingsPage.tsx
@@ -26,6 +26,7 @@ import {
showToast,
SideNav,
TextField,
+ toastQueue,
type IconName,
type SelectOption,
} from '../../../ui';
@@ -227,6 +228,8 @@ function languageName(code: string): string {
/** The listener of the last language change that is waiting for its toast, so a choice that switches nothing can't leave one behind. */
let stopWaitingForLanguage: (() => void) | undefined;
+/** The language toast showing, so the next language change replaces it instead of stacking another. */
+let languageToast: string | undefined;
function GeneralSection() {
const { t, i18n } = useTranslation('settings');
@@ -254,7 +257,8 @@ function GeneralSection() {
stop();
// The editor's own strings and Chromium's follow after a relaunch.
const tNew = i18n.getFixedT(language, 'settings');
- showToast({
+ if (languageToast) toastQueue.close(languageToast);
+ languageToast = showToast({
title: tNew('locale.relaunchTitle'),
description: tNew('locale.relaunchDescription'),
actionLabel: tNew('locale.relaunch'),