diff --git a/frontend/src/core/i18n/cookies.ts b/frontend/src/core/i18n/cookies.ts index 255163dcc2..80e896bb80 100644 --- a/frontend/src/core/i18n/cookies.ts +++ b/frontend/src/core/i18n/cookies.ts @@ -15,9 +15,21 @@ export function getLocaleFromCookie(): string | null { const cookies = document.cookie.split(";"); for (const cookie of cookies) { - const [name, value] = cookie.trim().split("="); - if (name === LOCALE_COOKIE_NAME) { - return decodeURIComponent(value ?? ""); + // Split on the first "=" only: a cookie value may contain "=" itself. + const separator = cookie.indexOf("="); + if (separator < 0) { + continue; + } + if (cookie.slice(0, separator).trim() !== LOCALE_COOKIE_NAME) { + continue; + } + const value = cookie.slice(separator + 1).trim(); + try { + return decodeURIComponent(value); + } catch { + // A malformed value must not crash locale bootstrap; hand the raw + // value to normalizeLocale, which falls back to the default locale. + return value; } } return null; diff --git a/frontend/src/core/utils/datetime.ts b/frontend/src/core/utils/datetime.ts index 030065d99b..e8d1a4bcc8 100644 --- a/frontend/src/core/utils/datetime.ts +++ b/frontend/src/core/utils/datetime.ts @@ -1,7 +1,7 @@ import { formatDistanceToNow } from "date-fns"; import { enUS as dateFnsEnUS, zhCN as dateFnsZhCN } from "date-fns/locale"; -import { detectLocale, type Locale } from "@/core/i18n"; +import { detectLocale, normalizeLocale, type Locale } from "@/core/i18n"; import { getLocaleFromCookie } from "@/core/i18n/cookies"; function getDateFnsLocale(locale: Locale) { @@ -15,9 +15,9 @@ function getDateFnsLocale(locale: Locale) { } export function formatTimeAgo(date: Date | string | number, locale?: Locale) { + const savedLocale = locale ?? getLocaleFromCookie(); const effectiveLocale = - locale ?? - (getLocaleFromCookie() as Locale | null) ?? + (savedLocale ? normalizeLocale(savedLocale) : null) ?? // Fallback when cookie is missing (or on first render) detectLocale(); // Guard against invalid/empty timestamps (e.g. a backend returning "" for diff --git a/frontend/tests/unit/core/i18n/cookies.dom.test.tsx b/frontend/tests/unit/core/i18n/cookies.dom.test.tsx new file mode 100644 index 0000000000..3bfb99f075 --- /dev/null +++ b/frontend/tests/unit/core/i18n/cookies.dom.test.tsx @@ -0,0 +1,26 @@ +import { afterEach, expect, test } from "@rstest/core"; + +import { getLocaleFromCookie } from "@/core/i18n/cookies"; + +function setLocaleCookie(value: string) { + document.cookie = `locale=${value}; path=/`; +} + +afterEach(() => { + document.cookie = "locale=; max-age=0; path=/"; +}); + +test("decodes an encoded locale", () => { + setLocaleCookie("zh-CN"); + expect(getLocaleFromCookie()).toBe("zh-CN"); +}); + +test("does not throw on a malformed percent-encoding", () => { + setLocaleCookie("%E0%A4%A"); + expect(getLocaleFromCookie()).toBe("%E0%A4%A"); +}); + +test("keeps a cookie value that contains '='", () => { + setLocaleCookie("en=US"); + expect(getLocaleFromCookie()).toBe("en=US"); +}); diff --git a/frontend/tests/unit/core/utils/datetime.dom.test.ts b/frontend/tests/unit/core/utils/datetime.dom.test.ts new file mode 100644 index 0000000000..c66049cd3a --- /dev/null +++ b/frontend/tests/unit/core/utils/datetime.dom.test.ts @@ -0,0 +1,17 @@ +import { afterEach, expect, test } from "@rstest/core"; + +import { formatTimeAgo } from "@/core/utils/datetime"; + +afterEach(() => { + document.cookie = "locale=; max-age=0; path=/"; +}); + +test("falls back to a known locale for a malformed locale cookie", () => { + document.cookie = "locale=zh%FF; path=/"; + expect(() => formatTimeAgo(Date.now() - 5_000)).not.toThrow(); +}); + +test("uses the locale from the cookie", () => { + document.cookie = "locale=zh-CN; path=/"; + expect(formatTimeAgo(Date.now() - 5_000)).toContain("前"); +});