diff --git a/docs/adr/0069-member-locale-preference.md b/docs/adr/0069-member-locale-preference.md index 1e25a7f0a..61f6a424d 100644 --- a/docs/adr/0069-member-locale-preference.md +++ b/docs/adr/0069-member-locale-preference.md @@ -10,7 +10,10 @@ with the product's five-value constraint: `en`, `ko`, `zh`, `ja`, or `vi`. The GNB selector updates both the local display and the authenticated member preference through `/api/me/preferences`. On login, the server preference wins over browser detection; browser storage remains only the unauthenticated or -offline fallback. +offline fallback. If the member changes the GNB selector while the initial +`/api/me` preference request is still pending, that current interaction wins +over the late server response and the server update remains the authoritative +next-login value. ## Consequences diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 4fcb8f8c6..e5ef51f9e 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -94,6 +94,7 @@ describe("App, authenticated", () => { pendingTeppRun?: boolean; pluralAffiliations?: boolean; deferMe?: boolean; + preferredLocale?: string | null; meFailed?: boolean; postBody?: string; manyCustomerHints?: number; @@ -156,6 +157,7 @@ describe("App, authenticated", () => { user_account_id: options?.admin ? "acct-admin" : "acct-1", display_name: options?.admin ? "Demo Admin" : "Demo Analyst", permission_codes: options?.admin ? ["post_read", "post_admin"] : ["post_read"], + preferred_locale: options?.preferredLocale ?? null, corporate_entities: options?.pluralAffiliations ? [ { corporate_entity_id: "corp-demo", entity_name: "Demo Corp" }, @@ -1932,6 +1934,19 @@ describe("App, authenticated", () => { }); }); + it("does not let a late member preference overwrite a new GNB choice", async () => { + const backend = stubBackend({ deferMe: true, preferredLocale: "en" }); + render(); + + const language = await screen.findByRole("combobox", { + name: /language|언어|言語|语言|ngôn ngữ/i, + }); + await userEvent.selectOptions(language, "ja"); + backend.releaseMe(); + + await waitFor(() => expect(language).toHaveValue("ja")); + }); + it("rebuilds lineage when the account has post_admin", async () => { const fetchMock = stubBackend({ admin: true }); render(); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1b5b351ab..5b08329fe 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -96,6 +96,7 @@ import { isSupportedLocale, LOCALE_LABELS, SUPPORTED_LOCALES, + getLocale, setLocale, t, tf, @@ -4581,9 +4582,16 @@ export default function App({ showLabPanels = false }: { showLabPanels?: boolean useEffect(() => { if (!accessToken) return; let active = true; + const localeBeforeMemberFetch = getLocale(); fetchMe(accessToken) .then((member) => { - if (active && isSupportedLocale(member.preferred_locale)) setLocale(member.preferred_locale); + if ( + active && + getLocale() === localeBeforeMemberFetch && + isSupportedLocale(member.preferred_locale) + ) { + setLocale(member.preferred_locale); + } }) .catch(() => undefined); return () => { diff --git a/frontend/src/PostBody.test.tsx b/frontend/src/PostBody.test.tsx index 86beb01c5..c1142399b 100644 --- a/frontend/src/PostBody.test.tsx +++ b/frontend/src/PostBody.test.tsx @@ -58,6 +58,13 @@ describe("PostBody", () => { expect(screen.getByText("Embedded image")).toBeInTheDocument(); }); + it("renders decoded markup as text instead of executable HTML", () => { + render(); + + expect(screen.getByText("alert(1)")).toBeInTheDocument(); + expect(document.querySelector("script")).not.toBeInTheDocument(); + }); + it("renders authoritative LLM structure levels for semantic list units", () => { render( + {/* Keep decoded source as a React text child; never render it as raw HTML. */} {segment.text}

); diff --git a/frontend/src/oidcReturnUrl.test.ts b/frontend/src/oidcReturnUrl.test.ts index 8582445ec..2f643aa1a 100644 --- a/frontend/src/oidcReturnUrl.test.ts +++ b/frontend/src/oidcReturnUrl.test.ts @@ -71,4 +71,15 @@ describe("OIDC return URL handling", () => { expect(restoreOidcReturnUrl(undefined)).toBe("/?post=from-local-storage"); expect(window.localStorage.getItem("lineageweave.oidc.returnUrl")).toBeNull(); }); + + it("preserves the post query and hash through the OIDC callback", () => { + const returnUrl = returnUrlFromLocation({ + pathname: "/", + search: "?post=synthetic-post", + hash: "#evidence", + }); + + expect(returnUrl).toBe("/?post=synthetic-post#evidence"); + expect(restoreOidcReturnUrl({ returnUrl })).toBe(returnUrl); + }); });