Smooth locale switches with a React transition

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é <emile@probo.com>
This commit is contained in:
Émile Ré
2026-07-21 16:49:52 +02:00
parent 03252b4883
commit 87033df505
3 changed files with 19 additions and 22 deletions

View File

@@ -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) {

View File

@@ -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;

View File

@@ -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,
);