From 87033df50581f6b3d36038b681a716ce10198e72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89mile=20R=C3=A9?= Date: Tue, 21 Jul 2026 16:49:52 +0200 Subject: [PATCH] Smooth locale switches with a React transition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Schedule persist and navigate together, and defer the mismatch banner so a one-frame URL/identity desync does not flash during language changes from the menu. Signed-off-by: Émile Ré --- .../src/components/LocaleMismatchCallout.tsx | 8 ++++-- .../src/lib/i18n/useChangeLocale.ts | 28 +++++++++---------- .../src/lib/i18n/useUpdateLocale.ts | 5 ---- 3 files changed, 19 insertions(+), 22 deletions(-) diff --git a/apps/compliance-portal/src/components/LocaleMismatchCallout.tsx b/apps/compliance-portal/src/components/LocaleMismatchCallout.tsx index fea3048d9..adeb5037e 100644 --- a/apps/compliance-portal/src/components/LocaleMismatchCallout.tsx +++ b/apps/compliance-portal/src/components/LocaleMismatchCallout.tsx @@ -22,7 +22,7 @@ import { GlobeIcon, XIcon } from "@phosphor-icons/react"; import { Button } from "@probo/ui/src/v2/Button/Button"; import { IconButton } from "@probo/ui/src/v2/IconButton/IconButton"; import { Text } from "@probo/ui/src/v2/typography/Text"; -import { useEffect, useState } from "react"; +import { useDeferredValue, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { graphql, useFragment } from "react-relay"; @@ -63,7 +63,11 @@ export function LocaleMismatchCallout({ identityKey }: LocaleMismatchCalloutProp const savedLocale = isUrlLocale(identity.locale) ? identity.locale : null; const savedLanguage = savedLocale != null ? urlLocaleToLanguage(savedLocale) : null; - const visible = !dismissed && savedLocale != null && savedLocale !== urlLocale; + const mismatched = savedLocale != null && savedLocale !== urlLocale; + // Lag the mismatch flag so a transient desync during startTransition locale + // switches never paints the banner; a real mismatch still shows once settled. + const deferredMismatched = useDeferredValue(mismatched); + const visible = !dismissed && mismatched && deferredMismatched; useEffect(() => { if (!visible || savedLanguage == null) { diff --git a/apps/compliance-portal/src/lib/i18n/useChangeLocale.ts b/apps/compliance-portal/src/lib/i18n/useChangeLocale.ts index 156243091..498e6ae7f 100644 --- a/apps/compliance-portal/src/lib/i18n/useChangeLocale.ts +++ b/apps/compliance-portal/src/lib/i18n/useChangeLocale.ts @@ -18,7 +18,7 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. -import { useCallback } from "react"; +import { startTransition, useCallback } from "react"; import { useLocation, useNavigate } from "react-router"; import { @@ -40,23 +40,21 @@ export function useChangeLocale() { const navigate = useNavigate(); const [updateLocale, isUpdating] = useUpdateLocale(); - const changeLocale = useCallback(async ( + const changeLocale = useCallback(( locale: UrlLocale, options: ChangeLocaleOptions = {}, ) => { - // Persist and navigate without sequencing them: awaiting the mutation - // before navigation left a frame where Identity.locale already matched - // the new choice but the URL still had the old prefix, flashing the - // mismatch callout. updateLocale writes the store optimistically, and - // flushSync applies the URL change in the same paint. - if (options.persist) { - void updateLocale(locale); - } - if (locale !== currentLocale) { - void navigate(replaceLocaleInPathname(pathname, locale) + search, { - flushSync: true, - }); - } + // Persist + navigate as one transition so React can keep the previous UI + // until both have settled. Await-then-navigate painted a one-frame + // Identity↔URL desync that flashed the mismatch callout. + startTransition(() => { + if (options.persist) { + void updateLocale(locale); + } + if (locale !== currentLocale) { + void navigate(replaceLocaleInPathname(pathname, locale) + search); + } + }); }, [currentLocale, navigate, pathname, search, updateLocale]); return [changeLocale, isUpdating] as const; diff --git a/apps/compliance-portal/src/lib/i18n/useUpdateLocale.ts b/apps/compliance-portal/src/lib/i18n/useUpdateLocale.ts index 560884cd8..6856bbe06 100644 --- a/apps/compliance-portal/src/lib/i18n/useUpdateLocale.ts +++ b/apps/compliance-portal/src/lib/i18n/useUpdateLocale.ts @@ -53,11 +53,6 @@ export function useUpdateLocale() { await commit( { variables: { input: { locale } }, - // Keep the Relay store in sync with the URL during locale switches so - // the mismatch callout never paints a one-frame desync. - optimisticUpdater: (store) => { - store.getRoot().getLinkedRecord("viewer")?.setValue(locale, "locale"); - }, }, feedback, );