Skip to content

Commit 6fa842a

Browse files
fix(app-shell): keep a language picked on the sign-in page when someone else last used the browser (#30397)
<!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wg8EjqGExu8hoAXQLwFvdu5t)_ domain:ui#3 · os-bill · session_01Utp6CbPN5UYMnBaNJPJWfG ## 维护者速读 - 改了什么:在登录页选的语言,换了一个人登录后不再被清掉,进系统后仍是刚选的语言。 - 为什么改:共用电脑上,上一个人登录过,下一个人在登录页选法语,进去却变回中文,选择还被删掉(#30338 第 3 行)。 - 风险与回滚:只改前端一个文件;上一个人留下的语言照旧清除(objectui#10193 不变)。回滚即撤本 PR。 - 先看:`useUserLocale.ts` 里新增的 `keepSignedOutPickThroughOwnerChange`,以及 `useLanguageSelection` 未登录分支。 - 两分钟验证:浏览器先用 A 登录再退出,在登录页选 Français,用 B 登录,页面应是法语。 - 需要你做的:无,席位落地。 Refs #30338 (item: keep the sign-in choice) Clause-②: no Metadata-protocol: none ## What changes `@object-ui/app-shell`, `useUserLocale.ts` only. - `useLanguageSelection`, signed out, now remembers the picked language for this page-load (module scope, memory only) and installs one listener on `subscribeSessionOwnerChange`. - When `SessionUserScope.adopt` drops the previous owner's state (the event fires after the sweep), the listener writes the remembered pick back into the existing `objectui-locale` slot. The next page-load boots from it like any choice. - The pick is handed to one sign-in only: the listener consumes it, a signed-in switch forgets it, and the shell's first successful `sys_user` read forgets it, so it cannot reach a later owner. No new storage key, no new export from the package barrel, no request added. ## Why Measured on `origin/main` `fdad1786`, the card's premise does not hold: `useLanguageResetOnOwnerChange` is not on the failing path. It runs inside `useSignedInUserLocale`, which the console mounts only in `AppContent` (the `/apps/:appName/*` route), and the sign-in page is outside it. The drop is in the sign-in page-load itself: 1. The footer `LocaleSwitcher` switch persists `objectui-locale` through the i18n provider's `languageChanged` choke point. 2. `signIn` sets the user; `AuthProvider` calls `SessionUserScope.adopt(B)`; the persisted pointer names A, so `purgePreviousUserClientState` sweeps every key outside `DEVICE_SCOPED_KEYS`, the pick included. 3. `LoginPage`'s post-login effect leaves by `window.location.assign`. The next page-load has no choice and no seed, so it boots in the browser language. Its pointer already names B, so no owner change fires there. The card asked whether the previous person's residue and a pick made after they signed out can be told apart without a new stored marker. For keeping the pick, they can: the pick and the sweep happen in the same page-load, so the pick is known in memory. objectui#10193 ruling B is unchanged: with no pick since, the previous person's language is still swept (pinned below). ## Acceptance notes - **Bound:** memory lasts one page-load. A pick followed by a reload of the sign-in page, or by a sign-in that leaves the page first (an identity provider's redirect), is still swept. - **Not in this PR, by the seat's ruling:** "saved as their own preference if they have none". The knowledge that the slot came from a sign-in pick ends at the post-login full navigation, and in the next page-load "column unset and slot set" is not a usable signal: the profile card's "use the deployment default" writes `locale: null` and leaves the slot set, so saving the slot there would undo that choice on every boot. The seat routes the save half to its own card (await the existing `sys_user` save before the sign-in page navigates), blocked by #30157, which holds the sign-in page files. A new stored marker was not adopted. The save half is #30399. - **Sign-in language list left as it is.** Signed out, the console requests no list (objectui#12034), so the menu offers the built-in packs. #30001's `PLATFORM_LOCALES` is a static platform table, not the deployment's list (the card's deployment offers `vi`, which neither the table nor the built-ins have), and moving the menu onto it is phase 4 of #29428, its own card. The fix does not need the list. Row 4 measures unchanged (below). - A column the person already has still wins after sign-in, as objectui#10059 rules; the kept pick only decides while the column is unset. ## Tests New `packages/app-shell/src/hooks/__tests__/userLocaleSignInPick-30338.test.tsx`, five cases: B picks then signs in (pick stored, A's seed swept); control with no pick (A's language swept, ruling B); the pick does not come back at a later owner change; a signed-in switch replaces it; the shell mounted with no navigation keeps it live and stored after the reset. <details><summary>vitest, the locale hook files and the LocaleSwitcher layout suite (merged head 1ec0fe4)</summary> ``` pnpm exec vitest run --maxWorkers=2 packages/app-shell/src/hooks/__tests__/userLocaleSignInPick-30338.test.tsx packages/app-shell/src/hooks/__tests__/userLocaleOwnerChange-10193.test.tsx packages/app-shell/src/hooks/__tests__/userLocale-10059.test.tsx packages/app-shell/src/layout/ Test Files 143 passed (143) Tests 926 passed (926) os-verify-lock: VERDICT command-exit 0 ``` </details> <details><summary>Ablation: useUserLocale.ts set to origin/main, new file run, then restored from HEAD (hash checked, git diff HEAD empty)</summary> ``` × B signs in after picking: the pick stays stored for the next page-load, A’s seed does not × the pick goes to the next sign-in only: it does not come back at a later change of owner × with the shell mounted (no navigation), B keeps the pick live and stored after the reset Test Files 1 failed (1) Tests 3 failed | 2 passed (5) os-verify-lock: VERDICT command-exit 1 ``` The two that stay green are the ruling-B control and the signed-in-switch guard, both expected to pass with or without the fix. </details> <details><summary>Type-check</summary> ``` pnpm exec turbo run type-check --filter=@object-ui/app-shell --concurrency=2 Tasks: 68 successful, 68 total os-verify-lock: VERDICT command-exit 0 ``` `@object-ui/app-shell`'s `type-check` runs `tsc --noEmit` and `tsc -p tsconfig.test.json`, so the new test file is type-checked. </details> ## Verification Local boot: `examples/app-showcase` `objectstack dev --seed-admin --fresh`, console from `ui/apps/console` via vite, Playwright with browser language `zh-CN`. A = the seeded admin, B = a second user. Each row is a fresh browser context; "signed out" is the auth sign-out call plus the session token and cookies removed (the owner pointer stays, as on a real sign-out). The before run swapped `useUserLocale.ts` to `origin/main` and restored it from HEAD. | Case | Choice | Before (origin/main) | After (this PR) | |:--|:--|:--|:--| | Same person who last used the browser | Français | fr, slot fr | fr, slot fr | | Clean browser | Français | fr, slot fr | fr, slot fr | | A different person last used the browser | Français | **zh-CN, slot removed** | **fr, slot fr** (and fr after a reload) | | Control: different person, no pick (A had picked Deutsch) | none | zh-CN, slot removed | zh-CN, slot removed | | Clean browser | Deutsch | de; locales en, zh-CN | de; locales en, zh-CN (unchanged, list left alone) | `sys_user.locale` read back empty in every row before and after: the save half is not in this PR. <details><summary>Derived gates</summary> On head `1ec0fe4c` (after merging `origin/main` `870ebce3`), `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, each exit code captured before any pipe: ``` exit 0 node scripts/check-adr-0087-registration.mjs --base origin/main exit 0 node scripts/check-adr-0087-registration.mjs --self-test exit 0 node scripts/check-changeset-no-major.mjs --base origin/main exit 0 node scripts/check-closing-keyword-parity.mjs exit 0 node scripts/check-closing-keyword-parity.mjs --self-test exit 0 node scripts/check-comment-mask-corpus.mjs exit 0 node scripts/check-empty-changeset.mjs --base origin/main exit 0 node scripts/check-empty-changeset.mjs --self-test exit 0 node scripts/check-sdui-manifest.mjs exit 0 node scripts/check-sdui-manifest.mjs --self-test exit 0 node scripts/pm/release-rehearsal-clone.mjs --self-test exit 0 node scripts/release-pending-publish.mjs --self-test exit 0 pnpm --dir ui exec vitest run scripts/ exit 0 pnpm --filter @objectstack/spec run check:duration-unit-keys exit 0 pnpm check:changeset-gate-self-tests exit 0 pnpm check:closed-ceremony-text exit 0 pnpm check:cross-package-test-inputs exit 0 pnpm check:dispatcher-error-vocabulary exit 0 pnpm check:driver-memory-census exit 0 pnpm check:engine-double-contract exit 0 pnpm check:gitlink-declared exit 0 pnpm check:nul-bytes exit 0 pnpm check:published-files exit 0 pnpm check:query-options-erasure exit 0 pnpm check:refd-timer-probe exit 0 pnpm check:registry-log-declared exit 0 pnpm check:rest-log-spy-declared exit 0 pnpm check:skill-identifier-liveness exit 0 pnpm check:test-source-alias exit 0 pnpm check:tier-file-adoption exit 0 pnpm check:type-check-coverage exit 0 pnpm check:type-check-debt exit 0 pnpm check:undeclared-dep-imports exit 0 pnpm check:watch-hint-literal exit 0 pnpm check:where-matcher ``` `dispatch-gates --ran`: 35 derived famil(ies) accounted for, 35 run, 0 NOT-MEASURED. The type-check lanes, wide-population and roster families are CI's run. main was red at dispatch on `check:liveness` (`query/searchFields`, #30367, fix in PR #30368, not landed at this merge); a `Lint UI` red on that row is not this PR's. </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (not touched) - [x] 2. Every name I added uses the casing for its kind. - [ ] 3. No formatter ran; `pnpm lint` passes. (no formatter ran; `pnpm lint` is not run locally, it is CI's run) - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. (not touched) - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. (the maintainer brief above is in Chinese by the repo's PR rule) - [x] The *Before Submitting* list in CONTRIBUTING.md is done. --- _Generated by [Claude Code](https://claude.ai/code/session_01Utp6CZGT41gdDg2656as2b)_ <!-- signed: domain:ui#3 · os-bill · session_01Utp6CbPN5UYMnBaNJPJWfG --> Co-authored-by: Claude <noreply@anthropic.com>
1 parent 54b491a commit 6fa842a

