fix(i18n): survive Safari's 7-day cookie cap on the chosen locale

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.
This commit is contained in:
Marc-André Bombeck committed 2026-07-05 21:01:50 +02:00
1 parent ef82545870
commit e9692f0b81
6 files changed
+233 -20

No files matched your search

+12
View File
@@ -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 });
});
+2 -20
View File
@@ -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<Locale> {
// 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"],
+46
View File
@@ -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<string | null> {
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<void> {
const cookieStore = await cookies();
const sessionId = cookieStore.get(SESSION_COOKIE)?.value;
@@ -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");
});
});
+36
View File
@@ -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<ReturnType<typeof cookies>>;
/**
* 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: "/",
});
}
+47
View File
@@ -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<Locale> {
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";
}
}