From e9692f0b818be73d5d50983be5df57fb14a2fa63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 5 Jul 2026 21:01:50 +0200 Subject: [PATCH] fix(i18n): survive Safari's 7-day cookie cap on the chosen locale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first-paint ladder consulted only the healthlog-locale cookie and Accept-Language — never User.locale. The cookie is written client-side via document.cookie, which Safari ITP caps at 7 days, so a chosen language silently reverted to the browser language once a week. Three seams close it: - resolveInitialLocale (extracted to src/lib/i18n/resolve-initial-locale.ts) now reads User.locale on a cookie miss via a lightweight session read (no sliding-expiry write, no cookie mutation, fail-soft) before the Accept-Language fallback. - PUT /api/auth/me/locale mirrors the accepted locale into the cookie via Set-Cookie (365 days, SameSite=Lax, not HttpOnly, Secure via shouldEmitSecureCookie) — server-set cookies are exempt from the ITP cap, and the mount-time backfill hitting the route doubles as the weekly refresh. - getSession re-emits the cookie when the user has a persisted locale but the cookie is absent (absent-only, so a mid-switch cookie is never reverted before the PUT lands). Users who never chose a language stay on Accept-Language. --- src/app/api/auth/me/locale/route.ts | 12 +++ src/app/layout.tsx | 22 +---- src/lib/auth/session.ts | 46 ++++++++++ .../__tests__/resolve-initial-locale.test.ts | 90 +++++++++++++++++++ src/lib/i18n/locale-cookie.ts | 36 ++++++++ src/lib/i18n/resolve-initial-locale.ts | 47 ++++++++++ 6 files changed, 233 insertions(+), 20 deletions(-) create mode 100644 src/lib/i18n/__tests__/resolve-initial-locale.test.ts create mode 100644 src/lib/i18n/locale-cookie.ts create mode 100644 src/lib/i18n/resolve-initial-locale.ts diff --git a/src/app/api/auth/me/locale/route.ts b/src/app/api/auth/me/locale/route.ts index 06681f44a..2b793be89 100644 --- a/src/app/api/auth/me/locale/route.ts +++ b/src/app/api/auth/me/locale/route.ts @@ -20,11 +20,13 @@ * holds a value the resolver would later have to defend against. */ import { NextRequest } from "next/server"; +import { cookies } from "next/headers"; import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiError, apiSuccess, safeJson } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { locales, type Locale } from "@/lib/i18n/config"; +import { setLocaleCookie } from "@/lib/i18n/locale-cookie"; export const dynamic = "force-dynamic"; @@ -58,5 +60,15 @@ export const PUT = apiHandler(async (request: NextRequest) => { annotate({ meta: { locale_next: locale } }); } + // Mirror the accepted locale into the `healthlog-locale` cookie via + // Set-Cookie. The client's own `document.cookie` write is capped at 7 + // days by Safari ITP; the server-set copy is not, so an explicit choice + // survives on every device. Runs on the idempotent path too — the + // mount-time backfill hitting this route is exactly the weekly refresh + // that keeps the cookie alive. Harmless no-op payload for Bearer + // callers (native clients ignore cookies). + const cookieStore = await cookies(); + setLocaleCookie(cookieStore, locale); + return apiSuccess({ locale }); }); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 1322ebb68..0643c3cba 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,32 +1,14 @@ import type { Metadata, Viewport } from "next"; import { Inter } from "next/font/google"; -import { headers, cookies } from "next/headers"; +import { headers } from "next/headers"; import "./globals.css"; import { Providers } from "@/components/providers"; import { AuthShell } from "@/components/layout/auth-shell"; import { MonitoringBootstrap } from "@/components/monitoring/bootstrap"; import { WebVitalsReporter } from "@/components/monitoring/web-vitals-reporter"; -import { parseLocaleFromAcceptLanguage } from "@/lib/format-locale"; -import { locales, type Locale } from "@/lib/i18n/config"; +import { resolveInitialLocale } from "@/lib/i18n/resolve-initial-locale"; import { allMessages } from "@/lib/i18n/shared-resolve"; -async function resolveInitialLocale(): Promise { - // Both cookies() and headers() can throw (DynamicServerError, etc.) — - // fall back to the default so a locale hiccup never crashes the root - // layout into global-error.tsx. - try { - const cookieStore = await cookies(); - const cookieLocale = cookieStore.get("healthlog-locale")?.value; - if (cookieLocale && (locales as readonly string[]).includes(cookieLocale)) { - return cookieLocale as Locale; - } - const headerList = await headers(); - return parseLocaleFromAcceptLanguage(headerList.get("accept-language")); - } catch { - return "en"; - } -} - const inter = Inter({ variable: "--font-sans", subsets: ["latin"], diff --git a/src/lib/auth/session.ts b/src/lib/auth/session.ts index 4e82e949a..bcddcc777 100644 --- a/src/lib/auth/session.ts +++ b/src/lib/auth/session.ts @@ -5,6 +5,8 @@ import { ensureDbCompatibility } from "@/lib/db-compat"; import { getEvent } from "@/lib/logging/context"; import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie"; import { isP2025 } from "@/lib/prisma-errors"; +import { locales, type Locale } from "@/lib/i18n/config"; +import { LOCALE_COOKIE, setLocaleCookie } from "@/lib/i18n/locale-cookie"; const SESSION_COOKIE = "healthlog_session"; const SESSION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days @@ -170,12 +172,56 @@ export async function getSession(): Promise<{ .catch(() => {}); } + // Locale-cookie refresh: Safari's ITP expires the script-written + // `healthlog-locale` cookie after 7 days, so a returning user's first + // paint fell back to the browser language once a week. When the user + // has a persisted locale but the cookie is gone, re-emit it here — + // Set-Cookie is exempt from the ITP cap. Absent-only on purpose: a + // locale switch writes the cookie client-side before the PUT persists + // the column, and overwriting a *differing* cookie here would revert + // that fresh choice mid-flight. Fail-soft: in a server-component + // render the cookie store is read-only and `set` throws — the next + // route-handler request re-attempts. + if ( + session.user.locale && + (locales as readonly string[]).includes(session.user.locale) && + !cookieStore.get(LOCALE_COOKIE) + ) { + try { + setLocaleCookie(cookieStore, session.user.locale as Locale); + } catch { + // Read-only cookie context — skip; nothing depends on this write. + } + } + return { session: { id: session.id, expiresAt: session.expiresAt }, user: session.user, }; } +/** + * Lightweight read of the signed-in user's persisted locale for the root + * layout's first-paint language resolution. Deliberately NOT + * `getSession()`: no db-compat check, no sliding-expiry write, no cookie + * mutation — the root layout renders on every request in a context where + * the cookie store is read-only, so the full session touch is both dead + * weight and a throw hazard there. Returns null for missing/expired + * sessions and users without a persisted locale. + */ +export async function getSessionUserLocale(): Promise { + const cookieStore = await cookies(); + const sessionId = cookieStore.get(SESSION_COOKIE)?.value; + if (!sessionId) return null; + + const session = await prisma.session.findUnique({ + where: { id: sessionId }, + select: { expiresAt: true, user: { select: { locale: true } } }, + }); + if (!session || session.expiresAt < new Date()) return null; + return session.user.locale; +} + export async function destroySession(): Promise { const cookieStore = await cookies(); const sessionId = cookieStore.get(SESSION_COOKIE)?.value; diff --git a/src/lib/i18n/__tests__/resolve-initial-locale.test.ts b/src/lib/i18n/__tests__/resolve-initial-locale.test.ts new file mode 100644 index 000000000..91cd2cc50 --- /dev/null +++ b/src/lib/i18n/__tests__/resolve-initial-locale.test.ts @@ -0,0 +1,90 @@ +/** + * First-paint locale ladder (root layout): cookie → User.locale → + * Accept-Language → "en" hard floor. + * + * The User.locale step is the Safari-ITP fix: the script-written + * `healthlog-locale` cookie expires after 7 days there, and before this + * ladder existed the first paint fell back to the browser language once + * a week even though the user had explicitly chosen one. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const mockCookies = vi.fn(); +const mockHeaders = vi.fn(); +vi.mock("next/headers", () => ({ + cookies: (...args: unknown[]) => mockCookies(...args), + headers: (...args: unknown[]) => mockHeaders(...args), +})); + +const mockGetSessionUserLocale = vi.fn(); +vi.mock("@/lib/auth/session", () => ({ + getSessionUserLocale: (...args: unknown[]) => + mockGetSessionUserLocale(...args), +})); + +import { resolveInitialLocale } from "@/lib/i18n/resolve-initial-locale"; + +function cookieStoreWith(value: string | undefined) { + return { + get: (name: string) => + name === "healthlog-locale" && value !== undefined + ? { name, value } + : undefined, + }; +} + +function headersWith(acceptLanguage: string | null) { + return { + get: (name: string) => + name.toLowerCase() === "accept-language" ? acceptLanguage : null, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + mockCookies.mockResolvedValue(cookieStoreWith(undefined)); + mockHeaders.mockResolvedValue(headersWith(null)); + mockGetSessionUserLocale.mockResolvedValue(null); +}); + +describe("resolveInitialLocale", () => { + it("returns the cookie locale without consulting the session", async () => { + mockCookies.mockResolvedValue(cookieStoreWith("de")); + await expect(resolveInitialLocale()).resolves.toBe("de"); + expect(mockGetSessionUserLocale).not.toHaveBeenCalled(); + }); + + it("ignores an unsupported cookie value and walks the ladder", async () => { + mockCookies.mockResolvedValue(cookieStoreWith("tlh")); + mockGetSessionUserLocale.mockResolvedValue("fr"); + await expect(resolveInitialLocale()).resolves.toBe("fr"); + }); + + it("falls back to User.locale on a cookie miss (the ITP case)", async () => { + mockGetSessionUserLocale.mockResolvedValue("pl"); + mockHeaders.mockResolvedValue(headersWith("en-US,en;q=0.9")); + await expect(resolveInitialLocale()).resolves.toBe("pl"); + }); + + it("ignores an unsupported User.locale value", async () => { + mockGetSessionUserLocale.mockResolvedValue("tlh"); + mockHeaders.mockResolvedValue(headersWith("it-IT,it;q=0.9")); + await expect(resolveInitialLocale()).resolves.toBe("it"); + }); + + it("uses Accept-Language when there is no cookie and no session", async () => { + mockHeaders.mockResolvedValue(headersWith("es-ES,es;q=0.9,en;q=0.5")); + await expect(resolveInitialLocale()).resolves.toBe("es"); + }); + + it("survives a session-read failure and still honours Accept-Language", async () => { + mockGetSessionUserLocale.mockRejectedValue(new Error("db down")); + mockHeaders.mockResolvedValue(headersWith("de-DE,de;q=0.9")); + await expect(resolveInitialLocale()).resolves.toBe("de"); + }); + + it("hard-floors to en when cookies() itself throws", async () => { + mockCookies.mockRejectedValue(new Error("DynamicServerError")); + await expect(resolveInitialLocale()).resolves.toBe("en"); + }); +}); diff --git a/src/lib/i18n/locale-cookie.ts b/src/lib/i18n/locale-cookie.ts new file mode 100644 index 000000000..3925bc666 --- /dev/null +++ b/src/lib/i18n/locale-cookie.ts @@ -0,0 +1,36 @@ +import type { cookies } from "next/headers"; +import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie"; +import type { Locale } from "@/lib/i18n/config"; + +/** + * The SSR handoff cookie for the active UI locale. The client-side + * switcher writes it via `document.cookie` (src/lib/i18n/context.tsx), + * but Safari's ITP caps script-written cookies at 7 days — a user who + * doesn't open the app for a week silently falls back to the browser + * language on first paint. Server-side `Set-Cookie` writes are exempt + * from that cap, so every server seam that knows the persisted locale + * re-emits the cookie through this one helper. + */ +export const LOCALE_COOKIE = "healthlog-locale"; + +// One year — mirrors the client-side `persistLocale` write. +const LOCALE_COOKIE_MAX_AGE_S = 60 * 60 * 24 * 365; + +type CookieStore = Awaited>; + +/** + * Emit the locale cookie server-side. Throws when the cookie store is + * read-only (server-component render) — callers on that path catch and + * skip; the next route-handler request re-attempts. + */ +export function setLocaleCookie(cookieStore: CookieStore, locale: Locale) { + cookieStore.set(LOCALE_COOKIE, locale, { + // NOT HttpOnly — the client reads it (format helpers, hydration + // handoff in src/lib/format.ts and i18n/context.tsx). + httpOnly: false, + secure: shouldEmitSecureCookie(), + sameSite: "lax", + maxAge: LOCALE_COOKIE_MAX_AGE_S, + path: "/", + }); +} diff --git a/src/lib/i18n/resolve-initial-locale.ts b/src/lib/i18n/resolve-initial-locale.ts new file mode 100644 index 000000000..5407735db --- /dev/null +++ b/src/lib/i18n/resolve-initial-locale.ts @@ -0,0 +1,47 @@ +import { headers, cookies } from "next/headers"; +import { parseLocaleFromAcceptLanguage } from "@/lib/format-locale"; +import { locales, type Locale } from "@/lib/i18n/config"; +import { LOCALE_COOKIE } from "@/lib/i18n/locale-cookie"; +import { getSessionUserLocale } from "@/lib/auth/session"; + +/** + * First-paint locale ladder for the root layout: + * + * 1. `healthlog-locale` cookie — the SSR handoff the client writes on + * every switch and the server re-emits on session touch. + * 2. `User.locale` — the persisted preference. Consulted only on a + * cookie miss (Safari ITP expires the script-written cookie after + * 7 days; without this step the app fell back to the browser + * language once a week even though the user had chosen one). + * 3. Accept-Language — users who never chose stay on the browser + * language ("automatic"). + * + * Fail-soft throughout: cookies()/headers() can throw + * (DynamicServerError, …) and the session read can hit a DB hiccup — a + * locale resolution problem must never crash the root layout into + * global-error.tsx. + */ +export async function resolveInitialLocale(): Promise { + try { + const cookieStore = await cookies(); + const cookieLocale = cookieStore.get(LOCALE_COOKIE)?.value; + if (cookieLocale && (locales as readonly string[]).includes(cookieLocale)) { + return cookieLocale as Locale; + } + + let userLocale: string | null = null; + try { + userLocale = await getSessionUserLocale(); + } catch { + // DB hiccup — fall through to Accept-Language rather than "en". + } + if (userLocale && (locales as readonly string[]).includes(userLocale)) { + return userLocale as Locale; + } + + const headerList = await headers(); + return parseLocaleFromAcceptLanguage(headerList.get("accept-language")); + } catch { + return "en"; + } +}