3 files changed

Lines changed: 344 additions & 5 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
---
4+
5+
fix: a language picked on the sign-in page is kept when the person who signs in is not the browser's last user (#30338)
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
On a shared browser, signing in as someone other than the last user clears the previous user's saved state, and that sweep also removed the language just picked in the sign-in page's menu, so the person landed in the browser's language. The pick is now remembered for that page-load and put back after the sweep, so the next page opens in the chosen language. What the previous user left is still cleared when nothing was picked. A pick followed by a reload of the sign-in page, or by an identity provider's redirect, is still lost. Saving the pick as the person's own language is a separate change.
Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* objectstack#30338 — a language picked on the sign-in page is kept when the
11+
* person who signs in is not the browser's last owner.
12+
*
13+
* The state pinned is the sign-in page-load: the persisted owner pointer names
14+
* A, A's language is in both device slots, and someone picks a language in the
15+
* sign-in page's menu (signed out, so `useLanguageSelection` is a bare local
16+
* switch). Then B signs in and `SessionUserScope.adopt(B)` runs, exactly as
17+
* `AuthProvider` runs it. The console then leaves by a full navigation, so
18+
* what the next page-load boots from is the STORAGE this page-load leaves —
19+
* that, not the live language, is what these pins read.
20+
*
21+
* objectui#10193 ruling B stands and is pinned beside it: what A left, with no
22+
* pick made since, is still swept.
23+
*/
24+
25+
import '@testing-library/jest-dom/vitest';
26+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
27+
import { render, screen, waitFor, act, fireEvent } from '@testing-library/react';
28+
import {
29+
I18nProvider,
30+
LOCALE_SEED_STORAGE_KEY,
31+
LOCALE_STORAGE_KEY,
32+
useObjectTranslation,
33+
} from '@object-ui/i18n';
34+
// Not on the `@object-ui/auth` barrel; the vitest alias maps that barrel to
35+
// `packages/auth/src`, so this deep path is the SAME module instance the hook's
36+
// `subscribeSessionOwnerChange` registers with.
37+
import { SessionUserScope } from '../../../../auth/src/ActiveOrganizationStorage';
38+
39+
const session = vi.hoisted(() => ({
40+
user: null as { id: string } | null,
41+
adapter: null as unknown,
42+
}));
43+
44+
vi.mock('@object-ui/auth', async (importOriginal) => ({
45+
...(await importOriginal<Record<string, unknown>>()),
46+
useAuth: () => ({
47+
user: session.user,
48+
isAuthenticated: Boolean(session.user),
49+
isLoading: false,
50+
}),
51+
}));
52+
53+
vi.mock('@object-ui/react', async (importOriginal) => ({
54+
...(await importOriginal<Record<string, unknown>>()),
55+
useAdapter: () => session.adapter,
56+
}));
57+
58+
import {
59+
__resetSignedOutPickForTests,
60+
useLanguageSelection,
61+
useSignedInUserLocale,
62+
} from '../useUserLocale';
63+
64+
const USER_A = 'u_alice';
65+
const USER_B = 'u_bob';
66+
const USER_C = 'u_carol';
67+
const POINTER_KEY = 'auth-session-user-id';
68+
/** A's language, and not the browser's (`en`). */
69+
const A_LANGUAGE = 'ja';
70+
/** The language picked on the sign-in page. */
71+
const PICK = 'fr';
72+
73+
function userRow(id: string, locale: string | null) {
74+
return { findOne: vi.fn(async () => ({ id, locale })), update: vi.fn(async () => ({})) };
75+
}
76+
77+
/** The sign-in page's language menu: a switch through `useLanguageSelection`. */
78+
function Menu({ to }: { to: string }) {
79+
const select = useLanguageSelection();
80+
const { language } = useObjectTranslation();
81+
return (
82+
<>
83+
<span data-testid="language">{language}</span>
84+
<button type="button" onClick={() => void select(to)}>
85+
pick
86+
</button>
87+
</>
88+
);
89+
}
90+
91+
/** The shell, for the page-load that does not navigate away. */
92+
function Shell({ to }: { to: string }) {
93+
useSignedInUserLocale();
94+
return <Menu to={to} />;
95+
}
96+
97+
const shownLanguage = () => screen.getByTestId('language').textContent;
98+
const storedChoice = () => window.localStorage.getItem(LOCALE_STORAGE_KEY);
99+
100+
/** The browser A left behind: pointer, explicit slot, seed. */
101+
function leaveAsResidue() {
102+
window.localStorage.setItem(POINTER_KEY, USER_A);
103+
window.localStorage.setItem(LOCALE_STORAGE_KEY, A_LANGUAGE);
104+
window.localStorage.setItem(LOCALE_SEED_STORAGE_KEY, A_LANGUAGE);
105+
}
106+
107+
async function pickOnSignInPage(to = PICK) {
108+
fireEvent.click(screen.getByRole('button', { name: 'pick' }));
109+
await waitFor(() => expect(shownLanguage()).toBe(to));
110+
// The switch persisted itself, as every switch does.
111+
expect(storedChoice()).toBe(to);
112+
}
113+
114+
beforeEach(() => {
115+
window.localStorage.clear();
116+
SessionUserScope._resetForTests();
117+
__resetSignedOutPickForTests();
118+
session.user = null;
119+
session.adapter = null;
120+
});
121+
122+
afterEach(() => {
123+
vi.restoreAllMocks();
124+
});
125+
126+
describe('a language picked on the sign-in page survives a change of owner (objectstack#30338)', () => {
127+
it('B signs in after picking: the pick stays stored for the next page-load, A’s seed does not', async () => {
128+
leaveAsResidue();
129+
render(
130+
<I18nProvider>
131+
<Menu to={PICK} />
132+
</I18nProvider>,
133+
);
134+
expect(shownLanguage()).toBe(A_LANGUAGE);
135+
await pickOnSignInPage();
136+
137+
let purged: unknown;
138+
act(() => {
139+
purged = SessionUserScope.adopt(USER_B);
140+
});
141+
142+
// The purge really ran (A's seed is gone) — and the pick was put back.
143+
expect(purged).toBe(true);
144+
expect(window.localStorage.getItem(LOCALE_SEED_STORAGE_KEY)).toBeNull();
145+
expect(storedChoice()).toBe(PICK);
146+
expect(window.localStorage.getItem(POINTER_KEY)).toBe(USER_B);
147+
});
148+
149+
it('control (objectui#10193 ruling B): with no pick since A, A’s language is still swept', async () => {
150+
leaveAsResidue();
151+
render(
152+
<I18nProvider>
153+
<Menu to={PICK} />
154+
</I18nProvider>,
155+
);
156+
157+
act(() => {
158+
SessionUserScope.adopt(USER_B);
159+
});
160+
161+
expect(storedChoice()).toBeNull();
162+
expect(window.localStorage.getItem(LOCALE_SEED_STORAGE_KEY)).toBeNull();
163+
});
164+
165+
it('the pick goes to the next sign-in only: it does not come back at a later change of owner', async () => {
166+
leaveAsResidue();
167+
render(
168+
<I18nProvider>
169+
<Menu to={PICK} />
170+
</I18nProvider>,
171+
);
172+
await pickOnSignInPage();
173+
act(() => {
174+
SessionUserScope.adopt(USER_B);
175+
});
176+
expect(storedChoice()).toBe(PICK);
177+
178+
// B's page-load ends; C signs in on the same tab without picking anything.
179+
SessionUserScope._resetForTests();
180+
act(() => {
181+
SessionUserScope.adopt(USER_C);
182+
});
183+
184+
expect(storedChoice()).toBeNull();
185+
});
186+
187+
it('a signed-in switch replaces the pick, so the next owner change does not restore it', async () => {
188+
leaveAsResidue();
189+
const { rerender } = render(
190+
<I18nProvider>
191+
<Menu to={PICK} />
192+
</I18nProvider>,
193+
);
194+
await pickOnSignInPage();
195+
196+
// The first owner of a clean pointer is not an owner change: no purge, no
197+
// event. B is signed in and switches to German from the globe.
198+
window.localStorage.removeItem(POINTER_KEY);
199+
act(() => {
200+
SessionUserScope.adopt(USER_B);
201+
});
202+
session.user = { id: USER_B };
203+
session.adapter = userRow(USER_B, null);
204+
rerender(
205+
<I18nProvider>
206+
<Menu to="de" />
207+
</I18nProvider>,
208+
);
209+
await pickOnSignInPage('de');
210+
211+
// C signs in next: neither B's German nor the earlier pick is C's.
212+
SessionUserScope._resetForTests();
213+
act(() => {
214+
SessionUserScope.adopt(USER_C);
215+
});
216+
expect(storedChoice()).toBeNull();
217+
});
218+
219+
it('with the shell mounted (no navigation), B keeps the pick live and stored after the reset', async () => {
220+
leaveAsResidue();
221+
const adapter = userRow(USER_B, null);
222+
const { rerender } = render(
223+
<I18nProvider>
224+
<Shell to={PICK} />
225+
</I18nProvider>,
226+
);
227+
await pickOnSignInPage();
228+
229+
session.user = { id: USER_B };
230+
session.adapter = adapter;
231+
rerender(
232+
<I18nProvider>
233+
<Shell to={PICK} />
234+
</I18nProvider>,
235+
);
236+
act(() => {
237+
SessionUserScope.adopt(USER_B);
238+
});
239+
240+
await waitFor(() => expect(adapter.findOne).toHaveBeenCalled());
241+
await new Promise((r) => setTimeout(r, 50));
242+
expect(shownLanguage()).toBe(PICK);
243+
expect(storedChoice()).toBe(PICK);
244+
});
245+
});

‎ui/packages/app-shell/src/hooks/useUserLocale.ts‎

Lines changed: 89 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -60,9 +60,11 @@
6060
*
6161
* ## Signed out
6262
*
63-
* Nothing here applies: with no `sys_user` row there is nothing to read and
64-
* nothing to write. {@link useLanguageSelection} performs the local switch and
65-
* returns, so language selection for a visitor is exactly what it was.
63+
* With no `sys_user` row there is nothing to read and nothing to write.
64+
* {@link useLanguageSelection} performs the local switch, and it remembers the
65+
* pick for this page-load only, so that the purge above does not take it from
66+
* the person who signs in next (objectstack#30338, see
67+
* {@link keepSignedOutPickThroughOwnerChange}).
6668
*
6769
* @module
6870
*/
@@ -87,6 +89,78 @@ import { useAfterFirstPaint } from './sharedUserFeeds.js';
8789
*/
8890
let ownerChangesHandled = 0;
8991

92+
/**
93+
* The language picked while signed out in THIS page-load and not yet handed to
94+
* whoever signs in next, or `null` (objectstack#30338).
95+
*/
96+
let signedOutPick: string | null = null;
97+
let signedOutPickKeeperInstalled = false;
98+
99+
/**
100+
* Keep a language picked on the sign-in page through the purge that follows a
101+
* sign-in by someone other than the browser's last owner (objectstack#30338).
102+
*
103+
* ## The drop this closes
104+
*
105+
* The sign-in page's language menu writes the pick to the device slot
106+
* ({@link LOCALE_STORAGE_KEY}) like every switch. When the person who then
107+
* signs in is not the one the browser last held, `SessionUserScope.adopt`
108+
* sweeps that slot with the rest of the previous owner's state
109+
* (objectui#5664), and the sign-in page then leaves by a full navigation. The
110+
* next page-load boots with no choice stored, so the person lands in the
111+
* browser's language, not the one they just picked. The reset in
112+
* {@link useLanguageResetOnOwnerChange} is not on that path: the shell is not
113+
* mounted on the sign-in page.
114+
*
115+
* ## Why this keeps objectui#10193 ruling B
116+
*
117+
* The purge removes what the PREVIOUS owner left, and still does. A language
118+
* picked after they signed out is not that, and the two can be told apart
119+
* without a stored marker: the pick and the purge happen in the same
120+
* page-load, so the pick is known here, in memory. The owner-change event
121+
* fires after the sweep, and this puts the pick back into the same slot,
122+
* where the next boot reads it as it reads any choice.
123+
*
124+
* ⚠️ The bound, stated: memory lasts one page-load. A pick followed by a reload
125+
* of the sign-in page, or a sign-in that leaves the page first (an identity
126+
* provider's redirect), is still swept.
127+
*
128+
* Installed once, on the first signed-out pick, at module scope: the sign-in
129+
* page's menu unmounts in the same commit that sets the user, before
130+
* `AuthProvider`'s effect calls `adopt`, so a listener owned by a component
131+
* would be gone when the event fires.
132+
*/
133+
function keepSignedOutPickThroughOwnerChange(code: string): void {
134+
signedOutPick = code;
135+
if (signedOutPickKeeperInstalled) return;
136+
signedOutPickKeeperInstalled = true;
137+
subscribeSessionOwnerChange(() => {
138+
const pick = signedOutPick;
139+
signedOutPick = null;
140+
if (!pick) return;
141+
try {
142+
window.localStorage.setItem(LOCALE_STORAGE_KEY, pick);
143+
} catch {
144+
// Storage blocked — the live language still holds the pick for this
145+
// page-load; nothing more can be kept.
146+
}
147+
});
148+
}
149+
150+
/**
151+
* The pick has reached a signed-in person, or a signed-in choice replaced it:
152+
* either way it must not reach the NEXT owner change, which belongs to
153+
* someone else.
154+
*/
155+
function forgetSignedOutPick(): void {
156+
signedOutPick = null;
157+
}
158+
159+
/** Test seam: forget this page-load's signed-out pick. Not public. */
160+
export function __resetSignedOutPickForTests(): void {
161+
signedOutPick = null;
162+
}
163+
90164
/**
91165
* Serialises every language switch this module makes, so they land in the
92166
* order they were decided. The owner-change reset and the column apply are
@@ -304,6 +378,9 @@ export function useSignedInUserLocale(): void {
304378
if (cancelled) return;
305379
const raw = (row as { locale?: unknown } | null)?.locale;
306380
const column = typeof raw === 'string' && raw.length > 0 ? raw : null;
381+
// This person's session is established and read: a sign-in-page pick
382+
// still held has reached them, and must not reach whoever is next.
383+
forgetSignedOutPick();
307384
setRead((prev) => ({ seq: prev.seq + 1, column }));
308385
} catch (err) {
309386
// Cosmetic and never fail-closed, the same posture the rest of the
@@ -370,7 +447,9 @@ export function useSignedInUserLocale(): void {
370447
* is the divergence this card exists to end, one layer down.
371448
*
372449
* Signed out (or with no adapter): the local switch is the whole operation, no
373-
* request is attempted and nothing is reported.
450+
* request is attempted and nothing is reported. Signed out, the pick is also
451+
* remembered for this page-load, so that a sign-in by someone other than the
452+
* browser's last owner keeps it ({@link keepSignedOutPickThroughOwnerChange}).
374453
*/
375454
export function useLanguageSelection(): (code: string) => Promise<void> {
376455
const { user } = useAuth();
@@ -389,7 +468,12 @@ export function useLanguageSelection(): (code: string) => Promise<void> {
389468
return useCallback(
390469
async (code: string) => {
391470
await changeLanguageRef.current(code);
392-
if (!adapter || !userId) return;
471+
if (!userId) {
472+
keepSignedOutPickThroughOwnerChange(code);
473+
return;
474+
}
475+
forgetSignedOutPick();
476+
if (!adapter) return;
393477
try {
394478
await adapter.update(USER_OBJECT, userId, { [LOCALE_FIELD]: code });
395479
} catch (err) {

0 commit comments

Comments
 (0)